npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aleph-alpha/settings-kit

v1.0.0

Published

Turns a settings schema and layout into accessible admin screens for Vue applications

Readme

@aleph-alpha/settings-kit

Turns two backend payloads into an accessible admin screen for Vue 3 applications built on the Aleph Alpha Design System:

  • Fields (SettingField[]) say what each setting is: type, value, constraints, rules, fallback.
  • Layout (SettingsLayout) says where each setting renders: category › page › section › group › item.

Both are plain JSON. The kit renders them, stages edits in the state manager, validates, and saves through your onSave.

pnpm add @aleph-alpha/settings-kit @aleph-alpha/ui-library vue
pnpm add vue-router # only for '@aleph-alpha/settings-kit/shell'

Contents

More: Overview · Quick start · Components · Generators · Reference

Quick start

<script setup lang="ts">
import { useSettingsForm } from '@aleph-alpha/settings-kit';
import type { SettingField, SettingsLayout } from '@aleph-alpha/settings-kit';
import { SkShell } from '@aleph-alpha/settings-kit/shell';

const props = defineProps<{ fields: SettingField[]; layout: SettingsLayout }>();

const session = useSettingsForm({
  fields: () => props.fields,
  async onSave(changes) {
    await api.saveSettings(changes); // { 'branding.appName': 'Phoenix', … }
  },
});
</script>

<template>
  <SkShell :session="session" :layout="layout" base="/settings" back-href="/" />
</template>

That renders the sidebar navigation, one page per URL (/settings/<category>/<page>), every field with its validation, and the page's Discard / Apply buttons, which call session.discard() and session.save().

Without a router or chrome, render one page with SkLayout:

<SkLayout v-model:page="pageId" :session="session" :layout="layout" />

Layers

Each layer only depends on the ones below it, so you can stop at any level and build the rest yourself.

flowchart TD
  shell["Shell — SkShell, useShellLocation<br/>chrome, navigation, URLs (vue-router peer)"]
  components["Components — SkLayout, SkPage/SkSection/SkGroup/SkItem, SkField<br/>default markup, accessible by default"]
  composables["Composables — useSettingsForm, useSettingsLayout, useSettingField, useFieldValidator<br/>reactive state, no markup"]
  core["Core — resolveLayout, validateLayout, toNavigation, rules, constraints, control registry<br/>plain TypeScript, no Vue reactivity"]
  shell --> components --> composables --> core

| Layer | Use it when | Main exports | | ----------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Core | You need the data model only: a server check, a custom renderer, tests. | resolveLayout, validateLayout, toNavigation, evaluateFieldRules, checkFieldConstraints, provideControls | | Composables | You render your own markup but want the kit's state, rules and validation. | useSettingsForm, createStateManager, useSettingsLayout, useSettingField, useFieldValidator | | Components | You want the default page markup inside your own frame. | SkLayout, SkField, SkPage, SkSection, SkGroup, SkItem | | Shell | You want the whole settings screen: top bar, sidebar navigation and one URL per page. | SkShell from @aleph-alpha/settings-kit/shell |

Core is pure: resolveLayout(layout, { fieldByKey, valueOf }) turns the layout JSON into a ResolvedLayout tree — ordered, filtered by rules, with labels resolved for a locale and ids assigned. Everything above renders that tree; nothing re-implements ordering or rules.

Composables wrap the core in reactivity. useSettingsLayout is the heart of it: it connects a layout to a state manager and returns the resolved pages, the navigation, layout issues and validation errors. See State manager integration.

Components render a resolved page. SkLayout = useSettingsLayout + the page renderer. The four blocks (SkPage, SkSection, SkGroup, SkItem) are presentational and replaceable; SkField renders one setting with its control.

Shell adds the frame and routing. It is a separate entry point because it is the only part that touches vue-router, which is an optional peer.

The schema

Field

A SettingField is one setting. The layout only refers to it by key.

{
  "key": "branding.appName",
  "type": "string",
  "label": { "en": "Application name" },
  "description": { "en": "Shown in the browser tab." },
  "value": "Phoenix chat",
  "default": "",
  "constraints": { "required": true, "maxLength": 30 },
  "presentation": { "control": "text", "charCounter": true },
  "rules": [],
  "errors": { "required": { "en": "Give the application a name." } }
}

