@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
- Quick start
- Layers
- The schema
- State manager integration
- Actions at runtime
- Routing (SkShell)
- Customisation
- Accessibility
- Styles
- Entry points
- Migration
- Development
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 withshowHeader: 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.columnpins an item to a column at that breakpoint; pins outside1…columnsare dropped. - Sidebar.
presentation.sidebarreserves the page's content sidebar; the host fills it through thesidebarslot ofSkShell.
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
valueis the method sent with the action event.discardandapplyhave 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.
ruleswork 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" } }
}SkFieldrenders the toggle (labelledinheritLabel) 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
assetfield, a storednullmeans "inherit", so the toggle starts on. - Fallbacks chain (
sales.phone→support.phone→org.phone).session.field(key).inheritedFromreturns 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 whatv-model:pagetakes 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
orderascending; ties keep array order. Items withoutorderkeep their array position and come before the section's groups; give items anorderto 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)" --> apiSkLayout, SkShell and useSettingsLayout pick the state manager in this order:
- the
sessionprop; - the nearest
useSettingsFormup the component tree; - a private one built from the
fieldsprop (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()andsession.field(key)all reflect what the layout shows;- several layouts (or a layout plus your own
useSettingFieldforms) 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 toouseSettingsLayout 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, renderSkShellin 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
?queryand#hash. - An unknown path inside
basefalls back to the first page. v-model:pagereads 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. SetheadingLevelwhen the shell sits under an existingh1. - Each page is a
<form novalidate>; errors are announced per field and linked witharia-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
backHrefand without abacklistener, 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 sourceThe 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:
- Merge a PR to
mainwith a conventional commit scoped to the kit (feat(settings-kit): …,fix(settings-kit): …). Only commits touchingpackages/settings-kit/**count. - Release Please opens or updates the
chore(release): settings-kit <version>PR with the version bump andCHANGELOG.mdentry. - Merging that PR tags
settings-kit-<version>and theReleaseworkflow publishes@aleph-alpha/settings-kitto 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.
