entasis 0.13.2 → 0.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Form/Ask/Ask.svelte +26 -16
- package/dist/components/Form/Ask/ask.mcp.d.ts +1 -1
- package/dist/components/Form/Ask/ask.mcp.js +1 -1
- package/dist/components/Form/Ask/ask.props.d.ts +4 -1
- package/dist/components/Form/Form/form.mcp.d.ts +1 -1
- package/dist/components/Form/Form/form.mcp.js +1 -1
- package/dist/components/Sidebar/SidebarMenuItem.svelte +47 -4
- package/dist/components/Sidebar/SidebarMenuSubItem.svelte +40 -3
- package/dist/components/Sidebar/sidebar.attributes.d.ts +1 -0
- package/dist/components/Sidebar/sidebar.attributes.js +1 -0
- package/dist/components/Sidebar/sidebar.mcp.d.ts +1 -1
- package/dist/components/Sidebar/sidebar.mcp.js +5 -1
- package/dist/components/Sidebar/sidebar.props.d.ts +16 -0
- package/dist/components/Sidebar/sidebar.suffix.svelte.d.ts +13 -0
- package/dist/components/Sidebar/sidebar.suffix.svelte.js +24 -0
- package/dist/components/Sidebar/sidebar.theme.d.ts +60 -0
- package/dist/components/Sidebar/sidebar.theme.js +75 -3
- package/package.json +1 -1
|
@@ -144,25 +144,35 @@
|
|
|
144
144
|
closeOnClickOutside={false}
|
|
145
145
|
swipeToDismiss={false}
|
|
146
146
|
theme={{
|
|
147
|
-
header: { base: '-mx-4 px-4' },
|
|
148
|
-
footer: { base: 'border-neutral-muted -mx-4 mt-2 border-t px-4 pt-3' }
|
|
147
|
+
header: { base: '-mx-4 shrink-0 px-4' },
|
|
148
|
+
footer: { base: 'border-neutral-muted -mx-4 mt-2 shrink-0 border-t px-4 pt-3' }
|
|
149
149
|
}}
|
|
150
150
|
onAfterClose={() => finishClose(askState)}
|
|
151
151
|
>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
152
|
+
<!-- The form is the body, and the only part that scrolls: with `scroll: 'inner'` the card is
|
|
153
|
+
capped to the viewport, so a tall form shrinks here and the title, description and
|
|
154
|
+
buttons stay on screen. It runs to the card's edges (`-mx-4 px-4`, like the header and
|
|
155
|
+
footer) with a little room above and below, so a full-width field's focus ring is never
|
|
156
|
+
clipped. With `scroll: 'outer'` nothing caps it and it never scrolls. -->
|
|
157
|
+
<div
|
|
158
|
+
data-slot="ask-body"
|
|
159
|
+
class="-mx-4 -my-1 min-h-0 overflow-y-auto overscroll-contain px-4 py-1"
|
|
160
|
+
>
|
|
161
|
+
<Form
|
|
162
|
+
inputs={askState.options.inputs}
|
|
163
|
+
onSubmit={(value) => completeAskSubmission(askState, value)}
|
|
164
|
+
bind:value={askState.value}
|
|
165
|
+
bind:form={askState.form}
|
|
166
|
+
class={askState.options.class}
|
|
167
|
+
size={askState.options.size}
|
|
168
|
+
density={askState.options.density}
|
|
169
|
+
variant={askState.options.variant}
|
|
170
|
+
layout={askState.options.layout}
|
|
171
|
+
validationMode={askState.options.validationMode}
|
|
172
|
+
validationDebounce={askState.options.validationDebounce}
|
|
173
|
+
theme={askState.options.theme}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
166
176
|
|
|
167
177
|
{#snippet footer()}
|
|
168
178
|
<!-- The button row lays itself out against the dialog footer's own width, not the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const askDescription = "\n# Ask\n\nImport from `entasis/ask`.\n\nMount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog, including input types the app registered with registerFormInputs (see Form), so a request can arrive as plain data. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Fields validate as in Form (validationMode and validationDebounce are accepted, async validators are awaited), disabled fields are left out of the result, and Confirm marks itself loading rather than natively disabled while the form submits, so it keeps keyboard focus. Mount the host before requesting a form.\n";
|
|
1
|
+
export declare const askDescription = "\n# Ask\n\nImport from `entasis/ask`.\n\nMount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog, including input types the app registered with registerFormInputs (see Form), so a request can arrive as plain data. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Fields validate as in Form (validationMode and validationDebounce are accepted, async validators are awaited), disabled fields are left out of the result, and Confirm marks itself loading rather than natively disabled while the form submits, so it keeps keyboard focus. Mount the host before requesting a form. A form taller than the viewport scrolls in its own body (`data-slot=\"ask-body\"`), so the title, description and buttons stay on screen; pass `dialog: { scroll: 'outer' }` to scroll the whole card instead.\n";
|
|
@@ -3,5 +3,5 @@ export const askDescription = `
|
|
|
3
3
|
|
|
4
4
|
Import from \`entasis/ask\`.
|
|
5
5
|
|
|
6
|
-
Mount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog, including input types the app registered with registerFormInputs (see Form), so a request can arrive as plain data. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Fields validate as in Form (validationMode and validationDebounce are accepted, async validators are awaited), disabled fields are left out of the result, and Confirm marks itself loading rather than natively disabled while the form submits, so it keeps keyboard focus. Mount the host before requesting a form.
|
|
6
|
+
Mount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog, including input types the app registered with registerFormInputs (see Form), so a request can arrive as plain data. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Fields validate as in Form (validationMode and validationDebounce are accepted, async validators are awaited), disabled fields are left out of the result, and Confirm marks itself loading rather than natively disabled while the form submits, so it keeps keyboard focus. Mount the host before requesting a form. A form taller than the viewport scrolls in its own body (\`data-slot="ask-body"\`), so the title, description and buttons stay on screen; pass \`dialog: { scroll: 'outer' }\` to scroll the whole card instead.
|
|
7
7
|
`;
|
|
@@ -16,7 +16,10 @@ export type AskOptions<I extends FormInputs> = Pick<FormProps<I>, 'inputs' | 'va
|
|
|
16
16
|
confirm: AskButton;
|
|
17
17
|
/** Cancel action label or protected Button presentation props. */
|
|
18
18
|
cancel: AskButton;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Dialog presentation options. Dismissal behavior remains owned by Ask. With `scroll: 'inner'`
|
|
21
|
+
* (the default) only the form scrolls, so the header and the buttons stay on screen.
|
|
22
|
+
*/
|
|
20
23
|
dialog?: AskDialogOptions;
|
|
21
24
|
};
|
|
22
25
|
export type AskResult<I extends FormInputs> = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const formDescription = "\n# Form\n\nForm renders configured entasis fields inside a div, owns their state and validation, and submits programmatically. It provides managed Enter-key navigation and submission, but not native form events, browser constraint validation, or FormData behavior. For a plain <form> (a SvelteKit form action), use the controls directly: each submits its name and value through hidden inputs, follows the form's reset, and accepts a form prop when it sits outside the form.\n\n## Basic usage\n\n```svelte\n<script lang=\"ts\">\n\timport {\n\t\tForm,\n\t\ttype FormState,\n\t\ttype InferFormValue,\n\t\ttype LiveFormValue\n\t} from 'entasis/form';\n\n\tconst inputs = {\n\t\tidentity: {\n\t\t\ttype: 'group',\n\t\t\tlabel: 'Identity',\n\t\t\tdescription: 'Core account details',\n\t\t\tcolumns: 2,\n\t\t\tinputs: {\n\t\t\t\tname: { type: 'text', label: 'Name', required: true },\n\t\t\t\temail: { type: 'email', label: 'Email', required: true }\n\t\t\t}\n\t\t}\n\t} as const;\n\n\tlet value = $state<LiveFormValue<typeof inputs>>({ name: 'Ada' });\n\tlet form = $state<FormState<typeof inputs>>();\n\tlet submitted = $state<InferFormValue<typeof inputs> | null>(null);\n</script>\n\n<Form\n\t{inputs}\n\tbind:value\n\tbind:form\n\tvariant=\"card\"\n\tlayout=\"horizontal\"\n\tsize=\"small\"\n\tdensity=\"normal\"\n\tactions={[\n\t\t{\n\t\t\tchildren: 'Save',\n\t\t\tonAction: (payload) => payload.submit()\n\t\t}\n\t]}\n\tonSubmit={(validatedValue) => {\n\t\tsubmitted = validatedValue;\n\t}}\n/>\n```\n\n## Props\n\n- **inputs** (required): ordered object keyed by entry name. Entries may be value-bearing fields, visual groups, action-button rows, or custom snippets. Only fields contribute keys to the live and validated values.\n- **value** (bindable): controlled partial live value. Visible mounted fields are published; hidden fields are omitted. A supplied top-level value takes precedence over an input's configured value.\n- **defaultValue**: initial partial live value when value is omitted.\n- **onValueChange**: called once when a user changes the visible live value. Parent value updates do not emit it.\n- **form** (bindable): the FormState instance.\n- **onSubmit**: called with the validated visible payload. Its return value is ignored; rejected promises propagate.\n- **size**: small, normal (default), or large. Controls Form typography and becomes the fallback size for fields and actions. An explicit field or action size takes precedence.\n- **density**: compact, normal (default), or comfortable. Controls gaps between the Form header, fields, footer, and actions independently from size.\n- **variant**: plain (default), sectioned, or card. All variants consume the shared Card typography. Plain remains transparent without separators. Sectioned stays transparent and adds edge-to-edge separators between the header, visible top-level fields or groups, and footer. Card adds the same separators inside the Card surface and inset; Card header/footer padding applies only to that variant. Separators do not appear between fields inside a group.\n- **layout**: vertical (default) or horizontal. Horizontal keeps one field per row and places labels to the left once each field is at least 32rem wide; fields remain stacked in narrower forms.\n- **actions**: optional array of Button props. Each action's onAction receives the live FormState, so a submit action calls form.submit(). Actions are disabled while the form is submitting; their own loading and disabled props are composed with that protection.\n- **validationMode**: submit (default), blur, or change. When a field validates by itself besides a submit: submit waits for the first submit attempt and then re-validates each edited field, blur validates when focus leaves the field, change validates on every accepted change. A field that shows an error always re-validates as it is edited. A field's own validationMode takes precedence, and mounting never validates.\n- **validationDebounce**: milliseconds an edit waits before the validation it triggers, so an async onValidate is not called on every keystroke. Submit and blur validate at once. A field's own validationDebounce takes precedence.\n- **errors**: errors from outside the form, for example a SvelteKit ActionData, keyed by field name with one message or a list of them. They show under their fields, survive validation, do not block a submit (the server re-checks), and clear when that field's value next changes. Changing the prop replaces the errors; the first errored field in render order is revealed.\n- **disabled**: disables every field, for example disabled={form.loading} while a submission runs. Disabled fields skip validation and, as in a native form, are left out of the submitted value. The root carries data-disabled. A disabled option inside an enabled radio or checkbox group is different: when selected it stays in that field's value, while a plain native <form> leaves it out of its submission.\n- **readOnly**: makes every field read-only. Values stay focusable, validated and submitted, but users cannot change them. The root carries data-readonly.\n- **class**: additional classes on the root div.\n- **theme**: Form theme overrides for root, header, title, description, group, group label/description/fields, top-level items, custom entries, footer, and actions. Global Card typography and section-rhythm changes flow into all Form variants before Form-specific overrides are composed; Card surface changes apply only to the card variant.\n\nThe **header**, **title**, **description**, **children**, and **footer** snippets each receive the FormState instance directly. No-argument snippets remain valid.\n\nField wrapper attributes and native control attributes have separate owners. Use fieldAttrs for the shared Field wrapper. Controls expose a typed bag for their actual native element, such as inputAttrs on TextInput, textareaAttrs on TextArea, and triggerAttrs on the custom Select's combobox button.\n\n## FormState\n\n- **value**: current visible partial value.\n- **loading**: true while a submission is pending: async validators first, then the handler.\n- **hasError**: true after validation fails or errors are set from outside; reset after successful validation.\n- **dirty**: true when any field differs from its initial value (null, an empty string and an empty list count as equal).\n- **touched**: true once focus has entered and left any field.\n- **validating**: true while an async onValidate of any field is pending.\n- **submitCount**: how many times the form was validated to submit.\n- **validate(options?)**: validates every visible, enabled field synchronously and returns the validated visible payload, or false after revealing the first invalid field. Disabled fields are skipped and left out of the payload. Pass { reveal: false } to check quietly: no scrolling, no focus, and the call does not count as a submit attempt. It does not wait for async validators.\n- **validateAsync(options?)**: like validate, and waits for async validators; returns a promise.\n- **validateField(name)**: validates one field now and returns whether it is valid. It neither scrolls nor focuses. A name that is not a visible field returns false; a disabled field skips validation and returns true; a field whose async validator is still running returns false, so use validateAsync to wait.\n- **submit()**: returns a promise of the validated visible payload, or false. It waits for pending async validators. Concurrent calls share the active promise.\n- **setErrors(errors)**: shows errors from outside the form under their fields, replacing those set before; setErrors({}) clears them. Call it from onSubmit with the server response. The first errored field in render order is revealed once the submission ends.\n- **reset(value?)**: restores every field to its initial value, or to value where it gives one, and makes that the new baseline. Errors, touched, dirty and the submit count clear, fields do not echo onValueChange, and the form's onValueChange runs once if the visible value changed. Conditional visibility is evaluated again.\n- **advanceFrom(field)**: moves on from a registered field as Enter does in a text field: the last navigable field submits, any other validates and focuses the next field when valid. Returns false when the field is hidden, disabled or not part of this form. Controls that own Enter themselves, such as checkboxes and radios, call it from their key handler and fall back to the native form otherwise.\n\n## Enter-key behavior\n\nOn an enabled, editable single-line input, Enter validates the current field. A valid field moves focus to the next visible enabled field, while an invalid field remains focused and displays its errors. Enter on a valid final navigable field calls form.submit(). Multiline editors and controls that already own Enter, such as selects and combobox menus, keep their native component behavior. A control that owns Enter can continue the flow with form.advanceFrom(field). Modified, repeated, and IME-composition Enter events are ignored.\n\n## Programmatic Dialog forms\n\nMount one Ask host near the application root, then call ask() from any client-side event. Both exports come from entasis/form:\n\n```svelte\n<script lang=\"ts\">\n\timport { Ask, ask } from 'entasis/form';\n\n\tconst inputs = {\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\temail: { type: 'email', label: 'Email', required: true }\n\t} as const;\n\n\tasync function editProfile() {\n\t\tconst outcome = await ask({\n\t\t\ttitle: 'Edit profile',\n\t\t\tdescription: 'Review the account details.',\n\t\t\tinputs,\n\t\t\tvalue: { name: 'Ada' },\n\t\t\tconfirm: 'Save',\n\t\t\tcancel: 'Cancel'\n\t\t});\n\n\t\tif (outcome.submitted) {\n\t\t\t// outcome.value is InferFormValue<typeof inputs>\n\t\t} else {\n\t\t\t// outcome.value is the partial, visible live value at cancellation\n\t\t}\n\t}\n</script>\n\n<Ask />\n```\n\nAskOptions accepts inputs, value, onSubmit, class, size, density, variant, layout, theme, validationMode, and validationDebounce with the same contracts as Form. Disabled fields are left out of the result. title is required for the Dialog's accessible name. confirm and cancel accept a string or Button presentation props plus text. Their click, loading, link, and native type behavior is owned by Ask. Set type on the mounted Ask host for its default Dialog type; a request's optional dialog object may override type and also accepts responsive, size, and scroll.\n\nValidation failures keep the Dialog open and focus the first invalid field. A successful submission resolves with the validated visible payload. Cancel, the close button, and Escape resolve with submitted: false and the current partial visible value. Outside-click and swipe dismissal are disabled. While submission is active, all close paths and actions are protected. Rejected onSubmit promises reject ask() after the Dialog closes. Concurrent ask() calls render as independent stacked Dialogs. Calling ask() during SSR or without one mounted host rejects clearly.\n\n## Visibility and values\n\nThe visible prop may be a boolean or a predicate receiving the complete private value cache:\n\n```svelte\n<Form\n\tinputs={{\n\t\tcontactMethod: {\n\t\t\ttype: 'select',\n\t\t\tlabel: 'Contact method',\n\t\t\titems: [\n\t\t\t\t{ label: 'Email', value: 'email' },\n\t\t\t\t{ label: 'Phone', value: 'phone' }\n\t\t\t]\n\t\t},\n\t\tphone: {\n\t\t\ttype: 'phone',\n\t\t\tlabel: 'Phone',\n\t\t\tvisible: (value) => value.contactMethod === 'phone'\n\t\t}\n\t}}\n/>\n```\n\nHidden field values are preserved privately and restored when shown again. Hidden fields are excluded from value, validation, and submission. Removing an input definition removes its cached value.\n\n## Field label position\n\nAll configured inputs inherit the Form layout through the shared Field wrapper. Set labelPosition: 'top' or 'left' on an individual input to override the Form layout without affecting value inference:\n\n```ts\nexport const inputs = {\n\tname: { type: 'text', label: 'Name' },\n\tnotes: { type: 'textarea', label: 'Notes', labelPosition: 'top' }\n} as const;\n```\n\nThe left position becomes a two-column label/control layout once the field is at least 32rem wide and remains stacked in narrower forms. The breakpoint is a container query against the field, so a form in a narrow drawer stacks on any screen. Form uses layout rather than orientation so controls such as Slider retain their own orientation prop.\n\n## Visual groups\n\nA group renders a transparent semantic fieldset with a required legend label and an optional description. Its label and description use the same size scale as ordinary fields; grouping does not add another card or padded container:\n\n```ts\nimport type { InferFormValue } from 'entasis/form';\n\nexport const inputs = {\n\tcontact: {\n\t\ttype: 'group',\n\t\tlabel: 'Contact details',\n\t\tdescription: 'How we can reach you',\n\t\tcolumns: 1,\n\t\tinputs: {\n\t\t\temail: { type: 'email', label: 'Email', required: true },\n\t\t\tphone: { type: 'phone', label: 'Phone' }\n\t\t}\n\t}\n} as const;\n\ntype Value = InferFormValue<typeof inputs>;\n// { email: string; phone: string | null }\n```\n\nSet disabled on a group to disable every field inside it, as a native disabled fieldset does (the fieldset renders disabled and data-disabled), or readOnly to make them read-only (data-readonly). A field is disabled or read-only when it, its group, or the Form is.\n\nThe group key is visual only and never appears in value or submission output. Descendant field names remain top-level, so duplicate names across groups are rejected. Group visibility hides all descendants, preserves field values in the private cache, and makes inferred field keys optional. In a vertical form layout, set columns to 1, 2, 3, or 4 to control the equal-width grid; the count is a container query against the form width (2 columns from 42rem, 3 from 56rem, 4 from 72rem), and groups collapse to one column in narrower forms. Horizontal form layouts ignore columns and always render group entries in one column. A child class may span tracks when an entry needs more room. Groups may contain fields, action rows, and custom snippets; nested groups are intentionally unsupported.\n\n## Custom value fields\n\nUse type: 'field' when Form must own a value but a custom control must render it. fieldType selects the existing FieldState schema and inferred value type. The snippet receives that registered FieldState controller:\n\n```svelte\n{#snippet handleControl(field)}\n\t<input\n\t\t{...field.controlAttrs}\n\t\t{@attach field.control}\n\t\tbind:value={field.value}\n\t/>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\thandle: {\n\t\t\ttype: 'field',\n\t\t\tfieldType: 'text',\n\t\t\tlabel: 'Handle',\n\t\t\trequired: true,\n\t\t\tsnippet: handleControl\n\t\t}\n\t}}\n/>\n```\n\nThe controller exposes value, disabled, required, validation state, accessible ids, controlAttrs, and the control attachment. The entry participates in bind:value, visibility, validation, submission, and InferFormValue through the same FieldState owner as built-in controls.\n\n## App-defined input types\n\nAn app can add its own input types to every Form and ask() dialog, by name, so entries stay plain data that serializes (JSON from a server, a tool call, a database). Two steps, once per app.\n\nType it: augment `FormInputRegistry` with the value the input holds and the props its entries take.\n\n```ts\n// doc-fence: skip (imports a sibling component; tooling/component-contract/consumer-form-input.ts checks this exact setup against the package)\nimport { registerFormInputs } from 'entasis/form';\nimport MoneyInput from './MoneyInput.svelte';\n\nexport type Money = { amount: number; currency: string };\n\ndeclare module 'entasis/form' {\n\tinterface FormInputRegistry {\n\t\tmoney: { value: Money; props: { currencies?: string[] } };\n\t}\n}\n\nregisterFormInputs({\n\tmoney: {\n\t\tcomponent: MoneyInput,\n\t\tisEmpty: (value) => value?.amount === undefined,\n\t\tvalidate: (value) => (value.amount < 0 ? 'Must not be negative' : null)\n\t}\n});\n```\n\nRegister it: call registerFormInputs at the top level of a module the server and the browser both load before a form renders, such as one the root layout imports. Its keys are checked against the registry; built-in type names are refused, and registering a type again replaces it.\n\nThe component renders the control alone. Form wraps it in Field (label, description, errors) and hands it the field it registered, typed from the registry, plus the entry's own props:\n\n```svelte\n<!-- doc-fence: skip -->\n<!-- Needs the money registration above; tooling/component-contract/consumer-money-input.svelte is the checked copy. -->\n<script lang=\"ts\">\n\timport type { FormInputComponentProps } from 'entasis/form';\n\n\tlet { field, currencies = ['EUR'] }: FormInputComponentProps<'money'> = $props();\n\tconst currency = $derived(field.value?.currency ?? currencies[0] ?? 'EUR');\n</script>\n\n<input\n\ttype=\"number\"\n\t{...field.controlAttrs}\n\t{@attach field.control}\n\tvalue={field.value?.amount ?? ''}\n\toninput={(event) => field.setValue({ amount: Number(event.currentTarget.value), currency })}\n/>\n```\n\nEntries then use the type like a built-in one, in Form inputs, groups, and ask():\n\n```ts\n// doc-fence: skip (needs the money registration above)\nconst result = await ask({\n\ttitle: 'Set a price',\n\tconfirm: 'Save',\n\tcancel: 'Cancel',\n\tinputs: { price: { type: 'money', label: 'Price', required: true, currencies: ['EUR', 'USD'] } }\n});\nif (result.submitted) result.value.price; // Money\n```\n\nEvery Field entry option applies (label, description, required, disabled, readOnly, messages, size, density, visible, defaultValue, onValidate, onValueChange, slots); any other key goes to the component. required rejects what isEmpty reports empty (by default null, undefined, an empty string, or an array with no values) with the catalog required message, and isEmpty also decides whether the field reads as filled; validate runs for every entry of the type, before the entry's own onValidate. The value takes part in bind:value, visibility, validation, submission, and InferFormValue like a built-in input's. An entry whose type is neither built-in nor registered throws when the form reads it.\n\n## Non-value entries\n\nUse type: 'action' to place a labelled row of buttons in the ordered inputs flow. It accepts optional label and description content plus the same Form-aware action objects as the top-level actions prop. Its label position follows the Form layout unless labelPosition overrides it. Form size is the default button size, action density follows the Form, and loading or disabled protection cannot be overridden while the Form is submitting.\n\nUse type: 'custom' to place a snippet in the flow. The snippet receives the live public FormState and owns its own surface and padding; Form adds neither. Both entry types support class and visible, may appear at the top level or inside a group, and are omitted from bind:value, validation, and inferred submission output:\n\n```svelte\n{#snippet accountStatus(form)}\n\t<p>{Object.keys(form.value).length} visible values</p>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\tstatus: { type: 'custom', snippet: accountStatus },\n\t\tcontrols: {\n\t\t\ttype: 'action',\n\t\t\tlabel: 'Account actions',\n\t\t\tdescription: 'Validate or save this account.',\n\t\t\tactions: [\n\t\t\t\t{ children: 'Validate', variant: 'soft', onAction: (payload) => payload.validate() },\n\t\t\t\t{ children: 'Save', onAction: (payload) => payload.submit() }\n\t\t\t]\n\t\t}\n\t}}\n/>\n```\n\nFor this configuration, InferFormValue contains name only; status and controls are presentation keys.\n\n## Validation\n\nBuilt-in validation uses the field schema. Every message a user sees comes from the i18n catalog in the active locale, never from the validation library: an empty required field shows one required message (for a checkbox or switch, that it must be on), and email, URL, length, range and incomplete-code failures each have their own message. Override them per entry with messages:\n\n```ts\nimport type { FormInputs } from 'entasis/form';\n\nexport const inputs = {\n\tname: { type: 'text', label: 'Name', required: true, messages: { required: 'Enter your name' } },\n\tcode: {\n\t\ttype: 'pin',\n\t\tlabel: 'Code',\n\t\tlength: 6,\n\t\trequired: true,\n\t\tmessages: { incomplete: (length) => 'Enter the ' + length + '-digit code' }\n\t}\n} satisfies FormInputs;\n```\n\nonValidate may return:\n\n- false, null, undefined, an empty string, or an empty array for valid input;\n- true for the catalog's generic invalid message;\n- a string for one error, shown verbatim;\n- a string array for multiple errors.\n\nValidation runs on submission. Failed submission scrolls to and focuses the first invalid control in render order. Disabled fields, directly or through a disabled group or Form, skip validation and are left out of the submitted value, as in a native form; entries that declare disabled become optional keys in InferFormValue. Read-only fields still validate and submit.\n\n### When fields validate\n\nvalidationMode on the Form, or on one entry, picks when a field validates by itself:\n\n```ts\nimport type { FormInputs } from 'entasis/form';\n\nexport const inputs = {\n\tusername: { type: 'text', label: 'Username', required: true, validationMode: 'change' },\n\temail: { type: 'email', label: 'Email', required: true }\n} satisfies FormInputs;\n// <Form {inputs} validationMode=\"blur\" />: email validates when focus leaves it,\n// username validates on every change.\n```\n\nsubmit (the default) validates on a submit attempt, and from then on re-validates each edited field. blur validates when focus leaves the field, including a popup the field opened. change validates on every accepted change, including values synced from a parent. Whatever the mode, a field that shows an error re-validates as it is edited, and a blur that settles the value (trimming, say) validates the settled value. validationDebounce delays the validation an edit triggers; submit and blur never wait.\n\n### Async validators\n\nonValidate may return a promise. While it is pending the field is neither valid nor invalid: no aria-invalid and no data-invalid, and its root carries data-validating. A result for an older value is dropped, a rejected promise marks the field invalid with the generic message, and unmounting cancels the work. submit() waits for pending validators before it calls onSubmit (loading is true meanwhile); validate() stays synchronous, so use validateAsync() to wait.\n\n```ts\nimport type { FormInputs } from 'entasis/form';\n\ndeclare function isTaken(username: string): Promise<boolean>;\n\nexport const inputs = {\n\tusername: {\n\t\ttype: 'text',\n\t\tlabel: 'Username',\n\t\trequired: true,\n\t\tonValidate: async (username) => ((await isTaken(username)) ? 'That username is taken' : null)\n\t}\n} satisfies FormInputs;\n```\n\n### Server errors\n\nPass the errors of a failed save as the errors prop or call form.setErrors(). They show under their fields, are not wiped by validation, do not block a re-submit, and clear on that field's next change:\n\n```svelte\n<script lang=\"ts\">\n\timport { Form, type FormState, type FormInputs } from 'entasis/form';\n\n\tconst inputs = {\n\t\temail: { type: 'email', label: 'Email', required: true }\n\t} satisfies FormInputs;\n\n\tlet form = $state<FormState<typeof inputs>>();\n</script>\n\n<Form\n\t{inputs}\n\tbind:form\n\tonSubmit={async (value) => {\n\t\tconst response = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(value) });\n\t\tif (!response.ok) form?.setErrors({ email: 'Already registered' });\n\t}}\n/>\n```\n\n### Dirty, touched and reset\n\nEvery field reports touched, dirty and filled (data-touched, data-dirty and data-filled on its root); form.dirty and form.touched are true when any field is. form.reset() returns every field to its initial value and clears errors, touched and dirty; form.reset(value) restores the given values instead and makes them the new baseline.\n\nErrors fade and grow in; reduced motion and the Theme motion tokens apply (the field theme motion slot).\n\n## Supported input types\n\ntext, email, url, password, number, rating, voice, slider, slider-range, textarea, rich-text, select, select-multiple, combobox, radio, checkboxes, checkbox, switch, phone, calendar, calendar-range, date, datetime, color, file, files, tag-group, tag, keyvalue, pin, and time.\n\nAlternative displays are display: 'selector' for date and calendar-range, and display: 'picker' for color.\n\nfield is the custom value-bearing entry. group, action, and custom are structural visual nodes rather than value-bearing input types.\n";
|
|
1
|
+
export declare const formDescription = "\n# Form\n\nForm renders configured entasis fields inside a div, owns their state and validation, and submits programmatically. It provides managed Enter-key navigation and submission, but not native form events, browser constraint validation, or FormData behavior. For a plain <form> (a SvelteKit form action), use the controls directly: each submits its name and value through hidden inputs, follows the form's reset, and accepts a form prop when it sits outside the form.\n\n## Basic usage\n\n```svelte\n<script lang=\"ts\">\n\timport {\n\t\tForm,\n\t\ttype FormState,\n\t\ttype InferFormValue,\n\t\ttype LiveFormValue\n\t} from 'entasis/form';\n\n\tconst inputs = {\n\t\tidentity: {\n\t\t\ttype: 'group',\n\t\t\tlabel: 'Identity',\n\t\t\tdescription: 'Core account details',\n\t\t\tcolumns: 2,\n\t\t\tinputs: {\n\t\t\t\tname: { type: 'text', label: 'Name', required: true },\n\t\t\t\temail: { type: 'email', label: 'Email', required: true }\n\t\t\t}\n\t\t}\n\t} as const;\n\n\tlet value = $state<LiveFormValue<typeof inputs>>({ name: 'Ada' });\n\tlet form = $state<FormState<typeof inputs>>();\n\tlet submitted = $state<InferFormValue<typeof inputs> | null>(null);\n</script>\n\n<Form\n\t{inputs}\n\tbind:value\n\tbind:form\n\tvariant=\"card\"\n\tlayout=\"horizontal\"\n\tsize=\"small\"\n\tdensity=\"normal\"\n\tactions={[\n\t\t{\n\t\t\tchildren: 'Save',\n\t\t\tonAction: (payload) => payload.submit()\n\t\t}\n\t]}\n\tonSubmit={(validatedValue) => {\n\t\tsubmitted = validatedValue;\n\t}}\n/>\n```\n\n## Props\n\n- **inputs** (required): ordered object keyed by entry name. Entries may be value-bearing fields, visual groups, action-button rows, or custom snippets. Only fields contribute keys to the live and validated values.\n- **value** (bindable): controlled partial live value. Visible mounted fields are published; hidden fields are omitted. A supplied top-level value takes precedence over an input's configured value.\n- **defaultValue**: initial partial live value when value is omitted.\n- **onValueChange**: called once when a user changes the visible live value. Parent value updates do not emit it.\n- **form** (bindable): the FormState instance.\n- **onSubmit**: called with the validated visible payload. Its return value is ignored; rejected promises propagate.\n- **size**: small, normal (default), or large. Controls Form typography and becomes the fallback size for fields and actions. An explicit field or action size takes precedence.\n- **density**: compact, normal (default), or comfortable. Controls gaps between the Form header, fields, footer, and actions independently from size.\n- **variant**: plain (default), sectioned, or card. All variants consume the shared Card typography. Plain remains transparent without separators. Sectioned stays transparent and adds edge-to-edge separators between the header, visible top-level fields or groups, and footer. Card adds the same separators inside the Card surface and inset; Card header/footer padding applies only to that variant. Separators do not appear between fields inside a group.\n- **layout**: vertical (default) or horizontal. Horizontal keeps one field per row and places labels to the left once each field is at least 32rem wide; fields remain stacked in narrower forms.\n- **actions**: optional array of Button props. Each action's onAction receives the live FormState, so a submit action calls form.submit(). Actions are disabled while the form is submitting; their own loading and disabled props are composed with that protection.\n- **validationMode**: submit (default), blur, or change. When a field validates by itself besides a submit: submit waits for the first submit attempt and then re-validates each edited field, blur validates when focus leaves the field, change validates on every accepted change. A field that shows an error always re-validates as it is edited. A field's own validationMode takes precedence, and mounting never validates.\n- **validationDebounce**: milliseconds an edit waits before the validation it triggers, so an async onValidate is not called on every keystroke. Submit and blur validate at once. A field's own validationDebounce takes precedence.\n- **errors**: errors from outside the form, for example a SvelteKit ActionData, keyed by field name with one message or a list of them. They show under their fields, survive validation, do not block a submit (the server re-checks), and clear when that field's value next changes. Changing the prop replaces the errors; the first errored field in render order is revealed.\n- **disabled**: disables every field, for example disabled={form.loading} while a submission runs. Disabled fields skip validation and, as in a native form, are left out of the submitted value. The root carries data-disabled. A disabled option inside an enabled radio or checkbox group is different: when selected it stays in that field's value, while a plain native <form> leaves it out of its submission.\n- **readOnly**: makes every field read-only. Values stay focusable, validated and submitted, but users cannot change them. The root carries data-readonly.\n- **class**: additional classes on the root div.\n- **theme**: Form theme overrides for root, header, title, description, group, group label/description/fields, top-level items, custom entries, footer, and actions. Global Card typography and section-rhythm changes flow into all Form variants before Form-specific overrides are composed; Card surface changes apply only to the card variant.\n\nThe **header**, **title**, **description**, **children**, and **footer** snippets each receive the FormState instance directly. No-argument snippets remain valid.\n\nField wrapper attributes and native control attributes have separate owners. Use fieldAttrs for the shared Field wrapper. Controls expose a typed bag for their actual native element, such as inputAttrs on TextInput, textareaAttrs on TextArea, and triggerAttrs on the custom Select's combobox button.\n\n## FormState\n\n- **value**: current visible partial value.\n- **loading**: true while a submission is pending: async validators first, then the handler.\n- **hasError**: true after validation fails or errors are set from outside; reset after successful validation.\n- **dirty**: true when any field differs from its initial value (null, an empty string and an empty list count as equal).\n- **touched**: true once focus has entered and left any field.\n- **validating**: true while an async onValidate of any field is pending.\n- **submitCount**: how many times the form was validated to submit.\n- **validate(options?)**: validates every visible, enabled field synchronously and returns the validated visible payload, or false after revealing the first invalid field. Disabled fields are skipped and left out of the payload. Pass { reveal: false } to check quietly: no scrolling, no focus, and the call does not count as a submit attempt. It does not wait for async validators.\n- **validateAsync(options?)**: like validate, and waits for async validators; returns a promise.\n- **validateField(name)**: validates one field now and returns whether it is valid. It neither scrolls nor focuses. A name that is not a visible field returns false; a disabled field skips validation and returns true; a field whose async validator is still running returns false, so use validateAsync to wait.\n- **submit()**: returns a promise of the validated visible payload, or false. It waits for pending async validators. Concurrent calls share the active promise.\n- **setErrors(errors)**: shows errors from outside the form under their fields, replacing those set before; setErrors({}) clears them. Call it from onSubmit with the server response. The first errored field in render order is revealed once the submission ends.\n- **reset(value?)**: restores every field to its initial value, or to value where it gives one, and makes that the new baseline. Errors, touched, dirty and the submit count clear, fields do not echo onValueChange, and the form's onValueChange runs once if the visible value changed. Conditional visibility is evaluated again.\n- **advanceFrom(field)**: moves on from a registered field as Enter does in a text field: the last navigable field submits, any other validates and focuses the next field when valid. Returns false when the field is hidden, disabled or not part of this form. Controls that own Enter themselves, such as checkboxes and radios, call it from their key handler and fall back to the native form otherwise.\n\n## Enter-key behavior\n\nOn an enabled, editable single-line input, Enter validates the current field. A valid field moves focus to the next visible enabled field, while an invalid field remains focused and displays its errors. Enter on a valid final navigable field calls form.submit(). Multiline editors and controls that already own Enter, such as selects and combobox menus, keep their native component behavior. A control that owns Enter can continue the flow with form.advanceFrom(field). Modified, repeated, and IME-composition Enter events are ignored.\n\n## Programmatic Dialog forms\n\nMount one Ask host near the application root, then call ask() from any client-side event. Both exports come from entasis/form:\n\n```svelte\n<script lang=\"ts\">\n\timport { Ask, ask } from 'entasis/form';\n\n\tconst inputs = {\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\temail: { type: 'email', label: 'Email', required: true }\n\t} as const;\n\n\tasync function editProfile() {\n\t\tconst outcome = await ask({\n\t\t\ttitle: 'Edit profile',\n\t\t\tdescription: 'Review the account details.',\n\t\t\tinputs,\n\t\t\tvalue: { name: 'Ada' },\n\t\t\tconfirm: 'Save',\n\t\t\tcancel: 'Cancel'\n\t\t});\n\n\t\tif (outcome.submitted) {\n\t\t\t// outcome.value is InferFormValue<typeof inputs>\n\t\t} else {\n\t\t\t// outcome.value is the partial, visible live value at cancellation\n\t\t}\n\t}\n</script>\n\n<Ask />\n```\n\nAskOptions accepts inputs, value, onSubmit, class, size, density, variant, layout, theme, validationMode, and validationDebounce with the same contracts as Form. Disabled fields are left out of the result. title is required for the Dialog's accessible name. confirm and cancel accept a string or Button presentation props plus text. Their click, loading, link, and native type behavior is owned by Ask. Set type on the mounted Ask host for its default Dialog type; a request's optional dialog object may override type and also accepts responsive, size, and scroll. With scroll 'inner' (the default) the form is the Dialog's only scrolling part: a form taller than the viewport scrolls inside its body (`data-slot=\"ask-body\"`) while the title, description and Confirm/Cancel row stay on screen, and the body keeps room around its fields so a focus ring is never clipped. With scroll 'outer' the whole card grows and the page behind it scrolls instead.\n\nValidation failures keep the Dialog open and focus the first invalid field. A successful submission resolves with the validated visible payload. Cancel, the close button, and Escape resolve with submitted: false and the current partial visible value. Outside-click and swipe dismissal are disabled. While submission is active, all close paths and actions are protected. Rejected onSubmit promises reject ask() after the Dialog closes. Concurrent ask() calls render as independent stacked Dialogs. Calling ask() during SSR or without one mounted host rejects clearly.\n\n## Visibility and values\n\nThe visible prop may be a boolean or a predicate receiving the complete private value cache:\n\n```svelte\n<Form\n\tinputs={{\n\t\tcontactMethod: {\n\t\t\ttype: 'select',\n\t\t\tlabel: 'Contact method',\n\t\t\titems: [\n\t\t\t\t{ label: 'Email', value: 'email' },\n\t\t\t\t{ label: 'Phone', value: 'phone' }\n\t\t\t]\n\t\t},\n\t\tphone: {\n\t\t\ttype: 'phone',\n\t\t\tlabel: 'Phone',\n\t\t\tvisible: (value) => value.contactMethod === 'phone'\n\t\t}\n\t}}\n/>\n```\n\nHidden field values are preserved privately and restored when shown again. Hidden fields are excluded from value, validation, and submission. Removing an input definition removes its cached value.\n\n## Field label position\n\nAll configured inputs inherit the Form layout through the shared Field wrapper. Set labelPosition: 'top' or 'left' on an individual input to override the Form layout without affecting value inference:\n\n```ts\nexport const inputs = {\n\tname: { type: 'text', label: 'Name' },\n\tnotes: { type: 'textarea', label: 'Notes', labelPosition: 'top' }\n} as const;\n```\n\nThe left position becomes a two-column label/control layout once the field is at least 32rem wide and remains stacked in narrower forms. The breakpoint is a container query against the field, so a form in a narrow drawer stacks on any screen. Form uses layout rather than orientation so controls such as Slider retain their own orientation prop.\n\n## Visual groups\n\nA group renders a transparent semantic fieldset with a required legend label and an optional description. Its label and description use the same size scale as ordinary fields; grouping does not add another card or padded container:\n\n```ts\nimport type { InferFormValue } from 'entasis/form';\n\nexport const inputs = {\n\tcontact: {\n\t\ttype: 'group',\n\t\tlabel: 'Contact details',\n\t\tdescription: 'How we can reach you',\n\t\tcolumns: 1,\n\t\tinputs: {\n\t\t\temail: { type: 'email', label: 'Email', required: true },\n\t\t\tphone: { type: 'phone', label: 'Phone' }\n\t\t}\n\t}\n} as const;\n\ntype Value = InferFormValue<typeof inputs>;\n// { email: string; phone: string | null }\n```\n\nSet disabled on a group to disable every field inside it, as a native disabled fieldset does (the fieldset renders disabled and data-disabled), or readOnly to make them read-only (data-readonly). A field is disabled or read-only when it, its group, or the Form is.\n\nThe group key is visual only and never appears in value or submission output. Descendant field names remain top-level, so duplicate names across groups are rejected. Group visibility hides all descendants, preserves field values in the private cache, and makes inferred field keys optional. In a vertical form layout, set columns to 1, 2, 3, or 4 to control the equal-width grid; the count is a container query against the form width (2 columns from 42rem, 3 from 56rem, 4 from 72rem), and groups collapse to one column in narrower forms. Horizontal form layouts ignore columns and always render group entries in one column. A child class may span tracks when an entry needs more room. Groups may contain fields, action rows, and custom snippets; nested groups are intentionally unsupported.\n\n## Custom value fields\n\nUse type: 'field' when Form must own a value but a custom control must render it. fieldType selects the existing FieldState schema and inferred value type. The snippet receives that registered FieldState controller:\n\n```svelte\n{#snippet handleControl(field)}\n\t<input\n\t\t{...field.controlAttrs}\n\t\t{@attach field.control}\n\t\tbind:value={field.value}\n\t/>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\thandle: {\n\t\t\ttype: 'field',\n\t\t\tfieldType: 'text',\n\t\t\tlabel: 'Handle',\n\t\t\trequired: true,\n\t\t\tsnippet: handleControl\n\t\t}\n\t}}\n/>\n```\n\nThe controller exposes value, disabled, required, validation state, accessible ids, controlAttrs, and the control attachment. The entry participates in bind:value, visibility, validation, submission, and InferFormValue through the same FieldState owner as built-in controls.\n\n## App-defined input types\n\nAn app can add its own input types to every Form and ask() dialog, by name, so entries stay plain data that serializes (JSON from a server, a tool call, a database). Two steps, once per app.\n\nType it: augment `FormInputRegistry` with the value the input holds and the props its entries take.\n\n```ts\n// doc-fence: skip (imports a sibling component; tooling/component-contract/consumer-form-input.ts checks this exact setup against the package)\nimport { registerFormInputs } from 'entasis/form';\nimport MoneyInput from './MoneyInput.svelte';\n\nexport type Money = { amount: number; currency: string };\n\ndeclare module 'entasis/form' {\n\tinterface FormInputRegistry {\n\t\tmoney: { value: Money; props: { currencies?: string[] } };\n\t}\n}\n\nregisterFormInputs({\n\tmoney: {\n\t\tcomponent: MoneyInput,\n\t\tisEmpty: (value) => value?.amount === undefined,\n\t\tvalidate: (value) => (value.amount < 0 ? 'Must not be negative' : null)\n\t}\n});\n```\n\nRegister it: call registerFormInputs at the top level of a module the server and the browser both load before a form renders, such as one the root layout imports. Its keys are checked against the registry; built-in type names are refused, and registering a type again replaces it.\n\nThe component renders the control alone. Form wraps it in Field (label, description, errors) and hands it the field it registered, typed from the registry, plus the entry's own props:\n\n```svelte\n<!-- doc-fence: skip -->\n<!-- Needs the money registration above; tooling/component-contract/consumer-money-input.svelte is the checked copy. -->\n<script lang=\"ts\">\n\timport type { FormInputComponentProps } from 'entasis/form';\n\n\tlet { field, currencies = ['EUR'] }: FormInputComponentProps<'money'> = $props();\n\tconst currency = $derived(field.value?.currency ?? currencies[0] ?? 'EUR');\n</script>\n\n<input\n\ttype=\"number\"\n\t{...field.controlAttrs}\n\t{@attach field.control}\n\tvalue={field.value?.amount ?? ''}\n\toninput={(event) => field.setValue({ amount: Number(event.currentTarget.value), currency })}\n/>\n```\n\nEntries then use the type like a built-in one, in Form inputs, groups, and ask():\n\n```ts\n// doc-fence: skip (needs the money registration above)\nconst result = await ask({\n\ttitle: 'Set a price',\n\tconfirm: 'Save',\n\tcancel: 'Cancel',\n\tinputs: { price: { type: 'money', label: 'Price', required: true, currencies: ['EUR', 'USD'] } }\n});\nif (result.submitted) result.value.price; // Money\n```\n\nEvery Field entry option applies (label, description, required, disabled, readOnly, messages, size, density, visible, defaultValue, onValidate, onValueChange, slots); any other key goes to the component. required rejects what isEmpty reports empty (by default null, undefined, an empty string, or an array with no values) with the catalog required message, and isEmpty also decides whether the field reads as filled; validate runs for every entry of the type, before the entry's own onValidate. The value takes part in bind:value, visibility, validation, submission, and InferFormValue like a built-in input's. An entry whose type is neither built-in nor registered throws when the form reads it.\n\n## Non-value entries\n\nUse type: 'action' to place a labelled row of buttons in the ordered inputs flow. It accepts optional label and description content plus the same Form-aware action objects as the top-level actions prop. Its label position follows the Form layout unless labelPosition overrides it. Form size is the default button size, action density follows the Form, and loading or disabled protection cannot be overridden while the Form is submitting.\n\nUse type: 'custom' to place a snippet in the flow. The snippet receives the live public FormState and owns its own surface and padding; Form adds neither. Both entry types support class and visible, may appear at the top level or inside a group, and are omitted from bind:value, validation, and inferred submission output:\n\n```svelte\n{#snippet accountStatus(form)}\n\t<p>{Object.keys(form.value).length} visible values</p>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\tstatus: { type: 'custom', snippet: accountStatus },\n\t\tcontrols: {\n\t\t\ttype: 'action',\n\t\t\tlabel: 'Account actions',\n\t\t\tdescription: 'Validate or save this account.',\n\t\t\tactions: [\n\t\t\t\t{ children: 'Validate', variant: 'soft', onAction: (payload) => payload.validate() },\n\t\t\t\t{ children: 'Save', onAction: (payload) => payload.submit() }\n\t\t\t]\n\t\t}\n\t}}\n/>\n```\n\nFor this configuration, InferFormValue contains name only; status and controls are presentation keys.\n\n## Validation\n\nBuilt-in validation uses the field schema. Every message a user sees comes from the i18n catalog in the active locale, never from the validation library: an empty required field shows one required message (for a checkbox or switch, that it must be on), and email, URL, length, range and incomplete-code failures each have their own message. Override them per entry with messages:\n\n```ts\nimport type { FormInputs } from 'entasis/form';\n\nexport const inputs = {\n\tname: { type: 'text', label: 'Name', required: true, messages: { required: 'Enter your name' } },\n\tcode: {\n\t\ttype: 'pin',\n\t\tlabel: 'Code',\n\t\tlength: 6,\n\t\trequired: true,\n\t\tmessages: { incomplete: (length) => 'Enter the ' + length + '-digit code' }\n\t}\n} satisfies FormInputs;\n```\n\nonValidate may return:\n\n- false, null, undefined, an empty string, or an empty array for valid input;\n- true for the catalog's generic invalid message;\n- a string for one error, shown verbatim;\n- a string array for multiple errors.\n\nValidation runs on submission. Failed submission scrolls to and focuses the first invalid control in render order. Disabled fields, directly or through a disabled group or Form, skip validation and are left out of the submitted value, as in a native form; entries that declare disabled become optional keys in InferFormValue. Read-only fields still validate and submit.\n\n### When fields validate\n\nvalidationMode on the Form, or on one entry, picks when a field validates by itself:\n\n```ts\nimport type { FormInputs } from 'entasis/form';\n\nexport const inputs = {\n\tusername: { type: 'text', label: 'Username', required: true, validationMode: 'change' },\n\temail: { type: 'email', label: 'Email', required: true }\n} satisfies FormInputs;\n// <Form {inputs} validationMode=\"blur\" />: email validates when focus leaves it,\n// username validates on every change.\n```\n\nsubmit (the default) validates on a submit attempt, and from then on re-validates each edited field. blur validates when focus leaves the field, including a popup the field opened. change validates on every accepted change, including values synced from a parent. Whatever the mode, a field that shows an error re-validates as it is edited, and a blur that settles the value (trimming, say) validates the settled value. validationDebounce delays the validation an edit triggers; submit and blur never wait.\n\n### Async validators\n\nonValidate may return a promise. While it is pending the field is neither valid nor invalid: no aria-invalid and no data-invalid, and its root carries data-validating. A result for an older value is dropped, a rejected promise marks the field invalid with the generic message, and unmounting cancels the work. submit() waits for pending validators before it calls onSubmit (loading is true meanwhile); validate() stays synchronous, so use validateAsync() to wait.\n\n```ts\nimport type { FormInputs } from 'entasis/form';\n\ndeclare function isTaken(username: string): Promise<boolean>;\n\nexport const inputs = {\n\tusername: {\n\t\ttype: 'text',\n\t\tlabel: 'Username',\n\t\trequired: true,\n\t\tonValidate: async (username) => ((await isTaken(username)) ? 'That username is taken' : null)\n\t}\n} satisfies FormInputs;\n```\n\n### Server errors\n\nPass the errors of a failed save as the errors prop or call form.setErrors(). They show under their fields, are not wiped by validation, do not block a re-submit, and clear on that field's next change:\n\n```svelte\n<script lang=\"ts\">\n\timport { Form, type FormState, type FormInputs } from 'entasis/form';\n\n\tconst inputs = {\n\t\temail: { type: 'email', label: 'Email', required: true }\n\t} satisfies FormInputs;\n\n\tlet form = $state<FormState<typeof inputs>>();\n</script>\n\n<Form\n\t{inputs}\n\tbind:form\n\tonSubmit={async (value) => {\n\t\tconst response = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(value) });\n\t\tif (!response.ok) form?.setErrors({ email: 'Already registered' });\n\t}}\n/>\n```\n\n### Dirty, touched and reset\n\nEvery field reports touched, dirty and filled (data-touched, data-dirty and data-filled on its root); form.dirty and form.touched are true when any field is. form.reset() returns every field to its initial value and clears errors, touched and dirty; form.reset(value) restores the given values instead and makes them the new baseline.\n\nErrors fade and grow in; reduced motion and the Theme motion tokens apply (the field theme motion slot).\n\n## Supported input types\n\ntext, email, url, password, number, rating, voice, slider, slider-range, textarea, rich-text, select, select-multiple, combobox, radio, checkboxes, checkbox, switch, phone, calendar, calendar-range, date, datetime, color, file, files, tag-group, tag, keyvalue, pin, and time.\n\nAlternative displays are display: 'selector' for date and calendar-range, and display: 'picker' for color.\n\nfield is the custom value-bearing entry. group, action, and custom are structural visual nodes rather than value-bearing input types.\n";
|
|
@@ -132,7 +132,7 @@ Mount one Ask host near the application root, then call ask() from any client-si
|
|
|
132
132
|
<Ask />
|
|
133
133
|
\`\`\`
|
|
134
134
|
|
|
135
|
-
AskOptions accepts inputs, value, onSubmit, class, size, density, variant, layout, theme, validationMode, and validationDebounce with the same contracts as Form. Disabled fields are left out of the result. title is required for the Dialog's accessible name. confirm and cancel accept a string or Button presentation props plus text. Their click, loading, link, and native type behavior is owned by Ask. Set type on the mounted Ask host for its default Dialog type; a request's optional dialog object may override type and also accepts responsive, size, and scroll.
|
|
135
|
+
AskOptions accepts inputs, value, onSubmit, class, size, density, variant, layout, theme, validationMode, and validationDebounce with the same contracts as Form. Disabled fields are left out of the result. title is required for the Dialog's accessible name. confirm and cancel accept a string or Button presentation props plus text. Their click, loading, link, and native type behavior is owned by Ask. Set type on the mounted Ask host for its default Dialog type; a request's optional dialog object may override type and also accepts responsive, size, and scroll. With scroll 'inner' (the default) the form is the Dialog's only scrolling part: a form taller than the viewport scrolls inside its body (\`data-slot="ask-body"\`) while the title, description and Confirm/Cancel row stay on screen, and the body keeps room around its fields so a focus ring is never clipped. With scroll 'outer' the whole card grows and the page behind it scrolls instead.
|
|
136
136
|
|
|
137
137
|
Validation failures keep the Dialog open and focus the first invalid field. A successful submission resolves with the validated visible payload. Cancel, the close button, and Escape resolve with submitted: false and the current partial visible value. Outside-click and swipe dismissal are disabled. While submission is active, all close paths and actions are protected. Rejected onSubmit promises reject ask() after the Dialog closes. Concurrent ask() calls render as independent stacked Dialogs. Calling ask() during SSR or without one mounted host rejects clearly.
|
|
138
138
|
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import SidebarFlyout from './SidebarFlyout.svelte';
|
|
29
29
|
import SidebarIcon from './SidebarIcon.svelte';
|
|
30
30
|
import SidebarMenuSubItem from './SidebarMenuSubItem.svelte';
|
|
31
|
+
import { useSidebarSuffixWidth } from './sidebar.suffix.svelte.js';
|
|
31
32
|
import { useSidebarMotion, useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
|
|
32
33
|
|
|
33
34
|
let {
|
|
@@ -61,6 +62,8 @@
|
|
|
61
62
|
let submenuTriggerRef = $state<HTMLButtonElement | null>(null);
|
|
62
63
|
let submenuRef = $state<HTMLUListElement | null>(null);
|
|
63
64
|
let actionRef = $state<HTMLElement | null>(null);
|
|
65
|
+
let suffixRef = $state<HTMLElement | null>(null);
|
|
66
|
+
const suffixWidth = useSidebarSuffixWidth();
|
|
64
67
|
const classes = $derived(useSidebarTheme(theme));
|
|
65
68
|
// Motion preset from `sidebarTheme.motion`, through the override ladder
|
|
66
69
|
// (registry → `setSidebarTheme` → the instance `theme.motion` slot).
|
|
@@ -86,9 +89,20 @@
|
|
|
86
89
|
(item.view !== undefined && !back) ||
|
|
87
90
|
(hasSubmenu && !item.href && item.collapsible !== false && collapseIcon !== 'none')
|
|
88
91
|
);
|
|
92
|
+
// A suffix sits beside the row and stays visible before the action, so a row with one reserves
|
|
93
|
+
// the suffix's width and the action's box at every width, and its badge never steps aside.
|
|
89
94
|
const endReserve = $derived(
|
|
90
|
-
|
|
95
|
+
item.suffix
|
|
96
|
+
? item.action
|
|
97
|
+
? 'suffixAction'
|
|
98
|
+
: 'suffix'
|
|
99
|
+
: !item.action
|
|
100
|
+
? 'none'
|
|
101
|
+
: showBadge && !hasTrailingGlyph
|
|
102
|
+
? 'actionBelowMd'
|
|
103
|
+
: 'action'
|
|
91
104
|
);
|
|
105
|
+
const badgeAction = $derived(item.action && !item.suffix ? 'row' : 'none');
|
|
92
106
|
// A bare glyph stays bare: the wrapper only appears when the entry asks for a role tint or a
|
|
93
107
|
// tile, so an untinted row never inherits the ambient `data-color`.
|
|
94
108
|
const hasIconSurface = $derived(!!item.icon && (item.iconVariant === 'tile' || !!item.iconColor));
|
|
@@ -113,6 +127,9 @@
|
|
|
113
127
|
const tooltipPosition = () => sidebarInwardPhysicalSide(api.side, direction());
|
|
114
128
|
const uid = $props.id();
|
|
115
129
|
const submenuId = `${uid}-submenu`;
|
|
130
|
+
const suffixId = `${uid}-suffix`;
|
|
131
|
+
// The row reads with its suffix (« Dossiers, 20 ») while the suffix is on screen.
|
|
132
|
+
const describedBy = $derived(item.suffix && !isIconCollapsed ? suffixId : undefined);
|
|
116
133
|
// The submenu's `hidden` state and its height animation, reversible mid-way, come from the
|
|
117
134
|
// shared disclosure panel, as Collapsible's and Accordion's do.
|
|
118
135
|
const submenuPanel = disclosurePanel({ open: () => showSubmenu, motion: () => collapseMotion });
|
|
@@ -126,7 +143,10 @@
|
|
|
126
143
|
return;
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
if (
|
|
146
|
+
if (
|
|
147
|
+
isIconCollapsed &&
|
|
148
|
+
(actionRef?.contains(activeElement) || suffixRef?.contains(activeElement))
|
|
149
|
+
) {
|
|
130
150
|
(submenuTriggerRef ?? rowRef)?.focus();
|
|
131
151
|
}
|
|
132
152
|
});
|
|
@@ -172,7 +192,7 @@
|
|
|
172
192
|
<span
|
|
173
193
|
data-slot="sidebar-menu-badge"
|
|
174
194
|
data-sidebar="menu-badge"
|
|
175
|
-
class={classes.badge({ size, action:
|
|
195
|
+
class={classes.badge({ size, action: badgeAction })}
|
|
176
196
|
>
|
|
177
197
|
{item.badge}
|
|
178
198
|
</span>
|
|
@@ -209,6 +229,7 @@
|
|
|
209
229
|
data-active-variant={activeVariant}
|
|
210
230
|
aria-current={item.isActive ? 'page' : undefined}
|
|
211
231
|
aria-disabled={item.disabled || undefined}
|
|
232
|
+
aria-describedby={describedBy}
|
|
212
233
|
tabindex={item.disabled ? -1 : undefined}
|
|
213
234
|
class={classes.menuButton({
|
|
214
235
|
variant: item.variant,
|
|
@@ -238,6 +259,7 @@
|
|
|
238
259
|
data-active-variant={activeVariant}
|
|
239
260
|
aria-current={item.isActive ? 'page' : undefined}
|
|
240
261
|
aria-label={ariaLabel}
|
|
262
|
+
aria-describedby={describedBy}
|
|
241
263
|
data-sidebar-view-target={item.view}
|
|
242
264
|
disabled={item.disabled || undefined}
|
|
243
265
|
class={classes.menuButton({
|
|
@@ -295,6 +317,7 @@
|
|
|
295
317
|
aria-expanded={popover.isOpen}
|
|
296
318
|
aria-haspopup="menu"
|
|
297
319
|
aria-controls={popover.isOpen ? popover.id : undefined}
|
|
320
|
+
aria-describedby={describedBy}
|
|
298
321
|
{@attach tooltipContent
|
|
299
322
|
? tooltip(sidebarTooltip(tooltipContent, tooltipPosition))
|
|
300
323
|
: undefined}
|
|
@@ -374,7 +397,10 @@
|
|
|
374
397
|
data-sidebar="menu-item"
|
|
375
398
|
class={classes.menuItem()}
|
|
376
399
|
>
|
|
377
|
-
<div
|
|
400
|
+
<div
|
|
401
|
+
class="group/menu-row relative"
|
|
402
|
+
style:--sidebar-suffix-width={item.suffix ? suffixWidth.value : undefined}
|
|
403
|
+
>
|
|
378
404
|
{#if item.menu}
|
|
379
405
|
{@render dropdownButton()}
|
|
380
406
|
{:else if hasFlyout}
|
|
@@ -423,6 +449,7 @@
|
|
|
423
449
|
})}
|
|
424
450
|
aria-expanded={showSubmenu}
|
|
425
451
|
aria-controls={submenuPanel.rendered ? submenuId : undefined}
|
|
452
|
+
aria-describedby={describedBy}
|
|
426
453
|
{...openStateAttributes(showSubmenu)}
|
|
427
454
|
{@attach tooltipContent
|
|
428
455
|
? tooltip(sidebarTooltip(tooltipContent, tooltipPosition))
|
|
@@ -437,6 +464,22 @@
|
|
|
437
464
|
{@render leafButton()}
|
|
438
465
|
{/if}
|
|
439
466
|
|
|
467
|
+
{#if item.suffix}
|
|
468
|
+
<!-- Beside the row, never inside it: the suffix may hold its own controls. -->
|
|
469
|
+
<div
|
|
470
|
+
bind:this={suffixRef}
|
|
471
|
+
id={suffixId}
|
|
472
|
+
data-slot="sidebar-menu-suffix"
|
|
473
|
+
data-sidebar="menu-suffix"
|
|
474
|
+
inert={isIconCollapsed ? true : undefined}
|
|
475
|
+
aria-hidden={isIconCollapsed ? 'true' : undefined}
|
|
476
|
+
class={classes.suffix({ size, density, action: item.action ? 'action' : 'none' })}
|
|
477
|
+
{@attach suffixWidth.measure}
|
|
478
|
+
>
|
|
479
|
+
{@render item.suffix(api, item)}
|
|
480
|
+
</div>
|
|
481
|
+
{/if}
|
|
482
|
+
|
|
440
483
|
{#if item.action}
|
|
441
484
|
<div
|
|
442
485
|
bind:this={actionRef}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { sidebarInwardPhysicalSide, sidebarTooltip } from './sidebar-position.js';
|
|
12
12
|
import SidebarAction from './SidebarAction.svelte';
|
|
13
13
|
import SidebarIcon from './SidebarIcon.svelte';
|
|
14
|
+
import { useSidebarSuffixWidth } from './sidebar.suffix.svelte.js';
|
|
14
15
|
import { useSidebarTheme, type SidebarThemeProps } from './sidebar.theme.js';
|
|
15
16
|
|
|
16
17
|
let {
|
|
@@ -38,6 +39,11 @@
|
|
|
38
39
|
// tile, so an untinted row never inherits the ambient `data-color`.
|
|
39
40
|
const hasIconSurface = $derived(!!sub.icon && (sub.iconVariant === 'tile' || !!sub.iconColor));
|
|
40
41
|
const hasBadge = $derived(sub.badge != null);
|
|
42
|
+
const suffixWidth = useSidebarSuffixWidth();
|
|
43
|
+
const uid = $props.id();
|
|
44
|
+
const suffixId = `${uid}-suffix`;
|
|
45
|
+
// The row reads with its suffix (« Dossiers, 20 ») while the suffix is on screen.
|
|
46
|
+
const describedBy = $derived(sub.suffix && !iconCollapsed ? suffixId : undefined);
|
|
41
47
|
// The label is always visible on a submenu row, so the tooltip is extra information: shown
|
|
42
48
|
// whenever one is set, never on mobile, where hover does not exist.
|
|
43
49
|
const tooltipContent = $derived(sub.tooltip && !api.isMobile ? sub.tooltip : undefined);
|
|
@@ -53,8 +59,17 @@
|
|
|
53
59
|
activeVariant,
|
|
54
60
|
density,
|
|
55
61
|
// With a badge last in the row, the badge holds the end column from `md` up and the
|
|
56
|
-
// action takes its place while it shows; below `md` the two sit side by side.
|
|
57
|
-
|
|
62
|
+
// action takes its place while it shows; below `md` the two sit side by side. A suffix
|
|
63
|
+
// stays visible before the action, so a row with one reserves both at every width.
|
|
64
|
+
endReserve: sub.suffix
|
|
65
|
+
? sub.action
|
|
66
|
+
? 'suffixAction'
|
|
67
|
+
: 'suffix'
|
|
68
|
+
: !sub.action
|
|
69
|
+
? 'none'
|
|
70
|
+
: hasBadge
|
|
71
|
+
? 'actionBelowMd'
|
|
72
|
+
: 'action',
|
|
58
73
|
className: sub.class
|
|
59
74
|
})
|
|
60
75
|
);
|
|
@@ -90,7 +105,7 @@
|
|
|
90
105
|
<span
|
|
91
106
|
data-slot="sidebar-menu-sub-badge"
|
|
92
107
|
data-sidebar="menu-sub-badge"
|
|
93
|
-
class={classes.badge({ size: subSize, action: sub.action ? 'sub' : 'none' })}
|
|
108
|
+
class={classes.badge({ size: subSize, action: sub.action && !sub.suffix ? 'sub' : 'none' })}
|
|
94
109
|
>
|
|
95
110
|
{sub.badge}
|
|
96
111
|
</span>
|
|
@@ -104,6 +119,7 @@
|
|
|
104
119
|
data-slot="sidebar-menu-sub-item"
|
|
105
120
|
data-sidebar="menu-sub-item"
|
|
106
121
|
class="group/menu-sub-item relative"
|
|
122
|
+
style:--sidebar-suffix-width={sub.suffix ? suffixWidth.value : undefined}
|
|
107
123
|
>
|
|
108
124
|
{#if sub.href}
|
|
109
125
|
<!-- eslint-disable svelte/no-navigation-without-resolve -- Package consumers supply URLs; library links cannot depend on SvelteKit routing. -->
|
|
@@ -117,6 +133,7 @@
|
|
|
117
133
|
data-active-variant={activeVariant}
|
|
118
134
|
aria-current={sub.isActive ? 'page' : undefined}
|
|
119
135
|
aria-disabled={sub.disabled || undefined}
|
|
136
|
+
aria-describedby={describedBy}
|
|
120
137
|
tabindex={sub.disabled ? -1 : undefined}
|
|
121
138
|
class={buttonClass}
|
|
122
139
|
{@attach tooltipContent
|
|
@@ -136,6 +153,7 @@
|
|
|
136
153
|
data-active={sub.isActive ? 'true' : undefined}
|
|
137
154
|
data-active-variant={activeVariant}
|
|
138
155
|
disabled={sub.disabled || undefined}
|
|
156
|
+
aria-describedby={describedBy}
|
|
139
157
|
class={buttonClass}
|
|
140
158
|
{@attach tooltipContent
|
|
141
159
|
? tooltip(sidebarTooltip(tooltipContent, tooltipPosition))
|
|
@@ -146,6 +164,25 @@
|
|
|
146
164
|
</button>
|
|
147
165
|
{/if}
|
|
148
166
|
|
|
167
|
+
{#if sub.suffix}
|
|
168
|
+
<!-- Beside the row, never inside it: the suffix may hold its own controls. -->
|
|
169
|
+
<div
|
|
170
|
+
id={suffixId}
|
|
171
|
+
data-slot="sidebar-menu-sub-suffix"
|
|
172
|
+
data-sidebar="menu-sub-suffix"
|
|
173
|
+
inert={iconCollapsed ? true : undefined}
|
|
174
|
+
aria-hidden={iconCollapsed ? 'true' : undefined}
|
|
175
|
+
class={classes.suffix({
|
|
176
|
+
size: subSize,
|
|
177
|
+
density,
|
|
178
|
+
action: sub.action ? 'action' : 'none'
|
|
179
|
+
})}
|
|
180
|
+
{@attach suffixWidth.measure}
|
|
181
|
+
>
|
|
182
|
+
{@render sub.suffix(api, sub)}
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
|
|
149
186
|
{#if sub.action}
|
|
150
187
|
<div
|
|
151
188
|
data-slot="sidebar-menu-sub-action"
|
|
@@ -27,6 +27,7 @@ export declare const sidebarCssVariables: {
|
|
|
27
27
|
readonly '--sidebar-width': "On the root: the expanded panel width.";
|
|
28
28
|
readonly '--sidebar-width-icon': "On the root: the width of the collapsed icon rail.";
|
|
29
29
|
readonly '--sidebar-width-mobile': "On the root: the width of the mobile drawer.";
|
|
30
|
+
readonly '--sidebar-suffix-width': "On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.";
|
|
30
31
|
readonly '--disclosure-panel-height': "On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.";
|
|
31
32
|
};
|
|
32
33
|
/** The name of a Sidebar CSS variable. */
|
|
@@ -25,5 +25,6 @@ export const sidebarCssVariables = {
|
|
|
25
25
|
'--sidebar-width': 'On the root: the expanded panel width.',
|
|
26
26
|
'--sidebar-width-icon': 'On the root: the width of the collapsed icon rail.',
|
|
27
27
|
'--sidebar-width-mobile': 'On the root: the width of the mobile drawer.',
|
|
28
|
+
'--sidebar-suffix-width': "On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.",
|
|
28
29
|
'--disclosure-panel-height': 'On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.'
|
|
29
30
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const sidebarDescription = "\n# Sidebar Component\n\nSidebar navigation with data-driven groups, icon collapse, mobile drawer behavior,\nrecursive tree groups, header/footer rows, search, actions, and snippet escape hatches.\n\n## Import\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n</script>\n```\n\n## Basic Usage\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { houseIcon } from 'entasis/icons/house';\n\timport { gearIcon } from 'entasis/icons/gear';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Workspace',\n\t\t\titems: [\n\t\t\t\t{ label: 'Dashboard', href: '/', icon: houseIcon, isActive: true },\n\t\t\t\t{ label: 'Settings', href: '/settings', icon: gearIcon }\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar items={items}>\n\t{#snippet children({ toggle })}\n\t\t<header>\n\t\t\t<button type=\"button\" onclick={toggle}>Toggle</button>\n\t\t</header>\n\t\t<main>Page content</main>\n\t{/snippet}\n</Sidebar>\n```\n\n## AI-Safe Usage Contract\n\n1. Use `items` for normal navigation. Use `content` only when data-driven rows cannot express the layout.\n2. Use local Entasis icon snippets such as `houseIcon`, not Lucide component constructors.\n3. Use `MenuItem[]` from `entasis/menu` for `menu` and action dropdowns.\n4. Do not combine `menu` with `href` or `onclick` on the same row; use `action` for a trailing row menu.\n5. Keep `children`, `header`, `content`, `footer`, `banner`, and action snippets pure; they receive `SidebarApi`.\n6. Use `collapsible=\"icon\"` for icon rail behavior, `collapsible=\"offcanvas\"` for hidden desktop panels, and `collapsible=\"none\"` for fixed sidebars. Icon collapse automatically falls back to offcanvas when any data-driven row lacks an icon.\n7. Offcanvas sidebars reveal over the content from the screen edge by default when hidden; set `edgeReveal={false}` to disable that. A revealed hidden sidebar keeps its resize handle and dismisses through a small rectangular pointer tolerance.\n8. Set `keyboardShortcut={false}` when embedding Sidebar inside another shortcut-heavy surface.\n9. Sidebar owns navigation, resize mechanics, the lower application wall, and variant surface geometry. AppShell forwards its variant and composes PageShell inside that surface.\n10. Use `size` for typography, icon scale, and item height. Use `density` independently for section padding, gaps, and submenu spacing.\n11. Use `activityBar` for a persistent icon rail outside the panel (section switching, workspaces). Every item needs an `icon` and a `label`; the label is the accessible name and the tooltip. It is layout mode only: `mode=\"panel\"` renders the navigation panel alone. The rail does not change the panel by itself: keep the selected rail item in state from `onSelect`, mark it `isActive`, and either derive the Sidebar's `items` from it or, for an animated switch, give each rail item a view in `views` and set `view` from it.\n12. Use `expandOnHover` only with `collapsible=\"icon\"`. It is a temporary peek, not a toggle: the persisted collapsed state never changes, while the peeked panel renders with expanded semantics.\n13. Use `views` when the panel's contents change as the user moves through the app: sections switched from the activity bar or the route, and nested menus that open inside the panel. Key each view, set `parent` on nested ones, open them with a row's `view`, and drive `view` from state or the URL. Do not hand-animate `items` swaps.\n\n## Data Model\n\n### SidebarGroup\n- **label**: string - Group label, hidden in icon-collapsed mode.\n- **items**: SidebarMenuEntry[] - Menu rows.\n- **tree**: SidebarTreeNode[] - Recursive tree rows instead of menu items.\n- **action**: SidebarMenuActionDescriptor | SidebarMenuActionDescriptor[] | Snippet<[SidebarApi]> - Group actions pinned to the label's end. The label truncates on one line before them. Pass an array to pin several affordances (a `+` and a drag handle) to one group header; each renders as its own icon-only ghost button.\n- **collapsible**: boolean - Makes the group label a toggle.\n- **defaultOpen**: boolean - Initial collapsible group state.\n- **separator**: boolean - Divider before the group.\n\n### SidebarMenuEntry\n- **label**: string - Visible row label.\n- **icon**: SidebarIcon - Entasis icon snippet or string.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - Leading icon treatment. `tile` paints a rounded square (`bg-color-muted text-color-muted-readable`) around the glyph, so per-project colour chips come from the role scale instead of hand-built markup.\n- **href**: string - Render as an anchor. Mutually exclusive with menu.\n- **onclick**: (event: MouseEvent) => void - Native click handler for button or anchor rows. Mutually exclusive with menu. On a row with `items` and no `href`, it runs before the submenu toggles, inline or in the collapsed flyout; `event.preventDefault()` skips the toggle.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **disabled**: boolean - Disables button rows and marks anchor rows disabled.\n- **badge**: string | number - Trailing count/status, hidden in icon mode. It follows the label inside the row (before any chevron), so the label truncates before it whatever its width. On a row with an `action`, the badge steps aside while the action shows (hover, focus, open menu) from `md` up, and sits just before the always-visible action below `md`: the two never overlap.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu. Collapsed to icons on desktop, a row with `items` and no `href`, `view` or `menu` opens them in a flyout of links instead.\n- **collapsible**: boolean - Set false for an always-open submenu.\n- **defaultOpen**: boolean - Initial nested menu state.\n- **menu**: MenuItem[] - Popup menu opened from the full row. Mutually exclusive with href/onclick.\n- **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. `menuSide` takes a physical side (`top`, `right`, `bottom`, `left`) or a logical one (`inline-start`, `inline-end`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (`inline-end` of a left sidebar, `inline-start` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on `headerButton` / `footerButton`.\n- **view**: string - Key of the `views` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.\n\n### SidebarMenuSubEntry\nA row of an inline submenu (`items` on a menu entry). It carries the same row features as a\ntop-level entry, so nested navigation (pages filed in folders) keeps its menus, pins and tints\nwithout flattening the tree. Every field below `size` is optional, and a sub entry that sets none\nof them renders exactly as a plain one.\n- **label**: string - Visible row label.\n- **href** / **onclick** - Render as an anchor, or a button with a native click handler.\n- **icon**: SidebarIcon - Leading icon.\n- **isActive** / **disabled** / **size** - As on a top-level entry; `size` defaults to the Sidebar size.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - `tile` paints the same tinted rounded square as a top-level row.\n- **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's `action` as a top-level badge does.\n- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, `href` or `onclick`, and `isActive`/`disabled` state: it shows no tooltip, `badge` or `action`.\n- **class**: string - Classes applied to the row button.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (`data-sidebar=\"menu-sub-action\"`), drawn by the `subAction` theme part: hidden until the row is hovered or focused from `md` up, kept visible while its menu is open, always visible below `md`. The label truncates before it.\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { folderIcon } from 'entasis/icons/folder';\n\timport { fileTextIcon } from 'entasis/icons/fileText';\n\timport { pencilSimpleIcon } from 'entasis/icons/pencilSimple';\n\timport { pushPinIcon } from 'entasis/icons/pushPin';\n\timport { trashIcon } from 'entasis/icons/trash';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Pages',\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\tlabel: 'Planning',\n\t\t\t\t\ticon: folderIcon,\n\t\t\t\t\tdefaultOpen: true,\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlabel: 'Q3 roadmap',\n\t\t\t\t\t\t\thref: '/pages/q3-roadmap',\n\t\t\t\t\t\t\ticon: fileTextIcon,\n\t\t\t\t\t\t\ticonColor: 'success',\n\t\t\t\t\t\t\ticonVariant: 'tile',\n\t\t\t\t\t\t\tbadge: 2,\n\t\t\t\t\t\t\taction: {\n\t\t\t\t\t\t\t\tlabel: 'More options for Q3 roadmap',\n\t\t\t\t\t\t\t\tmenu: [\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Rename', prefix: pencilSimpleIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Pin', prefix: pushPinIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Delete', prefix: trashIcon, color: 'danger' }\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ label: 'Retro notes', href: '/pages/retro-notes', icon: fileTextIcon }\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar {items} />\n```\n\n### SidebarView\nOne named panel content in `views`.\n- **label**: string - The view's name, shown on the back row of the views nested under it.\n- **parent**: string - Key of the view this one is nested under. A nested view opens with a back row to its parent (named \"Back, <parent label>\"), and on mobile a swipe toward the inline end goes back. A view without `parent` is a top-level section.\n- **items**: SidebarGroup[] / **content**: Snippet<[SidebarApi]> - The view's body.\n- **headerButton**, **search**, **headerMenu**, **header**, **footerButton**, **footerMenu**, **footer** - Header and footer props for this view. Each one left undefined comes from the parent view, then from the Sidebar's own prop; `null` removes an inherited one.\n\nChanging the view slides the two views side by side, like pages: a deeper view comes in from the inline end while the old one leaves to the start, a shallower one slides back the other way, and between views at the same depth (sections) the later one in `views` counts as forward. When both views get every header and footer prop from the same place, the header and footer stay still and only the menu slides; when a view changes any of them, the whole panel slides as one page. `api.view` reads the current view and `api.setView(key)` changes it from a snippet.\n\n### SidebarActivityBar\nIcon rail pinned to the outer edge of the sidebar, visible in every display state.\n- **items**: SidebarActivityBarItem[] - Items rendered from the top.\n- **footerItems**: SidebarActivityBarItem[] - Items pinned to the end of the column.\n- **header** / **footer**: Snippet - Custom content before the first item and after the pinned ones.\n- **width**: string (default '3rem') - Column thickness, published as `--sidebar-width-activity`.\n- **label**: string - Accessible name for the column landmark. Set it whenever the panel also renders navigation.\n- **onSelect**: ({ item, index }) => void - Fires after an item is activated. `index` counts `items` then `footerItems`.\n\n### SidebarActivityBarItem\n- **icon**: SidebarIcon (required) - Icon rendered in the square.\n- **label**: string (required) - Accessible name and default tooltip; the square shows no text.\n- **id**: string - Stable render key.\n- **href** / **target** / **rel** - Render an anchor instead of a button.\n- **onclick**: (event: MouseEvent) => void - Native click handler.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **badge**: string | number | Snippet - Corner badge pinned to the outer top corner. An empty string renders a bare dot. A string or number badge joins the accessible name (`\"Alerts, 3\"`); a dot and a Snippet badge are decorative, so put their meaning in `label`.\n- **disabled**: boolean - Blocks activation and skips the item during keyboard navigation.\n- **tooltip**: string | false - Tooltip override; `false` suppresses it.\n\n### SidebarMenuButtonItem\nUse for `headerButton`, `footerButton`, or direct `<SidebarMenuButton />` rows.\n- **icon**: SidebarIcon - Leading logo/icon.\n- **avatar**: { src?: string; alt?: string; fallback?: string } - Leading avatar.\n- **variant**: 'default' | 'brand' | 'compact'.\n- **title**: string - Primary text.\n- **subtitle**: string - Secondary text.\n- **trailing**: SidebarIcon | false | SidebarMenuActionDescriptor - Trailing content. An icon is decorative; an action descriptor (`{ icon, label, onclick }`, the same shape as a group action) renders its own icon-only ghost button beside the row, so a workspace card can carry its own collapse control without a custom `header` snippet. Descriptor handlers are `onclick(event, api)`, so `api.toggle()` is reachable.\n- **href** / **onclick** / **menu** - Choose link, button, or popup behavior. `onclick(event, api)` receives the SidebarApi beside the event, so `api.toggle()` is reachable from the row itself.\n- **menuIconClass**: string - Class override for option icons inside the popup menu.\n\n## Props\n\n### State\n- **open**: boolean (bindable, default true) - Desktop expanded state.\n- **defaultOpen**: boolean (default true) - Initial desktop state when `open` is omitted.\n- **onOpenChange**: (open: boolean) => void - Called once for a library-originated desktop state change. Repeated requests and parent prop updates stay silent.\n- **onDisplayStateChange**: (state: SidebarDisplayState) => void - Called once for a library-originated semantic display-state change.\n- **view**: string (bindable) - Key of the view on screen when `views` is set. Defaults to `defaultView`, then the first view.\n- **defaultView**: string - Initial view when `view` is omitted.\n- **onViewChange**: (view: string) => void - Called once for a library-originated view change: a view row, a back row, a swipe. Parent prop updates stay silent; with a route-driven `view`, navigate here.\n- **api.displayState**: 'expanded' | 'collapsed' | 'hidden' - Semantic desktop state; hidden means closed offcanvas. A hover peek does not change it.\n- **api.isPeeking**: boolean - True while a hover peek renders the collapsed panel at full width. Read it alongside `displayState` when a snippet hides content in icon mode.\n- **keyboardShortcut**: string | false (default 'b') - Ctrl/Cmd shortcut key.\n\n### Layout\n- **side**: 'left' | 'right' - Desktop and mobile side.\n- **variant**: 'admin' | 'floating' | 'inset' | 'split' | 'framed' - Sidebar geometry. `framed` is the admin geometry for a sidebar hosted inside a raised card (AppShell variant framed), with a `surface-recessed` well. `admin` renders the conventional full-height navigation column; `inset` integrates navigation into the lower wall with an inset content surface; `split` renders detached sidebar and content surfaces.\n- **size**: 'small' | 'normal' | 'large' (default 'normal') - Typography, icon, avatar, badge, leading-media, item-height, and search-height scale.\n- **iconSize**: 'small' | 'normal' | 'large' - Icon and leading-media scale inside the panel on its own; defaults to `size`. Menu rows, sub rows, group labels and the header button follow it; the activity bar keeps `size`.\n- **activeVariant**: 'soft' | 'outline' | 'solid' (default 'soft') - How active rows are painted. `soft` is the shared selected recipe (`selectedSoft`: `bg-selected-muted text-selected-muted-readable`), `solid` its loud counterpart (`selectedSolid`: `bg-selected text-selected-contrast`), `outline` a bordered surface card (`bg-surface border border-neutral-muted text-neutral`) that reads as a raised card on a tinted well. Each row carries the choice as `data-active-variant`, so no descendant selector is needed to restyle selection.\n- **density**: 'compact' | 'normal' | 'comfortable' (default 'normal') - Section padding, group padding, gaps, horizontal inset, and submenu spacing.\n- **collapsible**: 'offcanvas' | 'icon' | 'none' - Collapse behavior. Icon mode requires icons on every data-driven row and otherwise resolves to offcanvas.\n- **collapseIcon**: DisclosureIndicator \u2014 'chevron' | 'plus-minus' | 'none' (default 'chevron') - Disclosure indicator drawn on collapsible menu rows. 'none' renders no indicator.\n- **mode**: 'layout' | 'panel' - Full resizing layout or only the visible navigation panel.\n- **frame**: 'viewport' | 'contained' - Standalone Sidebar positioning. Viewport mode uses Theme's dynamic window-height token; contained mode fills a positioned parent.\n- **width**: string - Expanded width.\n- **widthIcon**: string - Icon-collapsed width.\n- **widthMobile**: string - Mobile drawer width. The drawer runs the full screen height, flush with its edge (no inset); an activity bar adds its own width beside it. A swipe toward its edge closes it, from anywhere on it, rows included. Following a link in the panel closes it too (a plain click, also when a client-side router handles it); the activity bar's links, buttons, and clicks that open elsewhere (a modifier key, `target=\"_blank\"`, `download`) keep it open.\n- **rail**: boolean | 'line' | 'thumb' - Edge toggle rail. `true` keeps the thin line style; `thumb` renders a short visible handle with the same full-height hitbox. The appearance is preserved when the rail shares the resize control.\n- **activityBar**: SidebarActivityBar - Icon rail pinned outside the panel, in layout mode only (`mode=\"panel\"` renders the panel alone and ignores it). It never slides off screen: only the panel takes the offcanvas offset, and the reserved layout column is the panel width plus the rail width. It wears the surface of the variant's panel: a hairline column on the canvas for `admin`, the recessed well for `framed`, borderless on the canvas for `inset`, and a card of its own (the panel's radius, edge and gutter) for `floating` and `split`. Every gutter between the activity bar, the panel and the page, and between them and the frame edge, is `--space-md`, so it scales with the theme's spacing. With `inset` and `split`, hiding the panel normally lets the page drop its frame and fill the edge; with an activity bar the rail is still beside the page, so the page keeps its framed form (padding, rounding, AppShell's border and shadow), and a `split` page takes the gutter the panel used to supply. The wrapper carries `data-page-flush` and the `main` part a `flush` variant only when the page does fill the edge. On mobile it stays a column at the drawer's outer edge, beside the panel, as the hairline column on the canvas whatever the variant.\n- **expandOnHover**: boolean (default false) - With `collapsible=\"icon\"`, hovering or focusing into the collapsed panel expands it to `width` over the page (`data-peek=\"true\"`) while the reserved column stays at `widthIcon`, so page content does not reflow. The persisted collapsed state is untouched, and the peeked panel renders exactly like an expanded one: group headers, badges, search, inline submenus, and inline tree branches all come back, and the rail or resize handle travels to its inner edge.\n- **edgeReveal**: boolean (default true) - Pointer/focus edge preview for hidden offcanvas sidebars. Hover reveal overlays content, remains resizable when configured, and re-hides after the pointer leaves its small rectangular tolerance. Dragging the sidebar closed suppresses immediate hover reopening until the pointer leaves the edge trigger; toggle/click opens persistently.\n- **resizable**: boolean | SidebarResizableOptions - Enables pointer and keyboard resizing while expanded, icon-collapsed, or temporarily edge-revealed. `minWidth` and `maxWidth` (a CSS length or a number of px; defaults `12rem` and `32rem`) bound both the drag and the keyboard resize; a `maxWidth` below `minWidth` throws. By default, collapse requires dragging 75% of `minWidth` beyond the minimum; override `collapseThreshold` for a custom boundary. Use `storageKey` to restore and persist the expanded width across sessions.\n - `onWidthChange({ width, isUserInteraction })` reports every expanded-width change with one named payload: continuously while the user resizes (`isUserInteraction: true`) and once when a stored width is restored (`isUserInteraction: false`).\n\n### Content\n- **items**: SidebarGroup[] - Data-driven body navigation.\n- **views**: Record<string, SidebarView> - Named panel contents, one on screen at a time, replacing `items` and `content`. See SidebarView.\n- **headerButton** / **footerButton**: SidebarMenuButtonItem - Sticky large rows.\n- **search**: SidebarSearch - Header search input; use its native `oninput` handler.\n- **headerMenu** / **footerMenu**: SidebarMenuEntry[] - Sticky quick menus.\n- Menu entries and nested entries accept `size: 'small' | 'normal' | 'large'` for row geometry.\n- **header**, **content**, **footer**, **children**, **banner**: Snippet<[SidebarApi]> - Escape hatches. The `header` snippet renders **first** in the header region, above `headerButton`, `search` and `headerMenu`.\n\n### Styling\n- **class**: string - Classes applied to the Sidebar root.\n- **theme**: SidebarThemeProps - Semantic part overrides such as `panel`, `header`, `nav`, `footer`, menu, search, rail, and mobile drawer parts. Every part, with the element it lands on, its variants and their default classes, is listed in the entasis skill's `theme-parts/sidebar.md`; the registry key is `sidebar`.\n\n## Motion\n\n- **motion** theme slot: the height animation shared by collapsible groups, inline submenus, and\n tree branches. Its `in` / `out` `duration`, `delay` and `easing` (motion tokens) time the\n open and the close; the panel animates its height, block padding, borders and margins.\n- Ladder: `<Theme components={{ sidebar: { motion } }}>` \u2192 `setSidebarTheme({ motion })` \u2192\n `theme.motion`. Reduced motion collapses it to 0.\n- The `view` variant (`part: 'view'`) is the pager between views: `in.x` / `out.x` is how far a view travels (default `'100%'`: the two views slide side by side like pages) while it fades between `opacity` (default 0) and 1, at the `slow` duration on the `enter` easing. The back swipe moves the same layers the view change would, with the same travel and opacity. Reduced motion makes the switch instant while a swipe still follows the finger.\n\n## Restyle recipes\n\nThe five asks that come up first, as the override to copy. Each one is rendered and asserted by\n`sidebar-recipes.svelte.test.ts`, so it cannot drift from the component. One rule behind them: a\ndefault written under a variant prefix (`data-[active-variant=solid]:data-active:bg-selected`,\n`data-[side=left]:border-r`) is only replaced by an override carrying the same prefixes; an\nunprefixed class coexists with it and loses on specificity. `theme-parts/sidebar.md` shows every\ndefault verbatim, prefixes included.\n\n- **Dark panel.** `dark` scopes the dark theme's variables to the panel, so every neutral ink inside\n flips with it (every theme also emits its variables on `.<name>`, so the class scopes the dark theme to one subtree); needs a theme named `dark`, which the documented setup declares.\n `theme={{ panel: { base: 'dark bg-slate-900' }, mobilePanel: { base: 'dark bg-slate-900' } }}`\n- **Active row in your colour.** `activeVariant=\"solid\"` plus the same prefix chain as the default:\n `theme={{ menuButton: { base: 'rounded-full', activeVariant: { solid: 'data-[active-variant=solid]:data-active:bg-indigo-600 data-[active-variant=solid]:data-active:text-white' } } }}`\n- **No hover change.** The overlay is a `::before` and the default also brightens the ink on hover:\n `theme={{ menuButton: { base: 'state-layer-none hover:text-inherit' }, subButton: { base: 'state-layer-none hover:text-neutral/70' } }}`\n- **Flat panel.** The edge is a prefixed `border-r` / `border-l` on the admin variant, the shadow a\n `raised-*` on the floating ones:\n `theme={{ panel: { base: 'raised-none', variant: { admin: 'data-[side=left]:border-r-0 data-[side=right]:border-l-0' } } }}`\n- **Row height.** A plain height beats the compound `h-control-*`: `theme={{ menuButton: { base: 'h-11' } }}`\n- **Bigger icons.** A prop, not a theme: `iconSize=\"large\"`.\n\n## Styling hooks\n\n`data-open` on a disclosure toggle is a presence attribute (before 0.12.0 a submenu toggle carried `data-open=\"true\"`); match it with `[data-open]` or `data-open:`.\n\nAttributes are presence attributes (set while the state holds, omitted otherwise) unless they carry a value.\n\n**Data attributes**\n- `data-state`: On the root: `expanded` or `collapsed`.\n- `data-display-state`: On the root: what is on screen, `expanded`, `collapsed` (the icon rail) or `hidden`.\n- `data-collapsible`: On the root: `icon` while collapsed to the icon rail, `offcanvas` while hidden, empty while expanded.\n- `data-side`: On the root, the panel and its rail and handles: `left` or `right`.\n- `data-variant`: On the root: the resolved `variant`.\n- `data-size`: On the root: the resolved `size`.\n- `data-density`: On the root: the resolved `density`.\n- `data-peek`: On the layout wrapper: `true` while a hover peek shows the collapsed panel at full width.\n- `data-active`: On a row: `true` while it is the current page (`isActive`).\n- `data-active-variant`: On a row: the resolved `activeVariant`.\n- `data-open`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is open.\n- `data-closed`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is closed, including during the exit transition.\n- `data-entering`: On a group, submenu or tree branch panel: present while its opening animation plays.\n- `data-exiting`: On a group, submenu or tree branch panel: present while its closing animation plays.\n- `data-popup-open`: On a row: present while one of its popups is open, its `menu`, its flyout on the collapsed rail, or its tooltip (every row on the collapsed rail, and every row with `tooltips=\"always\"`). Match `[aria-expanded=\"true\"]` to style an open menu or flyout only.\n- `data-sidebar`: The part (`sidebar`, `menu-button`, `menu-sub`, `group-content`, `rail`, ...).\n\n**CSS variables**\n- `--sidebar-width`: On the root: the expanded panel width.\n- `--sidebar-width-icon`: On the root: the width of the collapsed icon rail.\n- `--sidebar-width-mobile`: On the root: the width of the mobile drawer.\n- `--disclosure-panel-height`: On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.\n\nThe typed maps `sidebarDataAttributes` and `sidebarCssVariables` (from `entasis/sidebar`) list each name with its meaning.\n\n## Accessibility\n\n- The body navigation renders inside a named `<nav>` landmark (`data-sidebar=\"nav\"`), so assistive tech can jump straight to it and tell it apart from the activity bar's own landmark.\n- Active links set `aria-current=\"page\"`.\n- Collapsible rows and groups set `aria-expanded`.\n- Disabled buttons use `disabled`; disabled links omit `href`, use `aria-disabled` and `tabindex=-1`, and block activation.\n- Mobile drawer includes a backdrop button labelled \"Close Sidebar\".\n- Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.\n- Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.\n- A submenu row's `action` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes `label` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons.\n- Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared `disclosurePanel` Collapsible and Accordion use): the toggle sets `aria-expanded` and, while the panel is rendered, `aria-controls` to its id; the panel carries `data-open` / `data-closed` and `data-entering` / `data-exiting`, exposes `--disclosure-panel-height` while it animates, and stays `inert` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the `motion` slot's duration, delay and easing time them.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with `items` and no `href`, `view` or `menu`, is a disclosure button (`aria-expanded`, `aria-controls`) that opens a flyout holding a named `<nav>` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an `onclick` and no `href` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and `preventDefault()` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no `role=\"menu\"`; root leaves retain direct navigation and tooltips.\n- Row menus, flyouts and tooltips open toward the page: at the inline end of a `left` sidebar and the inline start of a `right` one, the side the layout row puts the page on in either reading direction (a `left` sidebar is the row's start, on the right in right-to-left). An explicit `menuSide` wins.\n- When `rail` and `resizable` are both enabled, one edge control owns click-to-toggle, drag resize, and keyboard resize without overlapping hitboxes.\n- Hidden offcanvas sidebars keep that combined edge control while temporarily revealed. Resizing does not pin the sidebar open; leaving the panel, trigger, and handle tolerance re-hides it without discarding the configured width.\n- Both peeks (edge reveal and `expandOnHover`) stay open while focus is inside the panel or while an overlay opened from inside it is open, including nested submenus. They release about 120ms after the pointer, focus, and every such overlay are gone.\n- The activity bar is its own `<nav>` landmark with a `<ul>` of items, a roving tabindex, and ArrowUp/ArrowDown/Home/End navigation that loops and skips disabled items. Tab lands on the `isActive` item. Each square takes its accessible name from `label`, with a string or number `badge` appended to it, and shows `label` as a tooltip on hover and focus.\n- A hidden offcanvas panel is `inert`, so Tab never lands in a panel parked off screen; a peek makes it interactive again.\n- Views: when focus sat in the view being replaced, it lands on the back row after going deeper and on the row that opened the view after coming back. A view change from outside the panel (a route) leaves focus where it is. The back swipe is a shortcut; the back row stays the accessible path.\n\n## Notes\n\n- Dropdown menus use Entasis `PopupMenu` and `MenuItem[]`.\n- The component uses semantic Entasis tokens. Do not add shadcn `sidebar-*` color tokens.\n- Snippet icons from `entasis/icons/*` are the preferred icon format.\n";
|
|
1
|
+
export declare const sidebarDescription = "\n# Sidebar Component\n\nSidebar navigation with data-driven groups, icon collapse, mobile drawer behavior,\nrecursive tree groups, header/footer rows, search, actions, and snippet escape hatches.\n\n## Import\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n</script>\n```\n\n## Basic Usage\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { houseIcon } from 'entasis/icons/house';\n\timport { gearIcon } from 'entasis/icons/gear';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Workspace',\n\t\t\titems: [\n\t\t\t\t{ label: 'Dashboard', href: '/', icon: houseIcon, isActive: true },\n\t\t\t\t{ label: 'Settings', href: '/settings', icon: gearIcon }\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar items={items}>\n\t{#snippet children({ toggle })}\n\t\t<header>\n\t\t\t<button type=\"button\" onclick={toggle}>Toggle</button>\n\t\t</header>\n\t\t<main>Page content</main>\n\t{/snippet}\n</Sidebar>\n```\n\n## AI-Safe Usage Contract\n\n1. Use `items` for normal navigation. Use `content` only when data-driven rows cannot express the layout.\n2. Use local Entasis icon snippets such as `houseIcon`, not Lucide component constructors.\n3. Use `MenuItem[]` from `entasis/menu` for `menu` and action dropdowns.\n4. Do not combine `menu` with `href` or `onclick` on the same row; use `action` for a trailing row menu.\n5. Keep `children`, `header`, `content`, `footer`, `banner`, and action snippets pure; they receive `SidebarApi`.\n6. Use `collapsible=\"icon\"` for icon rail behavior, `collapsible=\"offcanvas\"` for hidden desktop panels, and `collapsible=\"none\"` for fixed sidebars. Icon collapse automatically falls back to offcanvas when any data-driven row lacks an icon.\n7. Offcanvas sidebars reveal over the content from the screen edge by default when hidden; set `edgeReveal={false}` to disable that. A revealed hidden sidebar keeps its resize handle and dismisses through a small rectangular pointer tolerance.\n8. Set `keyboardShortcut={false}` when embedding Sidebar inside another shortcut-heavy surface.\n9. Sidebar owns navigation, resize mechanics, the lower application wall, and variant surface geometry. AppShell forwards its variant and composes PageShell inside that surface.\n10. Use `size` for typography, icon scale, and item height. Use `density` independently for section padding, gaps, and submenu spacing.\n11. Use `activityBar` for a persistent icon rail outside the panel (section switching, workspaces). Every item needs an `icon` and a `label`; the label is the accessible name and the tooltip. It is layout mode only: `mode=\"panel\"` renders the navigation panel alone. The rail does not change the panel by itself: keep the selected rail item in state from `onSelect`, mark it `isActive`, and either derive the Sidebar's `items` from it or, for an animated switch, give each rail item a view in `views` and set `view` from it.\n12. Use `expandOnHover` only with `collapsible=\"icon\"`. It is a temporary peek, not a toggle: the persisted collapsed state never changes, while the peeked panel renders with expanded semantics.\n13. Use `views` when the panel's contents change as the user moves through the app: sections switched from the activity bar or the route, and nested menus that open inside the panel. Key each view, set `parent` on nested ones, open them with a row's `view`, and drive `view` from state or the URL. Do not hand-animate `items` swaps.\n\n## Data Model\n\n### SidebarGroup\n- **label**: string - Group label, hidden in icon-collapsed mode.\n- **items**: SidebarMenuEntry[] - Menu rows.\n- **tree**: SidebarTreeNode[] - Recursive tree rows instead of menu items.\n- **action**: SidebarMenuActionDescriptor | SidebarMenuActionDescriptor[] | Snippet<[SidebarApi]> - Group actions pinned to the label's end. The label truncates on one line before them. Pass an array to pin several affordances (a `+` and a drag handle) to one group header; each renders as its own icon-only ghost button.\n- **collapsible**: boolean - Makes the group label a toggle.\n- **defaultOpen**: boolean - Initial collapsible group state.\n- **separator**: boolean - Divider before the group.\n\n### SidebarMenuEntry\n- **label**: string - Visible row label.\n- **icon**: SidebarIcon - Entasis icon snippet or string.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - Leading icon treatment. `tile` paints a rounded square (`bg-color-muted text-color-muted-readable`) around the glyph, so per-project colour chips come from the role scale instead of hand-built markup.\n- **href**: string - Render as an anchor. Mutually exclusive with menu.\n- **onclick**: (event: MouseEvent) => void - Native click handler for button or anchor rows. Mutually exclusive with menu. On a row with `items` and no `href`, it runs before the submenu toggles, inline or in the collapsed flyout; `event.preventDefault()` skips the toggle.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **disabled**: boolean - Disables button rows and marks anchor rows disabled.\n- **badge**: string | number - Trailing count/status, hidden in icon mode. It follows the label inside the row (before any chevron), so the label truncates before it whatever its width. On a row with an `action`, the badge steps aside while the action shows (hover, focus, open menu) from `md` up, and sits just before the always-visible action below `md`: the two never overlap.\n- **tooltip**: string - Entasis Tooltip content in icon mode. Defaults to label.\n- **items**: SidebarMenuSubEntry[] - Inline nested menu. Collapsed to icons on desktop, a row with `items` and no `href`, `view` or `menu` opens them in a flyout of links instead.\n- **collapsible**: boolean - Set false for an always-open submenu.\n- **defaultOpen**: boolean - Initial nested menu state.\n- **menu**: MenuItem[] - Popup menu opened from the full row. Mutually exclusive with href/onclick.\n- **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. `menuSide` takes a physical side (`top`, `right`, `bottom`, `left`) or a logical one (`inline-start`, `inline-end`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (`inline-end` of a left sidebar, `inline-start` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on `headerButton` / `footerButton`.\n- **view**: string - Key of the `views` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n### SidebarMenuSubEntry\nA row of an inline submenu (`items` on a menu entry). It carries the same row features as a\ntop-level entry, so nested navigation (pages filed in folders) keeps its menus, pins and tints\nwithout flattening the tree. Every field below `size` is optional, and a sub entry that sets none\nof them renders exactly as a plain one.\n- **label**: string - Visible row label.\n- **href** / **onclick** - Render as an anchor, or a button with a native click handler.\n- **icon**: SidebarIcon - Leading icon.\n- **isActive** / **disabled** / **size** - As on a top-level entry; `size` defaults to the Sidebar size.\n- **iconColor**: Colors - Role tint for the leading icon, applied through `data-color`.\n- **iconVariant**: 'bare' | 'tile' - `tile` paints the same tinted rounded square as a top-level row.\n- **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's `action` as a top-level badge does.\n- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, `href` or `onclick`, and `isActive`/`disabled` state: it shows no tooltip, `badge`, `suffix` or `action`.\n- **class**: string - Classes applied to the row button.\n- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (`data-sidebar=\"menu-sub-action\"`), drawn by the `subAction` theme part: hidden until the row is hovered or focused from `md` up, kept visible while its menu is open, always visible below `md`. The label truncates before it.\n- **suffix**: Snippet<[SidebarApi, SidebarMenuSubEntry]> - Content at the row's end, beside the label: a count, a `Chip`, an icon, a status dot, its own `PopupMenu` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (`{#snippet suffix(api, entry)}`) and tell themselves apart by `entry.href`. It renders beside the row's link or button, never inside it (`data-sidebar=\"menu-sub-suffix\"`, drawn by the `suffix` theme part), so it may hold controls: a click or a keypress inside it never follows the row's `href` nor toggles it, and Tab reaches its controls right after the row, before the `action`. The row's link or button is `aria-describedby` the suffix, so the row reads with it (\"Dossiers\", \"20\"). The label truncates before it: the row reserves its measured width (`--sidebar-suffix-width`), and the `suffix` part bounds it (`max-w-[40%]` of the row), so a wide suffix clips instead of breaking the row. With an `action`, it stays visible just before the action at every width (the row keeps the action's room, and a `badge` then stays put instead of stepping aside). Hidden, `inert` and `aria-hidden` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.\n\n```svelte\n<script lang=\"ts\">\n\timport { Sidebar, type SidebarGroup } from 'entasis/sidebar';\n\timport { folderIcon } from 'entasis/icons/folder';\n\timport { fileTextIcon } from 'entasis/icons/fileText';\n\timport { pencilSimpleIcon } from 'entasis/icons/pencilSimple';\n\timport { pushPinIcon } from 'entasis/icons/pushPin';\n\timport { trashIcon } from 'entasis/icons/trash';\n\n\tconst items: SidebarGroup[] = [\n\t\t{\n\t\t\tlabel: 'Pages',\n\t\t\titems: [\n\t\t\t\t{\n\t\t\t\t\tlabel: 'Planning',\n\t\t\t\t\ticon: folderIcon,\n\t\t\t\t\tdefaultOpen: true,\n\t\t\t\t\titems: [\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlabel: 'Q3 roadmap',\n\t\t\t\t\t\t\thref: '/pages/q3-roadmap',\n\t\t\t\t\t\t\ticon: fileTextIcon,\n\t\t\t\t\t\t\ticonColor: 'success',\n\t\t\t\t\t\t\ticonVariant: 'tile',\n\t\t\t\t\t\t\tbadge: 2,\n\t\t\t\t\t\t\taction: {\n\t\t\t\t\t\t\t\tlabel: 'More options for Q3 roadmap',\n\t\t\t\t\t\t\t\tmenu: [\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Rename', prefix: pencilSimpleIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Pin', prefix: pushPinIcon },\n\t\t\t\t\t\t\t\t\t{ type: 'option', title: 'Delete', prefix: trashIcon, color: 'danger' }\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ label: 'Retro notes', href: '/pages/retro-notes', icon: fileTextIcon }\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t]\n\t\t}\n\t];\n</script>\n\n<Sidebar {items} />\n```\n\n### SidebarView\nOne named panel content in `views`.\n- **label**: string - The view's name, shown on the back row of the views nested under it.\n- **parent**: string - Key of the view this one is nested under. A nested view opens with a back row to its parent (named \"Back, <parent label>\"), and on mobile a swipe toward the inline end goes back. A view without `parent` is a top-level section.\n- **items**: SidebarGroup[] / **content**: Snippet<[SidebarApi]> - The view's body.\n- **headerButton**, **search**, **headerMenu**, **header**, **footerButton**, **footerMenu**, **footer** - Header and footer props for this view. Each one left undefined comes from the parent view, then from the Sidebar's own prop; `null` removes an inherited one.\n\nChanging the view slides the two views side by side, like pages: a deeper view comes in from the inline end while the old one leaves to the start, a shallower one slides back the other way, and between views at the same depth (sections) the later one in `views` counts as forward. When both views get every header and footer prop from the same place, the header and footer stay still and only the menu slides; when a view changes any of them, the whole panel slides as one page. `api.view` reads the current view and `api.setView(key)` changes it from a snippet.\n\n### SidebarActivityBar\nIcon rail pinned to the outer edge of the sidebar, visible in every display state.\n- **items**: SidebarActivityBarItem[] - Items rendered from the top.\n- **footerItems**: SidebarActivityBarItem[] - Items pinned to the end of the column.\n- **header** / **footer**: Snippet - Custom content before the first item and after the pinned ones.\n- **width**: string (default '3rem') - Column thickness, published as `--sidebar-width-activity`.\n- **label**: string - Accessible name for the column landmark. Set it whenever the panel also renders navigation.\n- **onSelect**: ({ item, index }) => void - Fires after an item is activated. `index` counts `items` then `footerItems`.\n\n### SidebarActivityBarItem\n- **icon**: SidebarIcon (required) - Icon rendered in the square.\n- **label**: string (required) - Accessible name and default tooltip; the square shows no text.\n- **id**: string - Stable render key.\n- **href** / **target** / **rel** - Render an anchor instead of a button.\n- **onclick**: (event: MouseEvent) => void - Native click handler.\n- **isActive**: boolean - Adds active styling and `aria-current=\"page\"`.\n- **badge**: string | number | Snippet - Corner badge pinned to the outer top corner. An empty string renders a bare dot. A string or number badge joins the accessible name (`\"Alerts, 3\"`); a dot and a Snippet badge are decorative, so put their meaning in `label`.\n- **disabled**: boolean - Blocks activation and skips the item during keyboard navigation.\n- **tooltip**: string | false - Tooltip override; `false` suppresses it.\n\n### SidebarMenuButtonItem\nUse for `headerButton`, `footerButton`, or direct `<SidebarMenuButton />` rows.\n- **icon**: SidebarIcon - Leading logo/icon.\n- **avatar**: { src?: string; alt?: string; fallback?: string } - Leading avatar.\n- **variant**: 'default' | 'brand' | 'compact'.\n- **title**: string - Primary text.\n- **subtitle**: string - Secondary text.\n- **trailing**: SidebarIcon | false | SidebarMenuActionDescriptor - Trailing content. An icon is decorative; an action descriptor (`{ icon, label, onclick }`, the same shape as a group action) renders its own icon-only ghost button beside the row, so a workspace card can carry its own collapse control without a custom `header` snippet. Descriptor handlers are `onclick(event, api)`, so `api.toggle()` is reachable.\n- **href** / **onclick** / **menu** - Choose link, button, or popup behavior. `onclick(event, api)` receives the SidebarApi beside the event, so `api.toggle()` is reachable from the row itself.\n- **menuIconClass**: string - Class override for option icons inside the popup menu.\n\n## Props\n\n### State\n- **open**: boolean (bindable, default true) - Desktop expanded state.\n- **defaultOpen**: boolean (default true) - Initial desktop state when `open` is omitted.\n- **onOpenChange**: (open: boolean) => void - Called once for a library-originated desktop state change. Repeated requests and parent prop updates stay silent.\n- **onDisplayStateChange**: (state: SidebarDisplayState) => void - Called once for a library-originated semantic display-state change.\n- **view**: string (bindable) - Key of the view on screen when `views` is set. Defaults to `defaultView`, then the first view.\n- **defaultView**: string - Initial view when `view` is omitted.\n- **onViewChange**: (view: string) => void - Called once for a library-originated view change: a view row, a back row, a swipe. Parent prop updates stay silent; with a route-driven `view`, navigate here.\n- **api.displayState**: 'expanded' | 'collapsed' | 'hidden' - Semantic desktop state; hidden means closed offcanvas. A hover peek does not change it.\n- **api.isPeeking**: boolean - True while a hover peek renders the collapsed panel at full width. Read it alongside `displayState` when a snippet hides content in icon mode.\n- **keyboardShortcut**: string | false (default 'b') - Ctrl/Cmd shortcut key.\n\n### Layout\n- **side**: 'left' | 'right' - Desktop and mobile side.\n- **variant**: 'admin' | 'floating' | 'inset' | 'split' | 'framed' - Sidebar geometry. `framed` is the admin geometry for a sidebar hosted inside a raised card (AppShell variant framed), with a `surface-recessed` well. `admin` renders the conventional full-height navigation column; `inset` integrates navigation into the lower wall with an inset content surface; `split` renders detached sidebar and content surfaces.\n- **size**: 'small' | 'normal' | 'large' (default 'normal') - Typography, icon, avatar, badge, leading-media, item-height, and search-height scale.\n- **iconSize**: 'small' | 'normal' | 'large' - Icon and leading-media scale inside the panel on its own; defaults to `size`. Menu rows, sub rows, group labels and the header button follow it; the activity bar keeps `size`.\n- **activeVariant**: 'soft' | 'outline' | 'solid' (default 'soft') - How active rows are painted. `soft` is the shared selected recipe (`selectedSoft`: `bg-selected-muted text-selected-muted-readable`), `solid` its loud counterpart (`selectedSolid`: `bg-selected text-selected-contrast`), `outline` a bordered surface card (`bg-surface border border-neutral-muted text-neutral`) that reads as a raised card on a tinted well. Each row carries the choice as `data-active-variant`, so no descendant selector is needed to restyle selection.\n- **density**: 'compact' | 'normal' | 'comfortable' (default 'normal') - Section padding, group padding, gaps, horizontal inset, and submenu spacing.\n- **collapsible**: 'offcanvas' | 'icon' | 'none' - Collapse behavior. Icon mode requires icons on every data-driven row and otherwise resolves to offcanvas.\n- **collapseIcon**: DisclosureIndicator \u2014 'chevron' | 'plus-minus' | 'none' (default 'chevron') - Disclosure indicator drawn on collapsible menu rows. 'none' renders no indicator.\n- **mode**: 'layout' | 'panel' - Full resizing layout or only the visible navigation panel.\n- **frame**: 'viewport' | 'contained' - Standalone Sidebar positioning. Viewport mode uses Theme's dynamic window-height token; contained mode fills a positioned parent.\n- **width**: string - Expanded width.\n- **widthIcon**: string - Icon-collapsed width.\n- **widthMobile**: string - Mobile drawer width. The drawer runs the full screen height, flush with its edge (no inset); an activity bar adds its own width beside it. A swipe toward its edge closes it, from anywhere on it, rows included. Following a link in the panel closes it too (a plain click, also when a client-side router handles it); the activity bar's links, buttons, and clicks that open elsewhere (a modifier key, `target=\"_blank\"`, `download`) keep it open.\n- **rail**: boolean | 'line' | 'thumb' - Edge toggle rail. `true` keeps the thin line style; `thumb` renders a short visible handle with the same full-height hitbox. The appearance is preserved when the rail shares the resize control.\n- **activityBar**: SidebarActivityBar - Icon rail pinned outside the panel, in layout mode only (`mode=\"panel\"` renders the panel alone and ignores it). It never slides off screen: only the panel takes the offcanvas offset, and the reserved layout column is the panel width plus the rail width. It wears the surface of the variant's panel: a hairline column on the canvas for `admin`, the recessed well for `framed`, borderless on the canvas for `inset`, and a card of its own (the panel's radius, edge and gutter) for `floating` and `split`. Every gutter between the activity bar, the panel and the page, and between them and the frame edge, is `--space-md`, so it scales with the theme's spacing. With `inset` and `split`, hiding the panel normally lets the page drop its frame and fill the edge; with an activity bar the rail is still beside the page, so the page keeps its framed form (padding, rounding, AppShell's border and shadow), and a `split` page takes the gutter the panel used to supply. The wrapper carries `data-page-flush` and the `main` part a `flush` variant only when the page does fill the edge. On mobile it stays a column at the drawer's outer edge, beside the panel, as the hairline column on the canvas whatever the variant.\n- **expandOnHover**: boolean (default false) - With `collapsible=\"icon\"`, hovering or focusing into the collapsed panel expands it to `width` over the page (`data-peek=\"true\"`) while the reserved column stays at `widthIcon`, so page content does not reflow. The persisted collapsed state is untouched, and the peeked panel renders exactly like an expanded one: group headers, badges, search, inline submenus, and inline tree branches all come back, and the rail or resize handle travels to its inner edge.\n- **edgeReveal**: boolean (default true) - Pointer/focus edge preview for hidden offcanvas sidebars. Hover reveal overlays content, remains resizable when configured, and re-hides after the pointer leaves its small rectangular tolerance. Dragging the sidebar closed suppresses immediate hover reopening until the pointer leaves the edge trigger; toggle/click opens persistently.\n- **resizable**: boolean | SidebarResizableOptions - Enables pointer and keyboard resizing while expanded, icon-collapsed, or temporarily edge-revealed. `minWidth` and `maxWidth` (a CSS length or a number of px; defaults `12rem` and `32rem`) bound both the drag and the keyboard resize; a `maxWidth` below `minWidth` throws. By default, collapse requires dragging 75% of `minWidth` beyond the minimum; override `collapseThreshold` for a custom boundary. Use `storageKey` to restore and persist the expanded width across sessions.\n - `onWidthChange({ width, isUserInteraction })` reports every expanded-width change with one named payload: continuously while the user resizes (`isUserInteraction: true`) and once when a stored width is restored (`isUserInteraction: false`).\n\n### Content\n- **items**: SidebarGroup[] - Data-driven body navigation.\n- **views**: Record<string, SidebarView> - Named panel contents, one on screen at a time, replacing `items` and `content`. See SidebarView.\n- **headerButton** / **footerButton**: SidebarMenuButtonItem - Sticky large rows.\n- **search**: SidebarSearch - Header search input; use its native `oninput` handler.\n- **headerMenu** / **footerMenu**: SidebarMenuEntry[] - Sticky quick menus.\n- Menu entries and nested entries accept `size: 'small' | 'normal' | 'large'` for row geometry.\n- **header**, **content**, **footer**, **children**, **banner**: Snippet<[SidebarApi]> - Escape hatches. The `header` snippet renders **first** in the header region, above `headerButton`, `search` and `headerMenu`.\n\n### Styling\n- **class**: string - Classes applied to the Sidebar root.\n- **theme**: SidebarThemeProps - Semantic part overrides such as `panel`, `header`, `nav`, `footer`, menu, search, rail, and mobile drawer parts. Every part, with the element it lands on, its variants and their default classes, is listed in the entasis skill's `theme-parts/sidebar.md`; the registry key is `sidebar`.\n\n## Motion\n\n- **motion** theme slot: the height animation shared by collapsible groups, inline submenus, and\n tree branches. Its `in` / `out` `duration`, `delay` and `easing` (motion tokens) time the\n open and the close; the panel animates its height, block padding, borders and margins.\n- Ladder: `<Theme components={{ sidebar: { motion } }}>` \u2192 `setSidebarTheme({ motion })` \u2192\n `theme.motion`. Reduced motion collapses it to 0.\n- The `view` variant (`part: 'view'`) is the pager between views: `in.x` / `out.x` is how far a view travels (default `'100%'`: the two views slide side by side like pages) while it fades between `opacity` (default 0) and 1, at the `slow` duration on the `enter` easing. The back swipe moves the same layers the view change would, with the same travel and opacity. Reduced motion makes the switch instant while a swipe still follows the finger.\n\n## Restyle recipes\n\nThe five asks that come up first, as the override to copy. Each one is rendered and asserted by\n`sidebar-recipes.svelte.test.ts`, so it cannot drift from the component. One rule behind them: a\ndefault written under a variant prefix (`data-[active-variant=solid]:data-active:bg-selected`,\n`data-[side=left]:border-r`) is only replaced by an override carrying the same prefixes; an\nunprefixed class coexists with it and loses on specificity. `theme-parts/sidebar.md` shows every\ndefault verbatim, prefixes included.\n\n- **Dark panel.** `dark` scopes the dark theme's variables to the panel, so every neutral ink inside\n flips with it (every theme also emits its variables on `.<name>`, so the class scopes the dark theme to one subtree); needs a theme named `dark`, which the documented setup declares.\n `theme={{ panel: { base: 'dark bg-slate-900' }, mobilePanel: { base: 'dark bg-slate-900' } }}`\n- **Active row in your colour.** `activeVariant=\"solid\"` plus the same prefix chain as the default:\n `theme={{ menuButton: { base: 'rounded-full', activeVariant: { solid: 'data-[active-variant=solid]:data-active:bg-indigo-600 data-[active-variant=solid]:data-active:text-white' } } }}`\n- **No hover change.** The overlay is a `::before` and the default also brightens the ink on hover:\n `theme={{ menuButton: { base: 'state-layer-none hover:text-inherit' }, subButton: { base: 'state-layer-none hover:text-neutral/70' } }}`\n- **Flat panel.** The edge is a prefixed `border-r` / `border-l` on the admin variant, the shadow a\n `raised-*` on the floating ones:\n `theme={{ panel: { base: 'raised-none', variant: { admin: 'data-[side=left]:border-r-0 data-[side=right]:border-l-0' } } }}`\n- **Row height.** A plain height beats the compound `h-control-*`: `theme={{ menuButton: { base: 'h-11' } }}`\n- **Bigger icons.** A prop, not a theme: `iconSize=\"large\"`.\n\n## Styling hooks\n\n`data-open` on a disclosure toggle is a presence attribute (before 0.12.0 a submenu toggle carried `data-open=\"true\"`); match it with `[data-open]` or `data-open:`.\n\nAttributes are presence attributes (set while the state holds, omitted otherwise) unless they carry a value.\n\n**Data attributes**\n- `data-state`: On the root: `expanded` or `collapsed`.\n- `data-display-state`: On the root: what is on screen, `expanded`, `collapsed` (the icon rail) or `hidden`.\n- `data-collapsible`: On the root: `icon` while collapsed to the icon rail, `offcanvas` while hidden, empty while expanded.\n- `data-side`: On the root, the panel and its rail and handles: `left` or `right`.\n- `data-variant`: On the root: the resolved `variant`.\n- `data-size`: On the root: the resolved `size`.\n- `data-density`: On the root: the resolved `density`.\n- `data-peek`: On the layout wrapper: `true` while a hover peek shows the collapsed panel at full width.\n- `data-active`: On a row: `true` while it is the current page (`isActive`).\n- `data-active-variant`: On a row: the resolved `activeVariant`.\n- `data-open`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is open.\n- `data-closed`: On the toggle of a collapsible group, a submenu or a tree branch, and on its panel: present while it is closed, including during the exit transition.\n- `data-entering`: On a group, submenu or tree branch panel: present while its opening animation plays.\n- `data-exiting`: On a group, submenu or tree branch panel: present while its closing animation plays.\n- `data-popup-open`: On a row: present while one of its popups is open, its `menu`, its flyout on the collapsed rail, or its tooltip (every row on the collapsed rail, and every row with `tooltips=\"always\"`). Match `[aria-expanded=\"true\"]` to style an open menu or flyout only.\n- `data-sidebar`: The part (`sidebar`, `menu-button`, `menu-sub`, `group-content`, `rail`, ...).\n\n**CSS variables**\n- `--sidebar-width`: On the root: the expanded panel width.\n- `--sidebar-width-icon`: On the root: the width of the collapsed icon rail.\n- `--sidebar-width-mobile`: On the root: the width of the mobile drawer.\n- `--sidebar-suffix-width`: On a row with a `suffix` (its `menu-item` row wrapper or `menu-sub-item`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.\n- `--disclosure-panel-height`: On a group, submenu or tree branch panel: its measured height in px while it animates, `auto` once open and `0px` once closed.\n\nThe typed maps `sidebarDataAttributes` and `sidebarCssVariables` (from `entasis/sidebar`) list each name with its meaning.\n\n## Accessibility\n\n- The body navigation renders inside a named `<nav>` landmark (`data-sidebar=\"nav\"`), so assistive tech can jump straight to it and tell it apart from the activity bar's own landmark.\n- Active links set `aria-current=\"page\"`.\n- Collapsible rows and groups set `aria-expanded`.\n- Disabled buttons use `disabled`; disabled links omit `href`, use `aria-disabled` and `tabindex=-1`, and block activation.\n- Mobile drawer includes a backdrop button labelled \"Close Sidebar\".\n- Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.\n- Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.\n- A row's `suffix` is a sibling of its link or button, between it and the row's `action`: Tab reaches its controls right after the row, its content describes the row (`aria-describedby`), and it is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons, focus inside it returning to the row.\n- A submenu row's `action` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes `label` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is `inert` and `aria-hidden` whenever the Sidebar is collapsed to icons.\n- Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared `disclosurePanel` Collapsible and Accordion use): the toggle sets `aria-expanded` and, while the panel is rendered, `aria-controls` to its id; the panel carries `data-open` / `data-closed` and `data-entering` / `data-exiting`, exposes `--disclosure-panel-height` while it animates, and stays `inert` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the `motion` slot's duration, delay and easing time them.\n- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with `items` and no `href`, `view` or `menu`, is a disclosure button (`aria-expanded`, `aria-controls`) that opens a flyout holding a named `<nav>` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an `onclick` and no `href` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and `preventDefault()` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no `role=\"menu\"`; root leaves retain direct navigation and tooltips.\n- Row menus, flyouts and tooltips open toward the page: at the inline end of a `left` sidebar and the inline start of a `right` one, the side the layout row puts the page on in either reading direction (a `left` sidebar is the row's start, on the right in right-to-left). An explicit `menuSide` wins.\n- When `rail` and `resizable` are both enabled, one edge control owns click-to-toggle, drag resize, and keyboard resize without overlapping hitboxes.\n- Hidden offcanvas sidebars keep that combined edge control while temporarily revealed. Resizing does not pin the sidebar open; leaving the panel, trigger, and handle tolerance re-hides it without discarding the configured width.\n- Both peeks (edge reveal and `expandOnHover`) stay open while focus is inside the panel or while an overlay opened from inside it is open, including nested submenus. They release about 120ms after the pointer, focus, and every such overlay are gone.\n- The activity bar is its own `<nav>` landmark with a `<ul>` of items, a roving tabindex, and ArrowUp/ArrowDown/Home/End navigation that loops and skips disabled items. Tab lands on the `isActive` item. Each square takes its accessible name from `label`, with a string or number `badge` appended to it, and shows `label` as a tooltip on hover and focus.\n- A hidden offcanvas panel is `inert`, so Tab never lands in a panel parked off screen; a peek makes it interactive again.\n- Views: when focus sat in the view being replaced, it lands on the back row after going deeper and on the row that opened the view after coming back. A view change from outside the panel (a route) leaves focus where it is. The back swipe is a shortcut; the back row stays the accessible path.\n\n## Notes\n\n- Dropdown menus use Entasis `PopupMenu` and `MenuItem[]`.\n- The component uses semantic Entasis tokens. Do not add shadcn `sidebar-*` color tokens.\n- Snippet icons from `entasis/icons/*` are the preferred icon format.\n";
|
|
@@ -86,6 +86,7 @@ recursive tree groups, header/footer rows, search, actions, and snippet escape h
|
|
|
86
86
|
- **menuSide** / **menuAlign**: SidebarMenuSide / 'start' | 'center' | 'end' - Where that menu opens. \`menuSide\` takes a physical side (\`top\`, \`right\`, \`bottom\`, \`left\`) or a logical one (\`inline-start\`, \`inline-end\`, mirrored in right-to-left). Left out, a desktop menu opens toward the page (\`inline-end\` of a left sidebar, \`inline-start\` of a right one, so it follows the reading direction) aligned to the row's start, and a mobile one below the row aligned to its end. The same pair sits on action descriptors and on \`headerButton\` / \`footerButton\`.
|
|
87
87
|
- **view**: string - Key of the \`views\` entry the row opens, sliding it in; the row shows a trailing chevron. Mutually exclusive with href, menu and items.
|
|
88
88
|
- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Hover/focus trailing action.
|
|
89
|
+
- **suffix**: Snippet<[SidebarApi, SidebarMenuEntry]> - Content at the row's end, beside the label: a count, a \`Chip\`, an icon, a status dot, its own \`PopupMenu\` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (\`{#snippet suffix(api, entry)}\`) and tell themselves apart by \`entry.href\`. It renders beside the row's link or button, never inside it (\`data-sidebar="menu-suffix"\`, drawn by the \`suffix\` theme part), so it may hold controls: a click or a keypress inside it never follows the row's \`href\` nor toggles it, and Tab reaches its controls right after the row, before the \`action\`. The row's link or button is \`aria-describedby\` the suffix, so the row reads with it ("Dossiers", "20"). The label truncates before it: the row reserves its measured width (\`--sidebar-suffix-width\`), and the \`suffix\` part bounds it (\`max-w-[40%]\` of the row), so a wide suffix clips instead of breaking the row. With an \`action\`, it stays visible just before the action at every width (the row keeps the action's room, and a \`badge\` then stays put instead of stepping aside). Hidden, \`inert\` and \`aria-hidden\` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.
|
|
89
90
|
|
|
90
91
|
### SidebarMenuSubEntry
|
|
91
92
|
A row of an inline submenu (\`items\` on a menu entry). It carries the same row features as a
|
|
@@ -99,9 +100,10 @@ of them renders exactly as a plain one.
|
|
|
99
100
|
- **iconColor**: Colors - Role tint for the leading icon, applied through \`data-color\`.
|
|
100
101
|
- **iconVariant**: 'bare' | 'tile' - \`tile\` paints the same tinted rounded square as a top-level row.
|
|
101
102
|
- **badge**: string | number - Trailing count or status; the label truncates before it. It shares the row's end column with the parent rows' badges and makes way for the row's \`action\` as a top-level badge does.
|
|
102
|
-
- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, \`href\` or \`onclick\`, and \`isActive\`/\`disabled\` state: it shows no tooltip, \`badge\` or \`action\`.
|
|
103
|
+
- **tooltip**: string - Tooltip text. The label is always visible on an inline sub row, so the tooltip is extra information: shown whenever one is set, never on mobile. The collapsed row's flyout lists only each entry's label, icon, \`href\` or \`onclick\`, and \`isActive\`/\`disabled\` state: it shows no tooltip, \`badge\`, \`suffix\` or \`action\`.
|
|
103
104
|
- **class**: string - Classes applied to the row button.
|
|
104
105
|
- **action**: SidebarMenuActionDescriptor | Snippet<[SidebarApi]> - Trailing action pinned to the row's end (\`data-sidebar="menu-sub-action"\`), drawn by the \`subAction\` theme part: hidden until the row is hovered or focused from \`md\` up, kept visible while its menu is open, always visible below \`md\`. The label truncates before it.
|
|
106
|
+
- **suffix**: Snippet<[SidebarApi, SidebarMenuSubEntry]> - Content at the row's end, beside the label: a count, a \`Chip\`, an icon, a status dot, its own \`PopupMenu\` trigger. It receives the row's own entry, so rows built as data share one snippet declared in markup (\`{#snippet suffix(api, entry)}\`) and tell themselves apart by \`entry.href\`. It renders beside the row's link or button, never inside it (\`data-sidebar="menu-sub-suffix"\`, drawn by the \`suffix\` theme part), so it may hold controls: a click or a keypress inside it never follows the row's \`href\` nor toggles it, and Tab reaches its controls right after the row, before the \`action\`. The row's link or button is \`aria-describedby\` the suffix, so the row reads with it ("Dossiers", "20"). The label truncates before it: the row reserves its measured width (\`--sidebar-suffix-width\`), and the \`suffix\` part bounds it (\`max-w-[40%]\` of the row), so a wide suffix clips instead of breaking the row. With an \`action\`, it stays visible just before the action at every width (the row keeps the action's room, and a \`badge\` then stays put instead of stepping aside). Hidden, \`inert\` and \`aria-hidden\` in icon-collapsed mode; shown in a hover peek and in the mobile drawer.
|
|
105
107
|
|
|
106
108
|
\`\`\`svelte
|
|
107
109
|
<script lang="ts">
|
|
@@ -295,6 +297,7 @@ Attributes are presence attributes (set while the state holds, omitted otherwise
|
|
|
295
297
|
- \`--sidebar-width\`: On the root: the expanded panel width.
|
|
296
298
|
- \`--sidebar-width-icon\`: On the root: the width of the collapsed icon rail.
|
|
297
299
|
- \`--sidebar-width-mobile\`: On the root: the width of the mobile drawer.
|
|
300
|
+
- \`--sidebar-suffix-width\`: On a row with a \`suffix\` (its \`menu-item\` row wrapper or \`menu-sub-item\`): the suffix's measured width in px, reserved at the row's end so the label truncates before it.
|
|
298
301
|
- \`--disclosure-panel-height\`: On a group, submenu or tree branch panel: its measured height in px while it animates, \`auto\` once open and \`0px\` once closed.
|
|
299
302
|
|
|
300
303
|
The typed maps \`sidebarDataAttributes\` and \`sidebarCssVariables\` (from \`entasis/sidebar\`) list each name with its meaning.
|
|
@@ -308,6 +311,7 @@ The typed maps \`sidebarDataAttributes\` and \`sidebarCssVariables\` (from \`ent
|
|
|
308
311
|
- Mobile drawer includes a backdrop button labelled "Close Sidebar".
|
|
309
312
|
- Icon-collapsed rows keep their labels mounted and visually fade them, preserving accessible names and stable icon geometry.
|
|
310
313
|
- Search, group controls, actions, and nested rows become inert before collapse can remove or hide them; focus returns to the owning visible row.
|
|
314
|
+
- A row's \`suffix\` is a sibling of its link or button, between it and the row's \`action\`: Tab reaches its controls right after the row, its content describes the row (\`aria-describedby\`), and it is \`inert\` and \`aria-hidden\` whenever the Sidebar is collapsed to icons, focus inside it returning to the row.
|
|
311
315
|
- A submenu row's \`action\` is a sibling of its link, so Tab reaches the action button right after the row; its trigger takes \`label\` as its accessible name, and Escape closes its menu and returns focus to the trigger. It is \`inert\` and \`aria-hidden\` whenever the Sidebar is collapsed to icons.
|
|
312
316
|
- Collapsible groups, tree branches, and inline submenus are disclosure panels (the shared \`disclosurePanel\` Collapsible and Accordion use): the toggle sets \`aria-expanded\` and, while the panel is rendered, \`aria-controls\` to its id; the panel carries \`data-open\` / \`data-closed\` and \`data-entering\` / \`data-exiting\`, exposes \`--disclosure-panel-height\` while it animates, and stays \`inert\` while it closes. Open, close and sidebar-collapse changes animate the panel's height and reverse from where they are when interrupted; the \`motion\` slot's duration, delay and easing time them.
|
|
313
317
|
- Tree roots use menu-row styling and nested tree nodes use submenu-row styling. In desktop icon mode, a root folder, and a menu row with \`items\` and no \`href\`, \`view\` or \`menu\`, is a disclosure button (\`aria-expanded\`, \`aria-controls\`) that opens a flyout holding a named \`<nav>\` of links, with NavigationMenu's semantics: nested folders are named sub-lists, a row with an \`onclick\` and no \`href\` is a button, a disabled sub entry stays listed without a destination, and a press on any row closes the flyout (the row's own handler runs first, and \`preventDefault()\` keeps it open). Opening from the keyboard keeps focus on the row; Tab enters the flyout's links, Tab past the last one continues after the row and closes it, Shift+Tab from the first returns to the row, and Escape closes it and refocuses the row. There is no \`role="menu"\`; root leaves retain direct navigation and tooltips.
|
|
@@ -197,6 +197,14 @@ export type SidebarMenuSubEntry = {
|
|
|
197
197
|
* from `md` up and while its menu is open, always on touch-sized screens.
|
|
198
198
|
*/
|
|
199
199
|
action?: Snippet<[SidebarApi]> | SidebarMenuActionDescriptor;
|
|
200
|
+
/**
|
|
201
|
+
* Content rendered at the row's end, beside the label: a count, a Chip, an icon, a status dot,
|
|
202
|
+
* its own menu trigger. It sits beside the row's link or button, never inside it, so it may hold
|
|
203
|
+
* controls; the row's link is described by it. It stays visible before the `action`, and is
|
|
204
|
+
* hidden in icon-collapsed mode. It receives the row's entry, so one snippet can serve every
|
|
205
|
+
* row (`{#snippet suffix(api, entry)}`) and tell them apart by `entry.href`.
|
|
206
|
+
*/
|
|
207
|
+
suffix?: Snippet<[SidebarApi, SidebarMenuSubEntry]>;
|
|
200
208
|
};
|
|
201
209
|
type SidebarMenuEntryBase = {
|
|
202
210
|
/** Visible label. */
|
|
@@ -240,6 +248,14 @@ type SidebarMenuEntryBase = {
|
|
|
240
248
|
defaultOpen?: boolean;
|
|
241
249
|
/** Secondary action pinned to the row edge. */
|
|
242
250
|
action?: Snippet<[SidebarApi]> | SidebarMenuActionDescriptor;
|
|
251
|
+
/**
|
|
252
|
+
* Content rendered at the row's end, beside the label: a count, a Chip, an icon, a status dot,
|
|
253
|
+
* its own menu trigger. It sits beside the row's link or button, never inside it, so it may hold
|
|
254
|
+
* controls; the row's link is described by it. It stays visible before the `action`, and is
|
|
255
|
+
* hidden in icon-collapsed mode. It receives the row's entry, so one snippet can serve every
|
|
256
|
+
* row (`{#snippet suffix(api, entry)}`) and tell them apart by `entry.href`.
|
|
257
|
+
*/
|
|
258
|
+
suffix?: Snippet<[SidebarApi, SidebarMenuEntry]>;
|
|
243
259
|
};
|
|
244
260
|
type SidebarMenuEntryNavigation = SidebarMenuEntryBase & {
|
|
245
261
|
/** Link href. Mutually exclusive with menu and view. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Attachment } from 'svelte/attachments';
|
|
2
|
+
/**
|
|
3
|
+
* A row's `suffix` sits beside its link or button, never inside it, so it may hold its own
|
|
4
|
+
* controls. The row reserves the suffix's measured width at its end, through
|
|
5
|
+
* `--sidebar-suffix-width`, so the label truncates before it. Until the first measurement (on the
|
|
6
|
+
* server, before hydration) the row reserves the suffix's maximum width instead, so the two never
|
|
7
|
+
* overlap.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useSidebarSuffixWidth(): {
|
|
10
|
+
measure: Attachment<HTMLElement>;
|
|
11
|
+
/** The value for `style:--sidebar-suffix-width` on the row. */
|
|
12
|
+
readonly value: string | undefined;
|
|
13
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row's `suffix` sits beside its link or button, never inside it, so it may hold its own
|
|
3
|
+
* controls. The row reserves the suffix's measured width at its end, through
|
|
4
|
+
* `--sidebar-suffix-width`, so the label truncates before it. Until the first measurement (on the
|
|
5
|
+
* server, before hydration) the row reserves the suffix's maximum width instead, so the two never
|
|
6
|
+
* overlap.
|
|
7
|
+
*/
|
|
8
|
+
export function useSidebarSuffixWidth() {
|
|
9
|
+
let width = $state();
|
|
10
|
+
const measure = (node) => {
|
|
11
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
12
|
+
width = entry?.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
|
|
13
|
+
});
|
|
14
|
+
observer.observe(node);
|
|
15
|
+
return () => observer.disconnect();
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
measure,
|
|
19
|
+
/** The value for `style:--sidebar-suffix-width` on the row. */
|
|
20
|
+
get value() {
|
|
21
|
+
return width === undefined ? undefined : `${width}px`;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -191,6 +191,8 @@ export declare const sidebarTheme: {
|
|
|
191
191
|
none: string;
|
|
192
192
|
action: string;
|
|
193
193
|
actionBelowMd: string;
|
|
194
|
+
suffix: string;
|
|
195
|
+
suffixAction: string;
|
|
194
196
|
};
|
|
195
197
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
196
198
|
menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
|
|
@@ -247,6 +249,8 @@ export declare const sidebarTheme: {
|
|
|
247
249
|
none: string;
|
|
248
250
|
action: string;
|
|
249
251
|
actionBelowMd: string;
|
|
252
|
+
suffix: string;
|
|
253
|
+
suffixAction: string;
|
|
250
254
|
};
|
|
251
255
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
252
256
|
menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
@@ -273,6 +277,22 @@ export declare const sidebarTheme: {
|
|
|
273
277
|
comfortable: string;
|
|
274
278
|
};
|
|
275
279
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
280
|
+
suffix: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
281
|
+
size: {
|
|
282
|
+
small: string;
|
|
283
|
+
normal: string;
|
|
284
|
+
large: string;
|
|
285
|
+
};
|
|
286
|
+
density: {
|
|
287
|
+
compact: string;
|
|
288
|
+
normal: string;
|
|
289
|
+
comfortable: string;
|
|
290
|
+
};
|
|
291
|
+
action: {
|
|
292
|
+
none: string;
|
|
293
|
+
action: string;
|
|
294
|
+
};
|
|
295
|
+
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
276
296
|
actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
277
297
|
size: {
|
|
278
298
|
small: string;
|
|
@@ -732,6 +752,8 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
|
|
|
732
752
|
none: string;
|
|
733
753
|
action: string;
|
|
734
754
|
actionBelowMd: string;
|
|
755
|
+
suffix: string;
|
|
756
|
+
suffixAction: string;
|
|
735
757
|
};
|
|
736
758
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
737
759
|
menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
|
|
@@ -788,6 +810,8 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
|
|
|
788
810
|
none: string;
|
|
789
811
|
action: string;
|
|
790
812
|
actionBelowMd: string;
|
|
813
|
+
suffix: string;
|
|
814
|
+
suffixAction: string;
|
|
791
815
|
};
|
|
792
816
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
793
817
|
menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
@@ -814,6 +838,22 @@ export declare const setSidebarTheme: (theme: InferComponentTheme<{
|
|
|
814
838
|
comfortable: string;
|
|
815
839
|
};
|
|
816
840
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
841
|
+
suffix: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
842
|
+
size: {
|
|
843
|
+
small: string;
|
|
844
|
+
normal: string;
|
|
845
|
+
large: string;
|
|
846
|
+
};
|
|
847
|
+
density: {
|
|
848
|
+
compact: string;
|
|
849
|
+
normal: string;
|
|
850
|
+
comfortable: string;
|
|
851
|
+
};
|
|
852
|
+
action: {
|
|
853
|
+
none: string;
|
|
854
|
+
action: string;
|
|
855
|
+
};
|
|
856
|
+
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
817
857
|
actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
818
858
|
size: {
|
|
819
859
|
small: string;
|
|
@@ -1271,6 +1311,8 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
|
|
|
1271
1311
|
none: string;
|
|
1272
1312
|
action: string;
|
|
1273
1313
|
actionBelowMd: string;
|
|
1314
|
+
suffix: string;
|
|
1315
|
+
suffixAction: string;
|
|
1274
1316
|
};
|
|
1275
1317
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
1276
1318
|
menuLabel: (props?: import("../../utils/cva/types.ts").CVAClassProp | undefined) => string;
|
|
@@ -1327,6 +1369,8 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
|
|
|
1327
1369
|
none: string;
|
|
1328
1370
|
action: string;
|
|
1329
1371
|
actionBelowMd: string;
|
|
1372
|
+
suffix: string;
|
|
1373
|
+
suffixAction: string;
|
|
1330
1374
|
};
|
|
1331
1375
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
1332
1376
|
menuAction: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
@@ -1353,6 +1397,22 @@ export declare const useSidebarTheme: import("../../utils/cva/theme.ts").UseComp
|
|
|
1353
1397
|
comfortable: string;
|
|
1354
1398
|
};
|
|
1355
1399
|
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
1400
|
+
suffix: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
1401
|
+
size: {
|
|
1402
|
+
small: string;
|
|
1403
|
+
normal: string;
|
|
1404
|
+
large: string;
|
|
1405
|
+
};
|
|
1406
|
+
density: {
|
|
1407
|
+
compact: string;
|
|
1408
|
+
normal: string;
|
|
1409
|
+
comfortable: string;
|
|
1410
|
+
};
|
|
1411
|
+
action: {
|
|
1412
|
+
none: string;
|
|
1413
|
+
action: string;
|
|
1414
|
+
};
|
|
1415
|
+
}> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
|
|
1356
1416
|
actionSlot: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
|
|
1357
1417
|
size: {
|
|
1358
1418
|
small: string;
|
|
@@ -250,12 +250,35 @@ const defaultMenu = cva({
|
|
|
250
250
|
// always. `actionBelowMd` is for a row whose last trailing part is its badge: from `md` up the
|
|
251
251
|
// badge holds the end column and the action takes its place while it shows, so the room is only
|
|
252
252
|
// needed below `md`, where the action is always visible and the badge sits before it.
|
|
253
|
-
|
|
253
|
+
// `suffix` and `suffixAction` hold a row's `suffix` (see `suffix`), which sits beside the row
|
|
254
|
+
// rather than inside it: the row reserves the suffix's measured width (`--sidebar-suffix-width`,
|
|
255
|
+
// its maximum width until measured), plus the row's own end padding or the action's box.
|
|
256
|
+
const rowEndReserveKeys = { none: '', action: '', actionBelowMd: '', suffix: '', suffixAction: '' };
|
|
254
257
|
const rowEndReserve = [
|
|
255
258
|
{ endReserve: 'action', size: ['small', 'normal'], class: 'pe-layout-lg' },
|
|
256
259
|
{ endReserve: 'action', size: 'large', class: 'pe-[calc(var(--spacing)*10)]' },
|
|
257
260
|
{ endReserve: 'actionBelowMd', size: ['small', 'normal'], class: 'max-md:pe-layout-lg' },
|
|
258
|
-
{ endReserve: 'actionBelowMd', size: 'large', class: 'max-md:pe-[calc(var(--spacing)*10)]' }
|
|
261
|
+
{ endReserve: 'actionBelowMd', size: 'large', class: 'max-md:pe-[calc(var(--spacing)*10)]' },
|
|
262
|
+
{
|
|
263
|
+
endReserve: 'suffix',
|
|
264
|
+
density: 'compact',
|
|
265
|
+
class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--space-sm)*2)]'
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
endReserve: 'suffix',
|
|
269
|
+
density: ['normal', 'comfortable'],
|
|
270
|
+
class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--space-md)+var(--space-sm))]'
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
endReserve: 'suffixAction',
|
|
274
|
+
size: ['small', 'normal'],
|
|
275
|
+
class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--layout-space-lg)+var(--space-sm))]'
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
endReserve: 'suffixAction',
|
|
279
|
+
size: 'large',
|
|
280
|
+
class: 'pe-[calc(var(--sidebar-suffix-width,40%)+var(--spacing)*10+var(--space-sm))]'
|
|
281
|
+
}
|
|
259
282
|
];
|
|
260
283
|
const defaultMenuButton = cva({
|
|
261
284
|
base: 'state-layer peer/menu-button group/menu-button flex w-full items-center overflow-hidden rounded-sm text-start outline-none transition-[background,color,width,height,padding,margin,border-radius] duration-normal ease-linear hover:text-neutral focus-visible:ring-2 focus-visible:ring-focus/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:font-medium group-data-[collapsible=icon]:mx-[calc((var(--sidebar-width-icon)-var(--sidebar-icon-button-width))/2-var(--sidebar-group-padding))] group-data-[collapsible=icon]:w-[var(--sidebar-icon-button-width)] group-data-[collapsible=icon]:rounded-none group-data-[variant=admin]:group-data-[collapsible=icon]:rounded-sm group-data-[variant=framed]:group-data-[collapsible=icon]:rounded-sm group-data-[variant=inset]:group-data-[collapsible=icon]:rounded-sm group-data-[collapsible=icon]:![padding-inline:calc((var(--sidebar-icon-button-width)-var(--sidebar-icon-size))/2)] group-data-[collapsible=icon]:ring-inset [&_svg]:shrink-0 [&_svg]:size-[var(--sidebar-icon-size)]',
|
|
@@ -285,6 +308,11 @@ const defaultMenuButton = cva({
|
|
|
285
308
|
},
|
|
286
309
|
compoundVariants: [
|
|
287
310
|
...rowEndReserve,
|
|
311
|
+
// The suffix covers the row's end, so hovering it keeps the row's hover tint.
|
|
312
|
+
{
|
|
313
|
+
endReserve: ['suffix', 'suffixAction'],
|
|
314
|
+
class: 'group-has-[[data-sidebar=menu-suffix]:hover]/menu-row:before:opacity-[var(--state-hover-opacity)]'
|
|
315
|
+
},
|
|
288
316
|
{ size: 'small', itemSize: 'small', class: 'h-control-sm' },
|
|
289
317
|
{ size: 'small', itemSize: 'normal', class: 'h-control-md' },
|
|
290
318
|
{ size: 'small', itemSize: 'large', class: 'h-row-lg' },
|
|
@@ -386,7 +414,13 @@ const defaultSubButton = cva({
|
|
|
386
414
|
// plain row keeps its exact classes.
|
|
387
415
|
endReserve: rowEndReserveKeys
|
|
388
416
|
},
|
|
389
|
-
compoundVariants: [
|
|
417
|
+
compoundVariants: [
|
|
418
|
+
...rowEndReserve,
|
|
419
|
+
{
|
|
420
|
+
endReserve: ['suffix', 'suffixAction'],
|
|
421
|
+
class: 'group-has-[[data-sidebar=menu-sub-suffix]:hover]/menu-sub-item:before:opacity-[var(--state-hover-opacity)]'
|
|
422
|
+
}
|
|
423
|
+
],
|
|
390
424
|
defaultVariants: {
|
|
391
425
|
activeVariant: 'soft',
|
|
392
426
|
size: 'normal',
|
|
@@ -434,6 +468,43 @@ const defaultSubAction = cva({
|
|
|
434
468
|
density: 'normal'
|
|
435
469
|
}
|
|
436
470
|
});
|
|
471
|
+
// A row's `suffix`: consumer content (a count, a Chip, a status dot, its own menu trigger) beside
|
|
472
|
+
// the row's link or button rather than inside it, so it may hold controls. It sits at the row's
|
|
473
|
+
// end, before the pinned action's box when the row has one, and stays visible while the action
|
|
474
|
+
// shows. Its width is bounded, so a wide suffix clips instead of pushing the label out; the row
|
|
475
|
+
// reserves its measured width (`endReserve`), so the label truncates before it. Hidden with the
|
|
476
|
+
// labels in icon-collapsed mode.
|
|
477
|
+
const defaultSuffix = cva({
|
|
478
|
+
base: 'text-neutral/70 absolute top-1/2 flex max-w-[40%] min-w-0 -translate-y-1/2 items-center justify-end gap-xs overflow-x-clip [overflow-clip-margin:var(--space-xs)] [&>*]:min-w-0 group-data-[collapsible=icon]:hidden',
|
|
479
|
+
variants: {
|
|
480
|
+
size: {
|
|
481
|
+
small: 'text-xs',
|
|
482
|
+
normal: 'text-sm',
|
|
483
|
+
large: 'text-sm'
|
|
484
|
+
},
|
|
485
|
+
density: {
|
|
486
|
+
compact: '',
|
|
487
|
+
normal: '',
|
|
488
|
+
comfortable: ''
|
|
489
|
+
},
|
|
490
|
+
// Whether the row also pins an `action`: the suffix then ends before the action's box.
|
|
491
|
+
action: {
|
|
492
|
+
none: '',
|
|
493
|
+
action: ''
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
compoundVariants: [
|
|
497
|
+
{ action: 'none', density: 'compact', class: 'end-[var(--space-sm)]' },
|
|
498
|
+
{ action: 'none', density: ['normal', 'comfortable'], class: 'end-[var(--space-md)]' },
|
|
499
|
+
{ action: 'action', size: ['small', 'normal'], class: 'end-[var(--layout-space-lg)]' },
|
|
500
|
+
{ action: 'action', size: 'large', class: 'end-[calc(var(--spacing)*10)]' }
|
|
501
|
+
],
|
|
502
|
+
defaultVariants: {
|
|
503
|
+
size: 'normal',
|
|
504
|
+
density: 'normal',
|
|
505
|
+
action: 'none'
|
|
506
|
+
}
|
|
507
|
+
});
|
|
437
508
|
// One icon-only ghost button box. `size` follows the descriptor, defaulting to the Sidebar size,
|
|
438
509
|
// so a group's `+` lands at row scale instead of at the group label's scale.
|
|
439
510
|
const defaultActionSlot = cva({
|
|
@@ -1097,6 +1168,7 @@ export const sidebarTheme = {
|
|
|
1097
1168
|
subButton: defaultSubButton,
|
|
1098
1169
|
menuAction: defaultMenuAction,
|
|
1099
1170
|
subAction: defaultSubAction,
|
|
1171
|
+
suffix: defaultSuffix,
|
|
1100
1172
|
actionSlot: defaultActionSlot,
|
|
1101
1173
|
actionTrigger: defaultActionTrigger,
|
|
1102
1174
|
menuIcon: defaultMenuIcon,
|