| Property | Meaning | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | key | Unique id of the setting. Dots are just characters (branding.appName), not nesting. | | type | Stored value type: string, number, boolean, asset (asset://… or null), or actions (no value — see Actions). | | label | Localised name; required for every type except actions, so every input has an accessible name. | | value | Stored value from the server. Edits are staged separately until saved. | | default | Value used when value is absent. | | editable | false disables the control. | | constraints | Client validation: required, maxLength, pattern, format, enum, mimeTypes. | | errors | Localised message per constraint name, overriding the defaults. | | presentation | How it renders: control, placeholder, charCounter, options (select and actions), inheritLabel/inheritHint (fallback toggle). | | fallback | Inherit another setting's value while a toggle is on. See Fallback. | | rules | Show/hide and enable/disable from other settings' values. See Rules. |

presentation.control picks the control: text, select, checkbox, color, phone, period, image-upload, actions, or any name you register with provideControls. Without one, the type decides (string → text, boolean → checkbox, asset → image upload, actions → actions).

LocalizedText is Record<locale, string>. Call provideSettingsLocale({ locale, fallbackLocale }) to choose the language. The kit uses the first non-empty text in this order: locale, fallbackLocale, en, any translation. See Labels.

Layout: category, page, section, group, item

{
  "categories": [
    {
      "uid": "appearance",
      "label": { "en": "Appearance" },
      "order": 1,
      "pages": [
        {
          "uid": "branding",
          "label": { "en": "Branding" },
          "description": { "en": "How the application looks for your team." },
          "order": 1,
          "presentation": { "sidebar": true, "sidebarMode": "default" },
          "sections": [
            {
              "uid": "general",
              "order": 1,
              "presentation": { "showHeader": false },
              "items": [{ "key": "branding.appName" }]
            },
            {
              "uid": "visual-style",
              "label": { "en": "Visual style" },
              "order": 2,
              "items": [{ "key": "branding.actions" }],
              "groups": [
                {
                  "uid": "colors",
                  "label": { "en": "Colors" },
                  "order": 1,
                  "layout": { "columns": 2 },
                  "items": [
                    { "key": "branding.brandColor" },
                    { "key": "branding.accentColor", "column": 2 }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

| Level | What it is | Renders as | Properties | | ------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Category | A navigation group of pages. Never a screen of its own. | A labelled group in the sidebar navigation. | uid, label, order, pages | | Page | One screen and one URL (/<category>/<page>). Also the save boundary for its actions. | SkPage: a <form> with heading, sections and the actions footer. | uid, label, description?, order, presentation? (showHeader, sidebar, sidebarMode: default 8/4, wide 7/5, max 6/6), sections | | Section | A titled block of a page. Holds items directly, groups, or both. | SkSection: heading + grid. | uid, label?, description?, order, presentation?.showHeader, layout?.columns, items?, groups? | | Group | A labelled cluster inside a section, with its own grid. | SkGroup: sub-heading + grid. | uid, label?, description?, order, presentation?.showHeader, layout?.columns, items | | Item | A placement of one field. The only level that points at a field. | SkItem: one grid cell around SkField. | key, order?, column? (1-based pin) |

  • Labels are optional below the page. A section or group without a label, or with showHeader: false, renders no heading. In the navigation, an unlabelled section or group hands its children to its parent.
  • Grids. layout.columns (default 1) applies from the small breakpoint up; below it every grid is one column, in DOM order. column pins an item to a column at that breakpoint; pins outside 1…columns are dropped.
  • Sidebar. presentation.sidebar reserves the page's content sidebar; the host fills it through the sidebar slot of SkShell.

Actions

The page buttons are a field too, so the backend decides which buttons exist:

{
  "key": "branding.actions",
  "type": "actions",
  "presentation": {
    "options": [
      { "value": "discard", "label": { "en": "Discard" } },
      { "value": "apply", "label": { "en": "Apply changes" } }
    ]
  }
}
  • Place it like any item. Wherever it is placed on a page, it renders once, in that page's footer, not inline. A page without one has no buttons.
  • Each option is a button; its value is the method sent with the action event. discard and apply have built-in behaviour; any other name (export, reset-defaults, …) is yours to handle.
  • The last button is the form's submit button, so Enter in a text field triggers it.
  • rules work as on any field: hidden actions don't render, disabled ones render disabled. All buttons are also disabled while saving.

Fallback

A field can inherit another setting's value while a toggle is on — for example, the favicon reuses the logo:

{
  "key": "branding.favicon",
  "type": "asset",
  "label": { "en": "Favicon" },
  "value": null,
  "fallback": { "key": "branding.logo" },
  "presentation": { "inheritLabel": { "en": "Same as logo" } }
}
  • SkField renders the toggle (labelled inheritLabel) above the control. While it is on, the field shows and saves the source's value; turning it off restores the field's own value.
  • On an asset field, a stored null means "inherit", so the toggle starts on.
  • Fallbacks chain (sales.phone → support.phone → org.phone). session.field(key).inheritedFrom returns the final source.
  • The toggle state lives in the state manager, so it survives page changes and is reset by discard.

Rules

A rule lives on the field it governs and names the controlling setting(s) inside when:

{
  "key": "accessibility.motionLevel",
  "type": "string",
  "label": { "en": "Motion level" },
  "rules": [
    {
      "effect": "SHOW",
      "when": { "key": "accessibility.reduceMotion", "equals": true }
    }
  ]
}

| Effect | Meaning | | --------- | ----------------------------------- | | SHOW | Hidden until the condition holds. | | HIDE | Shown until the condition holds. | | ENABLE | Disabled until the condition holds. | | DISABLE | Enabled until the condition holds. |

  • Conditions compare current, staged values: equals, notEquals, in, notIn, gt, gte, lt, lte, isEmpty, notEmpty (several on one leaf are ANDed), and combine with { allOf: [] }, { anyOf: [] }, { not: {} }. Pure data — nothing is evaluated as code.
  • Several rules all apply: any hiding rule wins over showing, any disabling rule over enabling.
  • In the layout, hidden items are removed from the resolved tree, and a group or section left empty disappears with them. Navigation and validation only see what is visible, so a hidden required field never blocks saving.
  • validateFieldRules(fields) throws on a rule naming an unknown key, and on cycles across rules and fallbacks (A gates B gates A).

Identity, ordering and validation

  • Ids. Every resolved node has an id: the URI-encoded uid path, e.g. appearance/branding/colors. A page id (appearance/branding) is what v-model:page takes and what the URL shows.
  • Uids must be non-empty and unique among siblings. If two siblings share one, the first wins and the rest are skipped.
  • Keys may be placed once in the whole layout. Unknown keys and repeated placements are skipped.
  • Order. Siblings sort by order ascending; ties keep array order. Items without order keep their array position and come before the section's groups; give items an order to interleave them with groups.

validateLayout(layout, fields) reports every one of these as a LayoutIssue ({ code, severity, path, message }) instead of skipping silently. useSettingsLayout(...).issues runs it for you — log it in development or reject the payload server-side.

| Code | Severity | Meaning | | ---------------- | -------- | ------------------------------------------------------------------------------- | | empty-uid | error | A category, page, section or group has an empty uid. | | duplicate-uid | error | Two siblings share a uid; only the first renders. | | unknown-key | error | An item names a key no field has. | | duplicate-key | error | A key is placed twice; only the first renders. | | invalid-column | warning | column is outside the parent's columns; the pin is dropped. | | path-collision | warning | Two page URLs differ only in letter case, so they can't be told apart in a URL. | | unplaced-field | warning | A field is not placed anywhere, so it can never be edited. |

State manager integration

The layout never owns values. Every field the layout shows reads from and stages into one state manager (createStateManager), the same one useSettingsForm creates and provides.

flowchart LR
  api[(Backend)] -- fields --> sm["State manager<br/>useSettingsForm"]
  api -- layout --> sl["useSettingsLayout"]
  sm -- fieldByKey / valueOf --> sl
  sl -- resolved page --> ui["SkLayout / SkShell"]
  ui -- edits --> sm
  ui -- "action: apply" --> sm
  sm -- "onSave(changes)" --> api

SkLayout, SkShell and useSettingsLayout pick the state manager in this order:

  1. the session prop;
  2. the nearest useSettingsForm up the component tree;
  3. a private one built from the fields prop (a quick demo; nothing is saved).

Fields passed through fields that the state manager doesn't know yet are registered into it, so a layout can bring its own fields into a shared form.

Because the state lives in the state manager, not in components:

  • leaving a page and coming back keeps unsaved edits;
  • session.isDirty, session.save(), session.discard() and session.field(key) all reflect what the layout shows;
  • several layouts (or a layout plus your own useSettingField forms) can share one save.

Validation runs over the placed, visible, non-action fields of the layout. Client errors appear once a field is edited, or for every field after a failed Apply. Server issues (model.validator.applyServerIssues(...)) show immediately.

To render your own markup with the same behaviour, use the composable directly:

const model = useSettingsLayout({ layout: () => props.layout, session });

model.pages.value; // ResolvedPage[] in display order
model.page('appearance/branding');
model.navigation.value; // LayoutNavigationNode[] with `invalid` flags
model.issues.value; // LayoutIssue[]
model.errorsByKey.value; // Record<key, string[]> to show right now
model.revealErrors(); // show client errors for untouched fields too

useSettingsLayout reads the locale from provideSettingsLocale in a parent component. If the same component provides the locale, pass it: useSettingsLayout({ layout, session, settingsLocale }), where settingsLocale is the value that provideSettingsLocale returns.

Actions at runtime

Every button press emits one action event from SkLayout / SkShell:

interface SkLayoutActionEvent {
  method: string; // the option value, e.g. 'apply'
  key: string; // the actions field, e.g. 'branding.actions'
  pageId: string; // e.g. 'appearance/branding'
  preventDefault(): void;
  readonly defaultPrevented: boolean;
}

Your listener runs first. Unless it calls preventDefault(), the built-in behaviour follows:

| Method | Built-in behaviour | | --------- | ------------------------------------------------------------------------------------------------- | | discard | session.discard(), clear server issues, hide revealed errors. | | apply | Validate. If invalid, reveal every error. Otherwise session.save() (your onSave), then reset. | | other | Nothing — handle it in your listener. |

A failed save emits error with what onSave threw.

<SkShell
  :session="session"
  :layout="layout"
  @action="onAction"
  @error="toast.error"
/>
function onAction(event: SkLayoutActionEvent) {
  if (event.method === 'apply' && !window.confirm('Publish to all users?')) {
    event.preventDefault();
  }
  if (event.method === 'export') downloadSettings(event.pageId);
}

Routing (SkShell)

SkShell publishes each page at ${base}/<category>/<page>. Paths are always built from the uids; the base prop (default '') is the prefix.

  • With a host vue-router, render SkShell in a route that covers its sub-paths ({ path: '/settings/:page(.*)*', component: SettingsView }). The shell navigates with your router, only while its own route is active, and returns to the actual location if a guard cancels navigation.
  • Without a router, it uses the browser history directly, and never rewrites URLs outside base.
  • Links are real <a href>s, so open-in-new-tab and copy-link work; plain clicks navigate in place.
  • URL matching ignores letter case and a trailing slash, and a non-canonical URL is replaced by the canonical one, keeping ?query and #hash.
  • An unknown path inside base falls back to the first page.
  • v-model:page reads and sets the page id, two-way with the URL.
  • Inside <KeepAlive>, a deactivated shell stops following the URL and catches up when reactivated.

Customisation

Blocks

Replace the component used at any level; the rest keep their defaults:

<SkLayout :layout="layout" :blocks="{ section: MyCard, item: MyItem }" />

A block receives the same props as the default (SkPage, SkSection, SkGroup, SkItem) and renders its default slot. Keep the level prop on your heading so the outline stays sequential.

Controls

Register a control for a presentation.control name. Registrations merge over the parent's, so you can override a built-in or add your own:

import { provideControls } from '@aleph-alpha/settings-kit';

provideControls({
  slider: {
    component: MySlider,
    kind: 'input', // edits a value; 'button' emits `action` instead
    props: (field, { label }) => ({ label, max: field.presentation?.max }),
  },
});

A field with presentation: { control: 'slider' } now renders MySlider with v-model wired by SkField; props maps the schema to the component's props. Unknown names render a visible "unsupported control" placeholder.

Labels

Call provideSettingsLocale({ locale, fallbackLocale, messages }) once, in a parent of the shell. Schema text and the kit wording (en and de) follow it. The chrome text of SkShell (title, back, navigation, "has errors", …) uses the shell.* keys of the kit wording. Set labels to replace single entries. labels wins over messages. defaultShellLabels holds the en text.

Accessibility

  • Heading levels follow the structure: page = headingLevel (default 1), sections one below, groups one below a titled section. Set headingLevel when the shell sits under an existing h1.
  • Each page is a <form novalidate>; errors are announced per field and linked with aria-errormessage.
  • The navigation marks the current page with aria-current="page", and pages with errors get a visible dot plus a screen-reader text ("has errors").
  • Grids reflow to one column at narrow widths and 400% zoom, in DOM order, so the reading order never depends on the column pin.
  • Without backHref and without a back listener, no back control is rendered.

Styles

Components use UnoCSS classes from the Aleph Alpha preset; the kit ships no CSS file. Add the kit's dist to your UnoCSS content so its classes are generated, as you already do for the ui-library:

// uno.config.ts
content: {
  filesystem: [
    './node_modules/@aleph-alpha/ui-library/dist/**/*.js',
    './node_modules/@aleph-alpha/settings-kit/dist/**/*.js',
  ],
},

Entry points

| Import | Contents | Peers | | -------------------------------------- | --------------------------------------------------------- | -------------------------------- | | @aleph-alpha/settings-kit | Core, composables, components and all types. | vue, @aleph-alpha/ui-library | | @aleph-alpha/settings-kit/components | Components only. | same | | @aleph-alpha/settings-kit/shell | SkShell, SkShellFrame, SkShellNav, routing helpers. | + vue-router (optional peer) | | @aleph-alpha/settings-kit/generators | defineSettingUnit, createToggleUnit (alias /units). | none |

Migration

From the earlier preview of the layout builder:

| Before | Now | | ---------------------------------------------- | ------------------------------------------------------------------------------- | | SkCategory, SkCategoryNav, SkFormActions | SkLayout (one page) or SkShell (screen with navigation). | | useBlockGenerator | useSettingsLayout, or resolveLayout in plain TypeScript. | | @discard / @apply events | One @action event with method; built-in handling unless preventDefault(). | | fields prop holding the state | Values live in the state manager (session prop or useSettingsForm). | | URLs by label, SkShell from the root entry | /<category>/<page> from uids; import SkShell from /shell. | | valueFrom on rules, faviconSameAsLogo | fallback: { key } on the inheriting field. | | page.label.en in slots | page.label (already resolved). |

Development

npx nx run settings-kit:storybook       # Storybook on port 6009
npx nx run settings-kit:test            # Unit tests (Vitest)
npx nx run settings-kit:lint            # Prettier + ESLint
npx nx run settings-kit:build           # Library build + type declarations
npx nx run settings-kit:test-storybook  # Storybook interaction & a11y tests
pnpm --filter @aleph-alpha/settings-kit playground  # Live demo against the source

The kit builds against the published @aleph-alpha/ui-library from npm, not the workspace copy, so it always matches what consumers install.

Releasing

Releases are automated with Release Please, like every other package in this repo:

  1. Merge a PR to main with a conventional commit scoped to the kit (feat(settings-kit): …, fix(settings-kit): …). Only commits touching packages/settings-kit/** count.
  2. Release Please opens or updates the chore(release): settings-kit <version> PR with the version bump and CHANGELOG.md entry.
  3. Merging that PR tags settings-kit-<version> and the Release workflow publishes @aleph-alpha/settings-kit to npm.

While the kit is below 1.0.0, a breaking change (feat!: or a BREAKING CHANGE: footer) bumps the major version to 1.0.0.

See GitHub CI Flow for the full pipeline and troubleshooting.