@k2b/ui
Settings
Accessible settings tabs, fields, and save state.
A flat full-page settings shell: one heading, sections separated by space instead of cards and lines, a scrolling body, optional actions, and a fixed save footer. Section actions wrap below the heading only when they do not fit beside it.
Project settings
Identity and defaults
Identity
Public service details
Public service URL
Webhooks
Endpoints that receive project events
Signed deliveries
Each request carries a signature header.
TSX
Copy<SettingsPage title="Project settings" subtitle="Identity and defaults" icon="ti ti-settings" actions={<Button size="sm" variant="secondary">Test connection</Button>} footer={ <SettingsPanelFooter changeCount={() => (changed() ? 1 : 0)} loading={() => false} onDiscard={discard} onSave={save} /> }> <SettingsSection title="Identity" subtitle="Public service details" icon="ti ti-id"> <SettingsField label="Endpoint" description="Public service URL" error={() => undefined} changed={changed}> {(control) => <TextInput aria-label="Endpoint" aria-describedby={control.describedBy()} value={endpoint()} onValueChange={setEndpoint} />} </SettingsField> </SettingsSection> <SettingsSection title="Webhooks" subtitle="Endpoints that receive project events" icon="ti ti-webhook" actions={<Button size="sm" variant="secondary">Add webhook</Button>} > <NoticeCard tone="info" title="Signed deliveries" detail="Each request carries a signature header." /> <Placeholder icon="ti ti-webhook" description="No webhooks yet" /> </SettingsSection></SettingsPage>Grouped category navigation, flat form groups, compact entity collections, boundary-aware reorder controls, and a panel-owned save footer.
Preferences
Defaults for this browser.
Reading
Choose how messages are displayed.
Choose the preferred representation for incoming messages.
Writing
Defaults for new messages and replies.
Used when you open the composer.
Delay delivery so a message can still be recalled.
TSX
Copy<SettingsModal title="Mailbox settings" activeTab={active()} onTabChange={setActive}> <SettingsModal.Group title="Personal"> <SettingsModal.Tab id="preferences" title="Preferences" icon="ti ti-adjustments" description="Defaults for this browser."> <SettingsGroup title="Reading" description="Choose how messages are displayed."> <SettingsField label="Message format" description="Choose the preferred representation for incoming messages." error={() => readingFormat() ? undefined : "Choose a message format."} changed={() => readingFormat() !== savedPreferences().readingFormat}> {(control) => <Select aria-label="Message format" aria-describedby={control.describedBy()} value={readingFormat()} onValueChange={(value) => setReadingFormat(value ?? "")} clearable options={[{ value: "automatic", label: "Automatic — Recommended" }, { value: "html", label: "HTML" }, { value: "plain", label: "Plain text" }]} />} </SettingsField> </SettingsGroup> <SettingsGroup title="Writing" description="Defaults for new messages and replies."> <SettingsField label="Compose format" description="Used when you open the composer." error={() => undefined} changed={() => composeFormat() !== savedPreferences().composeFormat}> <Select aria-label="Compose format" value={composeFormat()} onValueChange={(value) => value && setComposeFormat(value)} options={[{ value: "rich", label: "Rich text" }, { value: "plain", label: "Plain text" }]} /> </SettingsField> <SettingsField label="Undo send" description="Delay delivery so a message can still be recalled." error={() => undefined} changed={() => undoSend() !== savedPreferences().undoSend}> <Select aria-label="Undo send" value={undoSend()} onValueChange={(value) => value && setUndoSend(value)} options={[{ value: "0", label: "Off" }, { value: "5", label: "5 seconds" }, { value: "10", label: "10 seconds" }, { value: "30", label: "30 seconds" }]} /> </SettingsField> </SettingsGroup> <SettingsModal.Footer> <SettingsPanelFooter changeCount={changeCount} loading={loading} saveDisabled={() => !readingFormat()} onDiscard={discard} onSave={save} /> </SettingsModal.Footer> </SettingsModal.Tab> </SettingsModal.Group> <SettingsModal.Group title="Mailbox"> <SettingsModal.Tab id="organization" title="Organization" icon="ti ti-tags" description="Reusable views and shared vocabulary."> <SettingsCollection title="Saved views" description="Reusable filters shown in navigation." empty="No saved views yet."> <SettingsCollection.Action><Button size="xs">New view</Button></SettingsCollection.Action> <SettingsCollection.Item title="Open conversations" description="Private view · 3 filters" icon={<i class="ti ti-filter" />}> <SettingsCollection.Item.Status><StatusBadge tone="neutral" label="Private" variant="text" /></SettingsCollection.Item.Status> <SettingsCollection.Item.Actions> <SettingsCollection.Item.Reorder label="Open conversations" index={0} count={2} onMove={() => {}} /> <IconButton size="xs" label="Edit Open conversations"><i class="ti ti-pencil" /></IconButton> </SettingsCollection.Item.Actions> </SettingsCollection.Item> <SettingsCollection.Item title="Waiting on customer" description="Shared view · 2 filters" icon={<i class="ti ti-filter" />}> <SettingsCollection.Item.Status><StatusBadge tone="neutral" label="Shared" variant="text" /></SettingsCollection.Item.Status> <SettingsCollection.Item.Actions> <SettingsCollection.Item.Reorder label="Waiting on customer" index={1} count={2} onMove={() => {}} /> <IconButton size="xs" label="Edit Waiting on customer"><i class="ti ti-pencil" /></IconButton> </SettingsCollection.Item.Actions> </SettingsCollection.Item> </SettingsCollection> <SettingsCollection title="Conversation tags" description="Shared across views and automations." empty="No tags yet."> <SettingsCollection.Action><Button size="xs" variant="secondary">Add tag</Button></SettingsCollection.Action> </SettingsCollection> </SettingsModal.Tab> <SettingsModal.Tab id="delivery" title="Delivery" icon="ti ti-send" description="Accounts and sender identities."> <SettingsCollection title="Connected accounts" empty="No account connected."> <SettingsCollection.Item title="support@example.test" description="IMAP and SMTP" icon={<i class="ti ti-mail" />}> <SettingsCollection.Item.Status><StatusBadge tone="ok" label="Connected" /></SettingsCollection.Item.Status> <SettingsCollection.Item.Actions><Button size="xs" variant="ghost">Manage</Button></SettingsCollection.Item.Actions> </SettingsCollection.Item> </SettingsCollection> </SettingsModal.Tab> </SettingsModal.Group> <SettingsModal.Group title="Lifecycle"> <SettingsModal.Tab id="danger" title="Danger zone" icon="ti ti-alert-triangle" tone="danger"> <SettingsGroup title="Disable mailbox" description="Stop new mail without deleting retained messages."> <SettingsGroup.Action><Button size="sm" variant="danger">Disable mailbox</Button></SettingsGroup.Action> </SettingsGroup> </SettingsModal.Tab> </SettingsModal.Group></SettingsModal>A sticky page-level save bar that appears only while settings have unsaved changes.
TSX
Copy<SettingsSaveBar changeCount={() => 2} loading={() => false} onDiscard={discard} onSave={save}/>SettingsPage is the flat shell for a dedicated settings route. It owns one
page heading, a scrolling content region, optional header actions, and an
optional fixed footer. It deliberately adds no outer card or dialog frame.
SettingsModal is a portable tabbed settings surface. It owns category
navigation, keyboard behavior, and the active panel. The application owns
loading, form state, validation, persistence, and the surrounding dialog or
page.
The active category uses --k2b-app-workspace-selected with --k2b-app-workspace-active
ink, without changing its text weight.
Use SettingsModal
Use SettingsPage for a full settings route. Use SettingsModal when one
resource has several settings categories. Use PanelDialog for an actual
dialog or complex embedded editor and prompts.form for a small prompt.
Import
import {
Button,
readSettingsError,
sameSettingValue,
SettingsCollection,
SettingsField,
SettingsGroup,
SettingsModal,
SettingsPage,
SettingsPanelFooter,
SettingsSaveBar,
SettingsSection,
TextInput,
} from "@k2b/ui";Compose a full settings page
Keep the page flat: place sections directly in SettingsPage, and put save
state in footer. Do not wrap a dedicated route in PanelDialog merely to
create another outer surface.
<SettingsPage
title="Project settings"
subtitle="Identity and defaults"
icon="ti ti-settings"
actions={<Button variant="secondary">Test connection</Button>}
footer={<SettingsPanelFooter {...saveState} />}
>
<SettingsSection title="Identity" icon="ti ti-id">
<SettingsField {...fieldProps}>…</SettingsField>
</SettingsSection>
</SettingsPage>SettingsSection groups one coherent set of page settings. It renders like
SettingsGroup: a heading, an optional subtitle, and its fields directly on
the page surface, with no card, header rule, or lines between fields. About
2rem of space separates sections, so the page stays the only frame, and the
page title sits one size step above the section titles. Header actions stay
beside the heading while it keeps about 16rem and wrap below it otherwise, so
a single icon action still fits on a phone. Actions that do not fit on one
row wrap onto further rows and never reach past the section's edge. The
section icon and its heading always share a line. Keep PanelDialog.Section inside dialogs; it
intentionally has a different containment contract.
Put a shared action, such as a documentation link, once in the page
actions. Give a section its own action only when it leads somewhere the page
action does not, such as a different article rather than an anchor in the
page's article, and prefer a compact IconButtonLink with a label that names
the section.
A SettingsSection used outside a SettingsPage, for example between framed
blocks on a detail page, still draws no card. Wrap it in Paper there when
its neighbours are framed.
Inside SettingsModal, use SettingsGroup for a flat form group and
SettingsCollection for compact entity management. Neither component owns a
backend or adds another paper inside the modal.
Compose controlled tabs
Compose categories with SettingsModal.Tab. Each tab needs a stable id,
title, and children. Wrap related tabs in SettingsModal.Group to add a short
rail label without changing tab selection or keyboard order. Below 48rem the
categories become one horizontally scrolling tab row above the panel, with the
close action beside the row instead of over the panel; group labels show only
in the side rail. The categories stay visible instead of becoming a separate
select. When the modal opens or the selection changes, the selected category
scrolls into view in the row or rail.
activeTab and onTabChange make selection controlled; use defaultTab for
local selection. An optional onClose adds a close action without deciding how
the surrounding surface is opened. The close action is a ghost IconButton, so
it has the shared touch target on
phones and tablets.
Place SettingsModal.Footer inside a tab when that category has a persistent
status and action row. The modal keeps the footer outside the scrolling panel;
the application still owns dirty state, saving, discarding, and navigation
guards.
Only the panel and the category list scroll; the modal's frame stays in place.
The modal fills the height of its surrounding surface. In a bare dialog, wrap it
in an element with a fixed height, for example height: min(86dvh, 40rem), as
described under custom dialogs.
The panel is the containing block for a tab's content. Absolutely positioned
content without a positioned ancestor of its own, such as a visually hidden
input, therefore scrolls with the panel and comes into view when it takes
focus.
Use category titles, descriptions, and icons to provide visible context.
Compose flat groups and collections
SettingsGroup owns one subsection heading, description, named action slot,
and a quiet content rhythm. Use SettingsGroup.Action for visual actions
instead of passing JSX through an action prop.
SettingsCollection owns one entity-list heading, compact empty state, and
semantic list. Add its primary action through SettingsCollection.Action.
Each SettingsCollection.Item accepts scalar title and description data; put
visual status and controls in SettingsCollection.Item.Status and
SettingsCollection.Item.Actions.
Use SettingsCollection.Item.Reorder inside the actions slot for an ordered
collection. Pass the current zero-based index, total count, and an onMove
callback. It owns accessible move-up and move-down controls and disables moves
at the collection boundaries. The application still owns the reordered state,
persistence, optimistic updates, rollback, and announcements after a move.
The collection does not create, update, delete, sort, authorize, or confirm
anything. The application supplies those behaviors through the controls it
composes into the named slots. Use a specialized editor such as TagEditor or
PermissionEditor when that complete interaction already exists.
Compose form state
SettingsField groups a label, required description, reactive error accessor,
optional reactive dirty accessor, and a control. Use its render-function child
and pass describedBy to the control's aria-describedby so screen readers
announce the visible description and current validation error.
SettingsSaveBar uses reactive changeCount and loading accessors. It
appears only while the count is greater than zero and disables its actions
while loading. Use reactive saveDisabled when validation should disable Save
without preventing Discard.
SettingsPanelFooter provides the same status and actions for a surrounding
panel footer. It remains visible with No unsaved changes, disables both
actions until a change exists, and accepts saveVariant for the shared button
hierarchy. It also accepts saveDisabled for field validation.
sameSettingValue performs the JSON-based, order-sensitive comparison used by
settings forms. readSettingsError(response, fallback) reads the shared
message and per-field errors response shape. These helpers do not perform a
request or select a persistence backend.
API reference
type SettingsModalTabTone = "default" | "danger";
type SettingsModalProps = {
title: string; subtitle?: string; icon?: string; defaultTab?: string; activeTab?: string;
onTabChange?: (id: string) => void; onClose?: () => void; closeLabel?: string; class?: string;
children: JSX.Element;
};
type SettingsModalTabProps = {
id: string; title: string; description?: string; icon?: string; tone?: SettingsModalTabTone;
children: JSX.Element;
};
type SettingsModalGroupProps = {
title: string; children: JSX.Element;
};
type SettingsModalFooterProps = {
children: JSX.Element;
};
Settings content
type SettingsPageProps = {
title: JSX.Element; subtitle?: JSX.Element; icon?: string; actions?: JSX.Element; children: JSX.Element;
footer?: JSX.Element; scrollPreserveKey?: string; class?: string; style?: JSX.CSSProperties | string;
};
type SettingsSectionProps = {
title: JSX.Element; subtitle?: JSX.Element; icon?: string; actions?: JSX.Element; children: JSX.Element;
class?: string;
};
type SettingsGroupProps = {
title: JSX.Element; description?: JSX.Element; children: JSX.Element; class?: string;
};
type SettingsGroupActionProps = {
children: JSX.Element;
};
type SettingsFieldProps = {
label: string; description: string; error: MaybeAccessor<string | undefined>;
changed?: MaybeAccessor<boolean>;
children: JSX.Element | ((control: SettingsFieldControlProps) => JSX.Element); class?: string;
};
type SettingsFieldControlProps = {
describedBy: () => string;
};
type SettingsSaveBarProps = {
changeCount: MaybeAccessor<number>; loading: MaybeAccessor<boolean>; saveDisabled?: MaybeAccessor<boolean>;
onDiscard: () => void; onSave: () => void; saveLabel?: string; saveVariant?: ButtonVariant; class?: string;
};
type SettingsPanelFooterProps = SettingsSaveBarProps;
Collections
type SettingsCollectionProps = {
title: JSX.Element; description?: JSX.Element; empty?: JSX.Element; children?: JSX.Element; class?: string;
};
type SettingsCollectionActionProps = {
children: JSX.Element;
};
type SettingsCollectionItemProps = {
title: JSX.Element; description?: JSX.Element; icon?: JSX.Element; children?: JSX.Element;
};
type SettingsCollectionItemStatusProps = {
children: JSX.Element;
};
type SettingsCollectionItemActionsProps = {
children: JSX.Element;
};
type SettingsCollectionItemReorderProps = {
label: string; index: number; count: number; disabled?: boolean; onMove: (direction: -1 | 1) => void;
};MaybeAccessor<T> means T | (() => T); use accessors where the types require them. Compound slots correspond to their full prop names, e.g. SettingsCollection.Item.Reorder. DropdownItem is defined under menus. A SettingsField child must give the actual input an accessible name and forward describedBy() to associate its description/error. SettingsModal.subtitle and icon are compatibility props; use tab descriptions/icons for visible context.
sameSettingValue(left: unknown, right: unknown): boolean compares JSON serialization, so use it for JSON-compatible setting values. readSettingsError(response: Response, fallback: string) resolves to { message: string; fields: Record<string, string> }, reading message and errors from response JSON; malformed JSON uses the fallback and empty fields.
Accessibility
The category rail is a tab list. Group labels are presentational and do not add
keyboard stops. Arrow keys move between all tabs; Home and End move to the
first and last tab across groups. Every tab needs a stable id and concise
title. Use tone="danger" only for destructive settings.
SettingsGroup, SettingsCollection, and collection items create their own
heading and list semantics. Visual collection actions still need the
accessible names required by their underlying controls.
Errors render as alerts, and the dirty state includes an explicit Unsaved
label rather than relying on color.
Runtime
The active panel renders on the server. Tab selection, close controls, form callbacks, and saving require browser code.
Example
import { SettingsField, SettingsGroup, SettingsModal, SettingsSaveBar, TextInput } from "@k2b/ui";
import { createSignal } from "solid-js";
export function ConnectionSettings(props: {
initialEndpoint: string;
save: (endpoint: string) => Promise<void>;
}) {
const [saved, setSaved] = createSignal(props.initialEndpoint);
const [endpoint, setEndpoint] = createSignal(props.initialEndpoint);
const [error, setError] = createSignal<string>();
const [loading, setLoading] = createSignal(false);
const changed = () => endpoint() !== saved();
const save = async () => {
setLoading(true);
setError(undefined);
const value = endpoint();
try {
await props.save(value);
setSaved(value);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Save failed");
} finally {
setLoading(false);
}
};
return (
<SettingsModal title="Connection settings">
<SettingsModal.Tab id="connection" title="Connection">
<SettingsGroup title="Service">
<SettingsField label="Endpoint" description="Public service URL" error={error} changed={changed}>
{({ describedBy }) => <TextInput aria-label="Endpoint"
aria-describedby={describedBy()} value={endpoint()} onValueChange={setEndpoint}
disabled={loading()} />}
</SettingsField>
</SettingsGroup>
<SettingsSaveBar changeCount={() => changed() ? 1 : 0} loading={loading}
onSave={() => void save()} onDiscard={() => { setEndpoint(saved()); setError(undefined); }} />
</SettingsModal.Tab>
</SettingsModal>
);
}