entasis 0.13.3 → 0.13.5

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.
@@ -108,8 +108,13 @@
108
108
  {/if}
109
109
 
110
110
  <!-- ScrollArea owns both axes: horizontal (long lines) natively, vertical when maxHeight caps
111
- the height. Its viewport is the focusable native scroll region (keyboard + focus ring). -->
112
- <ScrollArea type="hover" class="flex max-h-[var(--code-max-height)] flex-col rounded-b-lg">
111
+ the height or a height-bound flex column (a Dialog body) shrinks the block: `min-h-0` lets
112
+ it shrink below its content, so it scrolls under the kept header instead of the root
113
+ clipping its last lines. Its viewport is the focusable native scroll region. -->
114
+ <ScrollArea
115
+ type="hover"
116
+ class="flex max-h-[var(--code-max-height)] min-h-0 flex-col rounded-b-lg"
117
+ >
113
118
  {@render codeBody()}
114
119
  </ScrollArea>
115
120
 
@@ -52,11 +52,22 @@
52
52
  --code-token-property: color-mix(in oklab, var(--color-info) 70%, var(--color-neutral));
53
53
  }
54
54
 
55
- /* The pre stays transparent so the container background shows through. */
55
+ /* The pre stays transparent so the container background shows through. Code owns its layout
56
+ too: an app stylesheet may style every `pre.th-code` (edytor's `code.css` sets `white-space:
57
+ pre`, `tab-size` and `overflow-x`, unlayered, so it beats utilities), and these selectors
58
+ are scoped under the Code root so they win. The ScrollArea scrolls, never the pre. */
56
59
  :global([data-slot='code'] pre.th-code) {
57
60
  margin: 0;
61
+ overflow: visible;
58
62
  background-color: transparent;
59
63
  color: var(--code-token-plain);
64
+ white-space: pre;
65
+ word-break: normal;
66
+ tab-size: var(--code-tab-size, 2);
67
+ }
68
+
69
+ :global([data-slot='code'] pre.th-code[data-wrap]) {
70
+ white-space: pre-wrap;
60
71
  }
61
72
 
62
73
  /* Token classes emitted by TanStack Highlight (`th-token th-<role>`), mapped to our roles. */
@@ -16,11 +16,11 @@ function getHighlighter() {
16
16
  // Applied to the highlighter's `<pre>`. Scrolling is owned by the wrapping ScrollArea (see
17
17
  // Code.svelte), so the pre itself does not scroll: when not wrapping it takes its natural
18
18
  // (max-content) width so the ScrollArea sees the horizontal overflow; when wrapping it fills
19
- // and soft-wraps. `has-[.th-line]:px-0` lets the gutter sit flush against the edge. A narrow
19
+ // and soft-wraps (its white-space is set in CodeTheme.svelte). `has-[.th-line]:px-0` lets the gutter sit flush against the edge. A narrow
20
20
  // block (under 28rem) tightens its padding, as its text steps down (see `code.theme.ts`).
21
21
  const preClass = (wrap) => [
22
22
  'px-4 py-3.5 @max-md:px-3 @max-md:py-2.5 outline-none has-[.th-line]:px-0',
23
- wrap ? 'w-full whitespace-pre-wrap break-words' : 'w-max'
23
+ wrap ? 'w-full break-words' : 'w-max'
24
24
  ].join(' ');
25
25
  /**
26
26
  * Highlights `code` to a `<pre class="th-code"><code>…</code></pre>` HTML string
@@ -1 +1 @@
1
- export declare const codeDescription = "\n# Code Component\n\nDisplays a syntax-highlighted code block with an optional header (language label\n+ copy button), line numbers, and header/footer slots. Highlighting is powered by\nTanStack Highlight with every language bundled (synchronous, SSR-safe) and a CSS-variable token palette, so colors\nadapt to light and dark automatically from the design tokens.\n\n## Basic Usage\n\n```svelte\n<Code language=\"typescript\" code={`const hello: string = 'world';`} />\n```\n\n## Props\n\n### Core Props\n- **code**: string (required) - Source code to highlight. Also what the copy button writes to the clipboard.\n- **language**: string (default: 'text') - Language id (`ts`, `svelte`, `css`, `shell`, `json`, `python`, \u2026). Aliases like `typescript`, `js`, `bash` resolve automatically; every language TanStack Highlight ships is bundled, and unknown ids fall back to plain text.\n- **title**: string - Header label. Defaults to the language's display name (e.g. \"TypeScript\").\n\n### Display Props\n- **showLineNumbers**: boolean (default: false) - Renders a line-number gutter.\n- **showHeader**: boolean (default: true) - Renders the default header row (title + copy button). Set to false to show only the code block. Ignored when a custom `header` snippet is provided.\n- **copyable**: boolean (default: true) - Shows the copy-to-clipboard button. In the header when one is shown, otherwise a floating button in the top-right corner that appears on hover.\n- **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally.\n- **maxHeight**: number | string (default: none) - Caps the block height and makes it vertically scrollable (via ScrollArea). A number is pixels; a string is used as-is (e.g. `'20rem'`).\n- **tabSize**: number (default: 2) - Tab width in spaces.\n\n### Styling Props\n- **class**: string - Additional CSS classes on the root element.\n- **theme**: CodeThemeProps - Theme overrides for the `code`, `header`, `title`, `container`, and `footer` parts.\n\n### Slots\n- **header**: Snippet<[CodeHeaderPayload]> - Replaces the default header row. Receives `{ language, label, copied, copy }`.\n- **footer**: Snippet - Optional footer row (e.g. a token count or a run button).\n\n## Examples\n\n### With a title and line numbers\n```svelte\n<Code\n language=\"svelte\"\n title=\"Counter.svelte\"\n showLineNumbers\n code={svelteSource}\n/>\n```\n\n### Custom header\n```svelte\n<Code language=\"json\" code={json}>\n {#snippet header({ label, copied, copy })}\n <div class=\"flex items-center justify-between px-3 py-1.5\">\n <span>{label}</span>\n <button onclick={copy}>{copied ? 'Copied!' : 'Copy'}</button>\n </div>\n {/snippet}\n</Code>\n```\n\n### With a footer\n```svelte\n<Code language=\"bash\" code={installCmd}>\n {#snippet footer()}\n <span>Run in your project root</span>\n {/snippet}\n</Code>\n```\n\n## Theming\n\nThe block chrome (border, header, footer, container) is styled with cva parts and\nuses `bg-neutral-muted` / `text-neutral/70` for the header and footer.\nThe syntax colors are NOT part of cva \u2014 they come from the highlighter's `th-*` token classes plus the\ncolocated `CodeTheme.svelte`, which maps each `--code-token-*` variable onto our\n`--color-*` design tokens (keyword \u2192 primary, string \u2192 success, number \u2192 warning,\nfunction \u2192 info, tag \u2192 danger, \u2026) and re-tunes a few roles under the dark selector.\n\n### Theme Structure\n- **root**: Root container (border, radius, background).\n- **header**: Header row (language label + copy button).\n- **title**: The language/title label text.\n- **container**: The scrollable code area wrapping the highlighted `<pre>`.\n- **footer**: Optional footer row.\n\n### Theme Type Definition\n\n```typescript\nimport type { CodeThemeProps } from 'entasis/code';\n\nconst customTheme: CodeThemeProps = {\n root: { base: 'rounded-xl raised-4' },\n header: { base: 'bg-primary text-primary-contrast px-4 py-2' }\n};\n```\n\n## Accessibility\n- The copy button carries an `aria-label` that reflects its state (\"Copy\" / \"Copied\").\n- Clipboard access is guarded (`navigator.clipboard`) and only runs client-side.\n\n## Notes\n- The highlighter is a singleton constructed at module load \u2014 SSR-safe, no top-level await.\n- Copied state resets automatically after 2 seconds and whenever `code` or `language` changes.\n";
1
+ export declare const codeDescription = "\n# Code Component\n\nDisplays a syntax-highlighted code block with an optional header (language label\n+ copy button), line numbers, and header/footer slots. Highlighting is powered by\nTanStack Highlight with every language bundled (synchronous, SSR-safe) and a CSS-variable token palette, so colors\nadapt to light and dark automatically from the design tokens.\n\n## Basic Usage\n\n```svelte\n<Code language=\"typescript\" code={`const hello: string = 'world';`} />\n```\n\n## Props\n\n### Core Props\n- **code**: string (required) - Source code to highlight. Also what the copy button writes to the clipboard.\n- **language**: string (default: 'text') - Language id (`ts`, `svelte`, `css`, `shell`, `json`, `python`, \u2026). Aliases like `typescript`, `js`, `bash` resolve automatically; every language TanStack Highlight ships is bundled, and unknown ids fall back to plain text.\n- **title**: string - Header label. Defaults to the language's display name (e.g. \"TypeScript\").\n\n### Display Props\n- **showLineNumbers**: boolean (default: false) - Renders a line-number gutter.\n- **showHeader**: boolean (default: true) - Renders the default header row (title + copy button). Set to false to show only the code block. Ignored when a custom `header` snippet is provided.\n- **copyable**: boolean (default: true) - Shows the copy-to-clipboard button. In the header when one is shown, otherwise a floating button in the top-right corner that appears on hover.\n- **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally. Code sets its `<pre>`'s `white-space` and `tab-size` itself, scoped under its root, so an app stylesheet that styles every `pre.th-code` (edytor's `code.css` does) changes neither.\n- **maxHeight**: number | string (default: none) - Caps the block height and makes it vertically scrollable (via ScrollArea). A number is pixels; a string is used as-is (e.g. `'20rem'`).\n- **tabSize**: number (default: 2) - Tab width in spaces.\n\nIn a height-bound flex column (a Dialog or Drawer body, `flex min-h-0 flex-1 flex-col`), a Code taller than the room left shrinks to it and scrolls inside: the header and footer stay put and every line stays reachable. To keep its full height and let the column scroll instead, pass `class=\"shrink-0\"`.\n\n### Styling Props\n- **class**: string - Additional CSS classes on the root element.\n- **theme**: CodeThemeProps - Theme overrides for the `code`, `header`, `title`, `container`, and `footer` parts.\n\n### Slots\n- **header**: Snippet<[CodeHeaderPayload]> - Replaces the default header row. Receives `{ language, label, copied, copy }`.\n- **footer**: Snippet - Optional footer row (e.g. a token count or a run button).\n\n## Examples\n\n### With a title and line numbers\n```svelte\n<Code\n language=\"svelte\"\n title=\"Counter.svelte\"\n showLineNumbers\n code={svelteSource}\n/>\n```\n\n### Custom header\n```svelte\n<Code language=\"json\" code={json}>\n {#snippet header({ label, copied, copy })}\n <div class=\"flex items-center justify-between px-3 py-1.5\">\n <span>{label}</span>\n <button onclick={copy}>{copied ? 'Copied!' : 'Copy'}</button>\n </div>\n {/snippet}\n</Code>\n```\n\n### With a footer\n```svelte\n<Code language=\"bash\" code={installCmd}>\n {#snippet footer()}\n <span>Run in your project root</span>\n {/snippet}\n</Code>\n```\n\n## Theming\n\nThe block chrome (border, header, footer, container) is styled with cva parts and\nuses `bg-neutral-muted` / `text-neutral/70` for the header and footer.\nThe syntax colors are NOT part of cva \u2014 they come from the highlighter's `th-*` token classes plus the\ncolocated `CodeTheme.svelte`, which maps each `--code-token-*` variable onto our\n`--color-*` design tokens (keyword \u2192 primary, string \u2192 success, number \u2192 warning,\nfunction \u2192 info, tag \u2192 danger, \u2026) and re-tunes a few roles under the dark selector.\n\n### Theme Structure\n- **root**: Root container (border, radius, background).\n- **header**: Header row (language label + copy button).\n- **title**: The language/title label text.\n- **container**: The scrollable code area wrapping the highlighted `<pre>`.\n- **footer**: Optional footer row.\n\n### Theme Type Definition\n\n```typescript\nimport type { CodeThemeProps } from 'entasis/code';\n\nconst customTheme: CodeThemeProps = {\n root: { base: 'rounded-xl raised-4' },\n header: { base: 'bg-primary text-primary-contrast px-4 py-2' }\n};\n```\n\n## Accessibility\n- The copy button carries an `aria-label` that reflects its state (\"Copy\" / \"Copied\").\n- Clipboard access is guarded (`navigator.clipboard`) and only runs client-side.\n\n## Notes\n- The highlighter is a singleton constructed at module load \u2014 SSR-safe, no top-level await.\n- Copied state resets automatically after 2 seconds and whenever `code` or `language` changes.\n";
@@ -23,10 +23,12 @@ adapt to light and dark automatically from the design tokens.
23
23
  - **showLineNumbers**: boolean (default: false) - Renders a line-number gutter.
24
24
  - **showHeader**: boolean (default: true) - Renders the default header row (title + copy button). Set to false to show only the code block. Ignored when a custom \`header\` snippet is provided.
25
25
  - **copyable**: boolean (default: true) - Shows the copy-to-clipboard button. In the header when one is shown, otherwise a floating button in the top-right corner that appears on hover.
26
- - **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally.
26
+ - **wrap**: boolean (default: false) - Soft-wraps long lines instead of scrolling horizontally. Code sets its \`<pre>\`'s \`white-space\` and \`tab-size\` itself, scoped under its root, so an app stylesheet that styles every \`pre.th-code\` (edytor's \`code.css\` does) changes neither.
27
27
  - **maxHeight**: number | string (default: none) - Caps the block height and makes it vertically scrollable (via ScrollArea). A number is pixels; a string is used as-is (e.g. \`'20rem'\`).
28
28
  - **tabSize**: number (default: 2) - Tab width in spaces.
29
29
 
30
+ In a height-bound flex column (a Dialog or Drawer body, \`flex min-h-0 flex-1 flex-col\`), a Code taller than the room left shrinks to it and scrolls inside: the header and footer stay put and every line stays reachable. To keep its full height and let the column scroll instead, pass \`class="shrink-0"\`.
31
+
30
32
  ### Styling Props
31
33
  - **class**: string - Additional CSS classes on the root element.
32
34
  - **theme**: CodeThemeProps - Theme overrides for the \`code\`, \`header\`, \`title\`, \`container\`, and \`footer\` parts.
@@ -9,20 +9,21 @@ const defaultCode = cva({
9
9
  const defaultCodeFloatingCopy = cva({
10
10
  base: 'absolute top-2 right-2 z-10 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100'
11
11
  });
12
+ // Header and footer never shrink: in a height-bound column the code scrolls between them.
12
13
  const defaultCodeHeader = cva({
13
- base: 'flex items-center justify-between gap-md border-b border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
14
+ base: 'flex shrink-0 items-center justify-between gap-md border-b border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
14
15
  });
15
16
  const defaultCodeTitle = cva({
16
17
  base: 'font-mono'
17
18
  });
18
19
  const defaultCodeContainer = cva({
19
20
  // The scroll + padding live on the inner highlighter <pre> (classes added by codeToHtml)
20
- // so the focusable node is the scroller; the container is just the surface. `tab-size` is
21
- // driven by a CSS var set on the root (defaults to 2).
22
- base: 'min-w-0 bg-surface text-sm @max-md:text-xs [&_pre]:!bg-transparent [&_pre]:[tab-size:var(--code-tab-size,2)] [&_code]:font-mono'
21
+ // so the focusable node is the scroller; the container is just the surface. The pre's
22
+ // white-space and `tab-size` (from `--code-tab-size` on the root) live in CodeTheme.svelte.
23
+ base: 'min-w-0 bg-surface text-sm @max-md:text-xs [&_pre]:!bg-transparent [&_code]:font-mono'
23
24
  });
24
25
  const defaultCodeFooter = cva({
25
- base: 'flex items-center justify-between gap-md border-t border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
26
+ base: 'flex shrink-0 items-center justify-between gap-md border-t border-neutral-muted bg-surface-raised px-lg py-sm text-xs text-neutral/70'
26
27
  });
27
28
  export const codeTheme = {
28
29
  root: defaultCode,
@@ -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
- <Form
153
- inputs={askState.options.inputs}
154
- onSubmit={(value) => completeAskSubmission(askState, value)}
155
- bind:value={askState.value}
156
- bind:form={askState.form}
157
- class={askState.options.class}
158
- size={askState.options.size}
159
- density={askState.options.density}
160
- variant={askState.options.variant}
161
- layout={askState.options.layout}
162
- validationMode={askState.options.validationMode}
163
- validationDebounce={askState.options.validationDebounce}
164
- theme={askState.options.theme}
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
- /** Dialog presentation options. Dismissal behavior remains owned by Ask. */
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
 
@@ -59,6 +59,8 @@ export function getSidebarPanelPeekClass(variant) {
59
59
  * ones for floating and split (the panel's own gutter spaces the pair).
60
60
  */
61
61
  export function getSidebarActivityBarContainerClass(side, frame, variant) {
62
- return cx('inset-y-0 z-30 hidden w-[var(--sidebar-activity-offset,3rem)] md:flex', frame === 'viewport' ? 'fixed h-window' : 'absolute h-full', side === 'left' ? 'left-0' : 'right-0', variant === 'inset' && 'py-md', (variant === 'floating' || variant === 'split') &&
62
+ // Above the panel in every state, a peek (`z-40`) included: an edge-revealed panel slides in
63
+ // from past the screen edge and must come out from under the rail, never across it.
64
+ return cx('inset-y-0 z-[45] hidden w-[var(--sidebar-activity-offset,3rem)] md:flex', frame === 'viewport' ? 'fixed h-window' : 'absolute h-full', side === 'left' ? 'left-0' : 'right-0', variant === 'inset' && 'py-md', (variant === 'floating' || variant === 'split') &&
63
65
  (side === 'left' ? 'py-md pl-md' : 'py-md pr-md'));
64
66
  }
@@ -15,7 +15,7 @@
15
15
  size = 'normal',
16
16
  density = 'normal',
17
17
  color = 'neutral',
18
- connectorColor = 'neutral',
18
+ connectorColor,
19
19
  showConnectors = true,
20
20
  scrollFade = true,
21
21
  i18n,
@@ -1,4 +1,5 @@
1
1
  <script lang="ts" generics="Item extends TimelineItem = TimelineItem">
2
+ import { checkIcon } from '../Icons/check.js';
2
3
  import Slot from '../Slot/Slot.svelte';
3
4
  import Spinner from '../Spinner/Spinner.svelte';
4
5
  import type { Density, Sizes } from '../../types/theme.js';
@@ -64,6 +65,8 @@
64
65
  data-density={density}
65
66
  data-first={entry.isFirst ? 'true' : undefined}
66
67
  data-last={entry.isLast ? 'true' : undefined}
68
+ data-progress={entry.item.progress}
69
+ aria-current={entry.item.progress === 'current' ? 'step' : undefined}
67
70
  class={classes.item({
68
71
  orientation: entry.orientation,
69
72
  placement,
@@ -107,9 +110,11 @@
107
110
  <span
108
111
  data-slot="timeline-connector"
109
112
  data-color={entry.connectorColor}
113
+ data-progress={entry.item.progress}
110
114
  class={classes.connector({
111
115
  orientation: entry.orientation,
112
- color: entry.connectorColor
116
+ color: entry.connectorColor,
117
+ progress: entry.item.progress ?? 'none'
113
118
  })}
114
119
  ></span>
115
120
  {/if}
@@ -182,7 +187,12 @@
182
187
  <span
183
188
  data-slot="timeline-marker"
184
189
  data-color={entry.color}
185
- class={classes.marker({ size, color: entry.color })}
190
+ data-progress={entry.item.loading ? undefined : entry.item.progress}
191
+ class={classes.marker({
192
+ size,
193
+ color: entry.color,
194
+ progress: entry.item.loading ? 'none' : (entry.item.progress ?? 'none')
195
+ })}
186
196
  >
187
197
  {#if entry.item.loading}
188
198
  <span data-slot="timeline-loading" class={classes.loading({ size })}>
@@ -190,6 +200,10 @@
190
200
  </span>
191
201
  {:else if entry.item.icon}
192
202
  <Slot render={entry.item.icon} />
203
+ {:else if entry.item.progress === 'done'}
204
+ {@render checkIcon()}
205
+ {:else if entry.item.progress === 'upcoming'}
206
+ <span data-slot="timeline-step-number">{entry.index + 1}</span>
193
207
  {:else}
194
208
  <span data-slot="timeline-dot"></span>
195
209
  {/if}
@@ -1,3 +1,3 @@
1
1
  export { default as Timeline } from './Timeline.svelte';
2
- export type { TimelineItem, TimelineItemPayload, TimelineOrientation, TimelinePlacement, TimelineProps, TimelineSide, TimelineVariant } from './timeline.props.js';
2
+ export type { TimelineItem, TimelineItemPayload, TimelineOrientation, TimelinePlacement, TimelineProgress, TimelineProps, TimelineSide, TimelineVariant } from './timeline.props.js';
3
3
  export { timelineTheme, setTimelineTheme, useTimelineTheme, type TimelineTheme, type TimelineThemeProps } from './timeline.theme.js';
@@ -11,4 +11,4 @@ export type ResolvedTimelineItem<Item extends TimelineItem> = Readonly<{
11
11
  isFirst: boolean;
12
12
  isLast: boolean;
13
13
  }>;
14
- export declare function resolveTimelineItems<Item extends TimelineItem>(items: readonly Item[], orientation: TimelineOrientation, placement: TimelinePlacement, color: Colors, connectorColor: Colors): readonly ResolvedTimelineItem<Item>[];
14
+ export declare function resolveTimelineItems<Item extends TimelineItem>(items: readonly Item[], orientation: TimelineOrientation, placement: TimelinePlacement, color: Colors, connectorColor: Colors | undefined): readonly ResolvedTimelineItem<Item>[];
@@ -17,6 +17,7 @@ export function resolveTimelineItems(items, orientation, placement, color, conne
17
17
  const side = placement === 'alternate'
18
18
  ? (timelineItem.side ?? (index % 2 === 0 ? 'end' : 'start'))
19
19
  : placement;
20
+ const itemColor = timelineItem.color ?? color;
20
21
  let key = `index:${index}`;
21
22
  if (explicitId !== undefined) {
22
23
  key = `id:${typeof explicitId}:${explicitId}`;
@@ -27,8 +28,12 @@ export function resolveTimelineItems(items, orientation, placement, color, conne
27
28
  index,
28
29
  side,
29
30
  orientation,
30
- color: timelineItem.color ?? color,
31
- connectorColor: timelineItem.connectorColor ?? connectorColor,
31
+ color: itemColor,
32
+ // A progress band reads as one stroke: its connectors follow the step's own color
33
+ // unless a connector color is set on the item or the root.
34
+ connectorColor: timelineItem.connectorColor ??
35
+ connectorColor ??
36
+ (timelineItem.progress ? itemColor : 'neutral'),
32
37
  isFirst: index === 0,
33
38
  isLast: index === items.length - 1
34
39
  };
@@ -1 +1 @@
1
- export declare const timelineDescription = "\n# Timeline\n\nRender an ordered sequence of dated or descriptive events on a vertical or horizontal axis. Timeline owns the semantic list, item surfaces, axis anchors, markers, connectors, responsive alternate collapse, and horizontal overflow behavior. Input order is always render order.\n\n## Basic usage\n\n```svelte\n<script lang=\"ts\">\n import { Timeline, type TimelineItem } from './index.ts';\n\n const items: TimelineItem[] = [\n {\n id: 'placed',\n date: 'Mar 15, 2024',\n datetime: '2024-03-15',\n title: 'Order placed',\n description: 'Your order has been received.'\n },\n {\n id: 'payment',\n date: 'Mar 16, 2024',\n datetime: '2024-03-16',\n title: 'Payment confirmed',\n color: 'success'\n }\n ];\n</script>\n\n<Timeline {items} />\n```\n\nThe default configuration is a vertical timeline with all content on the logical end side of the axis.\n\n## Props\n\n### Data and layout\n\n- **items**: `readonly Item[]` (required) \u2014 entries in display order. `Item` must extend `TimelineItem`.\n- **orientation**: `'vertical' | 'horizontal'` (default: `'vertical'`) \u2014 direction of the sequence axis.\n- **placement**: `'start' | 'end' | 'alternate'` (default: `'end'`) \u2014 logical side used for item surfaces. Alternate placement starts on `end`, then alternates by array index.\n- **variant**: `'ghost' | 'card' | 'outline' | 'soft'` (default: `'ghost'`) \u2014 global surface treatment for all entries.\n- **size**: `'small' | 'normal' | 'large'` (default: `'normal'`) \u2014 title and description typography, marker and icon scale, and loading Spinner scale.\n- **density**: `'compact' | 'normal' | 'comfortable'` (default: `'normal'`) \u2014 item gaps, surface padding, connector spacing, and horizontal item minimum width.\n- **color**: Entasis semantic color (default: `'neutral'`) \u2014 default marker and outline or soft surface accent.\n- **connectorColor**: Entasis semantic color (default: `'neutral'`) \u2014 default outgoing connector color.\n- **showConnectors**: `boolean` (default: `true`) \u2014 shows connector segments between markers.\n- **scrollFade**: `boolean` (default: `true`) \u2014 applies the shared logical horizontal scroll fade only while a horizontal timeline actually overflows.\n- **i18n**: `Partial<Messages>` \u2014 per-instance translations merged over the global i18n catalog.\n\n### Custom rendering\n\n- **item**: `Snippet<[TimelineItemPayload<Item>]>` \u2014 replaces the content inside each Timeline-owned variant surface.\n- **marker**: `Snippet<[TimelineItemPayload<Item>]>` \u2014 replaces the marker visual inside the Timeline-owned axis anchor.\n- **opposite**: `Snippet<[TimelineItemPayload<Item>]>` \u2014 replaces the opposite track in alternate placement. Its default renderer is the item date. TypeScript rejects this prop with fixed `start` or `end` placement.\n\n### Root element\n\n- **ref**: `HTMLOListElement | null` (bindable) \u2014 reference to the root `<ol>`.\n- **class**: `string` \u2014 additional root classes.\n- **theme**: `TimelineThemeProps` \u2014 per-instance overrides for Timeline theme parts.\n- Native ordered-list attributes and Svelte attachments are forwarded to the root.\n\n## Item shape\n\n```ts\ntype TimelineItem = {\n id?: string | number;\n title: Slot;\n date?: Slot;\n datetime?: string;\n description?: Slot;\n icon?: Slot;\n loading?: boolean;\n color?: Colors;\n connectorColor?: Colors;\n side?: 'start' | 'end';\n};\n```\n\n- `title` is required. `title`, `date`, `description`, and `icon` accept a string or snippet.\n- `id` is optional. Use a stable explicit ID when entries can reorder; otherwise Timeline uses the array index as identity.\n- `datetime` adds the machine-readable value to a default `<time>` element when `date` is present.\n- `icon` replaces the default dot inside the default marker.\n- `loading` replaces the default marker visual with a color- and size-aware Spinner.\n- `color` overrides the root color for this marker and its outline or soft surface.\n- `connectorColor` overrides the root color for this item's outgoing connector. It has no effect on the final item.\n- `side` overrides alternate parity for one item. It is not valid with fixed placement.\n\n## Placement and dates\n\nFixed `start` and `end` placement keeps every surface on one logical side. The default content renders the date before the title inside each surface.\n\n`placement=\"alternate\"` creates an opposite track and starts the first item on `end`. Its default opposite renderer displays the date. Set `item.side` to place a specific alternate item on `start` or `end` without changing the array order.\n\n```svelte\n<Timeline items={milestones} placement=\"alternate\" variant=\"card\" />\n```\n\nTimeline treats dates as display content. It does not parse, format, compare, or sort them. It also has no completed, current, or pending status model. Put status meaning in the title or description and use semantic color or a custom marker only as supporting presentation.\n\n## Generic snippets and defaults\n\nApplication item types can extend `TimelineItem`. The extended type remains available as `payload.item` in all three renderer snippets.\n\n```svelte\n<script lang=\"ts\">\n import { Timeline, type TimelineItem } from './index.ts';\n\n type Release = TimelineItem & {\n version: string;\n href: string;\n };\n\n const releases: Release[] = [\n { id: 'v2', title: 'Version 2 released', version: '2.0.0', href: '/releases/v2' }\n ];\n</script>\n\n<Timeline items={releases}>\n {#snippet item({ item, defaultContent })}\n <a href={item.href} class=\"block\">\n {@render defaultContent()}\n <span class=\"text-neutral/70 text-xs\">{item.version}</span>\n </a>\n {/snippet}\n</Timeline>\n```\n\nEvery snippet receives:\n\n```ts\ntype TimelineItemPayload<Item extends TimelineItem> = Readonly<{\n item: Item;\n index: number;\n side: 'start' | 'end';\n orientation: 'vertical' | 'horizontal';\n color: Colors;\n connectorColor: Colors;\n isFirst: boolean;\n isLast: boolean;\n defaultContent: Snippet;\n defaultMarker: Snippet;\n defaultOpposite: Snippet;\n}>;\n```\n\nCall `defaultContent()`, `defaultMarker()`, or `defaultOpposite()` to wrap or extend the matching default renderer. A custom `marker` snippet that does not call `defaultMarker()` owns its complete marker visual, including loading presentation. Timeline still owns the `<ol>`, each `<li>`, axis and connector geometry, marker anchor, opposite track, and variant surface.\n\n## Responsive alternate layout\n\nA vertical alternate timeline uses its own inline-size container. Below `40rem`, it moves the axis to the logical start edge, places every main surface on the end side, and places opposite content before the main surface. DOM and input order do not change. Per-item `side` values remain in the payload, but their visual side effect is suspended while the layout is collapsed.\n\n## Horizontal overflow\n\nHorizontal entries stay on one axis and use density-controlled minimum widths. They do not wrap or compress; the root uses native horizontal scrolling. Timeline measures real overflow with `ResizeObserver`, applies `scroll-fade-x` only when needed, and makes the ordered list keyboard-focusable only while it overflows. The fade and native scrolling use logical inline direction and work in LTR and RTL layouts.\n\n## Variants\n\n- `ghost`: no surface chrome.\n- `card`: neutral raised surface with a quiet ring and shadow.\n- `outline`: transparent surface with a ring in the resolved item color.\n- `soft`: muted surface in the resolved item color.\n\nThe variant is global. Timeline does not support per-item variant overrides.\n\n## Runtime validation\n\nTimeline throws a descriptive `TypeError` when:\n\n- two items have the same explicit `id`;\n- an item supplies `side` while placement is fixed to `start` or `end`.\n\nThe ID check preserves number and string identity, so `1` and `'1'` are different IDs. TypeScript separately rejects `opposite` with fixed placement.\n\n## Accessibility\n\n- The root is an ordered list and every event is a list item.\n- Axis anchors, markers, and connectors are decorative and hidden from assistive technology. Do not use marker color or shape as the only status signal.\n- A default loading marker has a localized polite status announcement outside the decorative axis.\n- A default date with `datetime` renders as a semantic `<time>` element.\n- An overflowing horizontal timeline becomes a native keyboard scroll region; a non-overflowing timeline adds no tab stop.\n\n## Theme parts\n\n`root`, `item`, `opposite`, `axis`, `connector`, `marker`, `content`, `date`, `titleRow`, `title`, `description`, and `loading`.\n\nTimeline is display-only. It has no selection, navigation, event callbacks, animation state, bindable controller, date processing, or built-in progress status.\n";
1
+ export declare const timelineDescription = "\n# Timeline\n\nRender an ordered sequence of dated or descriptive events on a vertical or horizontal axis. Timeline owns the semantic list, item surfaces, axis anchors, markers, connectors, responsive alternate collapse, and horizontal overflow behavior. Input order is always render order.\n\n## Basic usage\n\n```svelte\n<script lang=\"ts\">\n import { Timeline, type TimelineItem } from './index.ts';\n\n const items: TimelineItem[] = [\n {\n id: 'placed',\n date: 'Mar 15, 2024',\n datetime: '2024-03-15',\n title: 'Order placed',\n description: 'Your order has been received.'\n },\n {\n id: 'payment',\n date: 'Mar 16, 2024',\n datetime: '2024-03-16',\n title: 'Payment confirmed',\n color: 'success'\n }\n ];\n</script>\n\n<Timeline {items} />\n```\n\nThe default configuration is a vertical timeline with all content on the logical end side of the axis.\n\n## Props\n\n### Data and layout\n\n- **items**: `readonly Item[]` (required) \u2014 entries in display order. `Item` must extend `TimelineItem`.\n- **orientation**: `'vertical' | 'horizontal'` (default: `'vertical'`) \u2014 direction of the sequence axis.\n- **placement**: `'start' | 'end' | 'alternate'` (default: `'end'`) \u2014 logical side used for item surfaces. Alternate placement starts on `end`, then alternates by array index.\n- **variant**: `'ghost' | 'card' | 'outline' | 'soft'` (default: `'ghost'`) \u2014 global surface treatment for all entries.\n- **size**: `'small' | 'normal' | 'large'` (default: `'normal'`) \u2014 title and description typography, marker and icon scale, and loading Spinner scale.\n- **density**: `'compact' | 'normal' | 'comfortable'` (default: `'normal'`) \u2014 item gaps, surface padding, connector spacing, and horizontal item minimum width.\n- **color**: Entasis semantic color (default: `'neutral'`) \u2014 default marker and outline or soft surface accent.\n- **connectorColor**: Entasis semantic color \u2014 default outgoing connector color. When omitted, an item with `progress` uses its own resolved color for its connector, and every other item uses `'neutral'`.\n- **showConnectors**: `boolean` (default: `true`) \u2014 shows connector segments between markers.\n- **scrollFade**: `boolean` (default: `true`) \u2014 applies the shared logical horizontal scroll fade only while a horizontal timeline actually overflows.\n- **i18n**: `Partial<Messages>` \u2014 per-instance translations merged over the global i18n catalog.\n\n### Custom rendering\n\n- **item**: `Snippet<[TimelineItemPayload<Item>]>` \u2014 replaces the content inside each Timeline-owned variant surface.\n- **marker**: `Snippet<[TimelineItemPayload<Item>]>` \u2014 replaces the marker visual inside the Timeline-owned axis anchor.\n- **opposite**: `Snippet<[TimelineItemPayload<Item>]>` \u2014 replaces the opposite track in alternate placement. Its default renderer is the item date. TypeScript rejects this prop with fixed `start` or `end` placement.\n\n### Root element\n\n- **ref**: `HTMLOListElement | null` (bindable) \u2014 reference to the root `<ol>`.\n- **class**: `string` \u2014 additional root classes.\n- **theme**: `TimelineThemeProps` \u2014 per-instance overrides for Timeline theme parts.\n- Native ordered-list attributes and Svelte attachments are forwarded to the root.\n\n## Item shape\n\n```ts\ntype TimelineItem = {\n id?: string | number;\n title: Slot;\n date?: Slot;\n datetime?: string;\n description?: Slot;\n icon?: Slot;\n loading?: boolean;\n color?: Colors;\n connectorColor?: Colors;\n progress?: 'done' | 'current' | 'upcoming';\n side?: 'start' | 'end';\n};\n```\n\n- `title` is required. `title`, `date`, `description`, and `icon` accept a string or snippet.\n- `id` is optional. Use a stable explicit ID when entries can reorder; otherwise Timeline uses the array index as identity.\n- `datetime` adds the machine-readable value to a default `<time>` element when `date` is present.\n- `icon` replaces the default dot inside the default marker.\n- `loading` replaces the default marker visual with a color- and size-aware Spinner.\n- `color` overrides the root color for this marker and its outline or soft surface.\n- `connectorColor` overrides the root color for this item's outgoing connector. It has no effect on the final item.\n- `side` overrides alternate parity for one item. It is not valid with fixed placement.\n- `progress` marks a step of a process (`TimelineProgress`). See Step progress below.\n\n## Placement and dates\n\nFixed `start` and `end` placement keeps every surface on one logical side. The default content renders the date before the title inside each surface.\n\n`placement=\"alternate\"` creates an opposite track and starts the first item on `end`. Its default opposite renderer displays the date. Set `item.side` to place a specific alternate item on `start` or `end` without changing the array order.\n\n```svelte\n<Timeline items={milestones} placement=\"alternate\" variant=\"card\" />\n```\n\nTimeline treats dates as display content. It does not parse, format, compare, or sort them.\n\n## Step progress\n\nGive each item a `progress` to draw a process's progress band, such as a file's six stages, so a reader sees at a glance what is done, what is under way, and what is to come:\n\n```svelte\n<script lang=\"ts\">\n import { Timeline, type TimelineItem } from './index.ts';\n\n const steps: TimelineItem[] = [\n { id: 'opened', title: 'Opened', progress: 'done' },\n { id: 'documents', title: 'Documents', progress: 'done' },\n { id: 'appraisal', title: 'Appraisal', progress: 'current' },\n { id: 'report', title: 'Report', progress: 'upcoming' }\n ];\n</script>\n\n<Timeline items={steps} orientation=\"horizontal\" color=\"primary\" />\n```\n\n- `done`: the marker is filled with the item color and carries a check. Its outgoing connector is solid.\n- `current`: the marker is a ring in the item color around a point. The `<li>` gets `aria-current=\"step\"`. Its outgoing connector is faint.\n- `upcoming`: the marker is a quiet neutral ring carrying the step number (the item's position, from 1). Its outgoing connector is faint.\n- Markers keep the size's marker scale. Progress connectors are 2px thick instead of the hairline, so the done part of the band stands out.\n- An item `icon` replaces the check, the point, or the number, and keeps the progress ring and fill. `loading` replaces the whole marker with the Spinner.\n- `progress` is per item: Timeline does not derive it from an index, so a host may mark any combination.\n- The step's state is not announced by the markers, which stay decorative. Put it in the item's own text (for example \"Step 3 of 6, done\") or an `item` snippet. `aria-current=\"step\"` on the current item is the only state Timeline adds.\n- The `marker` snippet still replaces every marker: read `payload.item.progress` and call `defaultMarker()` for the steps it leaves alone. Theme overrides reach the `progress` variant of the `marker` and `connector` parts, for example `theme={{ connector: { progress: { done: 'bg-success' } } }}`.\n- Styling hooks: `data-progress` on the item, its marker and its outgoing connector.\n\n## Generic snippets and defaults\n\nApplication item types can extend `TimelineItem`. The extended type remains available as `payload.item` in all three renderer snippets.\n\n```svelte\n<script lang=\"ts\">\n import { Timeline, type TimelineItem } from './index.ts';\n\n type Release = TimelineItem & {\n version: string;\n href: string;\n };\n\n const releases: Release[] = [\n { id: 'v2', title: 'Version 2 released', version: '2.0.0', href: '/releases/v2' }\n ];\n</script>\n\n<Timeline items={releases}>\n {#snippet item({ item, defaultContent })}\n <a href={item.href} class=\"block\">\n {@render defaultContent()}\n <span class=\"text-neutral/70 text-xs\">{item.version}</span>\n </a>\n {/snippet}\n</Timeline>\n```\n\nEvery snippet receives:\n\n```ts\ntype TimelineItemPayload<Item extends TimelineItem> = Readonly<{\n item: Item;\n index: number;\n side: 'start' | 'end';\n orientation: 'vertical' | 'horizontal';\n color: Colors;\n connectorColor: Colors;\n isFirst: boolean;\n isLast: boolean;\n defaultContent: Snippet;\n defaultMarker: Snippet;\n defaultOpposite: Snippet;\n}>;\n```\n\nCall `defaultContent()`, `defaultMarker()`, or `defaultOpposite()` to wrap or extend the matching default renderer. A custom `marker` snippet that does not call `defaultMarker()` owns its complete marker visual, including loading presentation. Timeline still owns the `<ol>`, each `<li>`, axis and connector geometry, marker anchor, opposite track, and variant surface.\n\n## Responsive alternate layout\n\nA vertical alternate timeline uses its own inline-size container. Below `40rem`, it moves the axis to the logical start edge, places every main surface on the end side, and places opposite content before the main surface. DOM and input order do not change. Per-item `side` values remain in the payload, but their visual side effect is suspended while the layout is collapsed.\n\n## Horizontal overflow\n\nHorizontal entries stay on one axis and use density-controlled minimum widths. They do not wrap or compress; the root uses native horizontal scrolling. Timeline measures real overflow with `ResizeObserver`, applies `scroll-fade-x` only when needed, and makes the ordered list keyboard-focusable only while it overflows. The fade and native scrolling use logical inline direction and work in LTR and RTL layouts.\n\n## Variants\n\n- `ghost`: no surface chrome.\n- `card`: neutral raised surface with a quiet ring and shadow.\n- `outline`: transparent surface with a ring in the resolved item color.\n- `soft`: muted surface in the resolved item color.\n\nThe variant is global. Timeline does not support per-item variant overrides.\n\n## Runtime validation\n\nTimeline throws a descriptive `TypeError` when:\n\n- two items have the same explicit `id`;\n- an item supplies `side` while placement is fixed to `start` or `end`.\n\nThe ID check preserves number and string identity, so `1` and `'1'` are different IDs. TypeScript separately rejects `opposite` with fixed placement.\n\n## Accessibility\n\n- The root is an ordered list and every event is a list item.\n- Axis anchors, markers, and connectors are decorative and hidden from assistive technology. Do not use marker color or shape as the only status signal.\n- An item with `progress: 'current'` carries `aria-current=\"step\"`.\n- A default loading marker has a localized polite status announcement outside the decorative axis.\n- A default date with `datetime` renders as a semantic `<time>` element.\n- An overflowing horizontal timeline becomes a native keyboard scroll region; a non-overflowing timeline adds no tab stop.\n\n## Theme parts\n\n`root`, `item`, `opposite`, `axis`, `connector`, `marker`, `content`, `date`, `titleRow`, `title`, `description`, and `loading`.\n\nTimeline is display-only. It has no selection, navigation, event callbacks, animation state, bindable controller, or date processing. Step `progress` is presentation the host sets per item, not a state Timeline advances.\n";
@@ -43,7 +43,7 @@ The default configuration is a vertical timeline with all content on the logical
43
43
  - **size**: \`'small' | 'normal' | 'large'\` (default: \`'normal'\`) — title and description typography, marker and icon scale, and loading Spinner scale.
44
44
  - **density**: \`'compact' | 'normal' | 'comfortable'\` (default: \`'normal'\`) — item gaps, surface padding, connector spacing, and horizontal item minimum width.
45
45
  - **color**: Entasis semantic color (default: \`'neutral'\`) — default marker and outline or soft surface accent.
46
- - **connectorColor**: Entasis semantic color (default: \`'neutral'\`) — default outgoing connector color.
46
+ - **connectorColor**: Entasis semantic color — default outgoing connector color. When omitted, an item with \`progress\` uses its own resolved color for its connector, and every other item uses \`'neutral'\`.
47
47
  - **showConnectors**: \`boolean\` (default: \`true\`) — shows connector segments between markers.
48
48
  - **scrollFade**: \`boolean\` (default: \`true\`) — applies the shared logical horizontal scroll fade only while a horizontal timeline actually overflows.
49
49
  - **i18n**: \`Partial<Messages>\` — per-instance translations merged over the global i18n catalog.
@@ -74,6 +74,7 @@ type TimelineItem = {
74
74
  loading?: boolean;
75
75
  color?: Colors;
76
76
  connectorColor?: Colors;
77
+ progress?: 'done' | 'current' | 'upcoming';
77
78
  side?: 'start' | 'end';
78
79
  };
79
80
  \`\`\`
@@ -86,6 +87,7 @@ type TimelineItem = {
86
87
  - \`color\` overrides the root color for this marker and its outline or soft surface.
87
88
  - \`connectorColor\` overrides the root color for this item's outgoing connector. It has no effect on the final item.
88
89
  - \`side\` overrides alternate parity for one item. It is not valid with fixed placement.
90
+ - \`progress\` marks a step of a process (\`TimelineProgress\`). See Step progress below.
89
91
 
90
92
  ## Placement and dates
91
93
 
@@ -97,7 +99,36 @@ Fixed \`start\` and \`end\` placement keeps every surface on one logical side. T
97
99
  <Timeline items={milestones} placement="alternate" variant="card" />
98
100
  \`\`\`
99
101
 
100
- Timeline treats dates as display content. It does not parse, format, compare, or sort them. It also has no completed, current, or pending status model. Put status meaning in the title or description and use semantic color or a custom marker only as supporting presentation.
102
+ Timeline treats dates as display content. It does not parse, format, compare, or sort them.
103
+
104
+ ## Step progress
105
+
106
+ Give each item a \`progress\` to draw a process's progress band, such as a file's six stages, so a reader sees at a glance what is done, what is under way, and what is to come:
107
+
108
+ \`\`\`svelte
109
+ <script lang="ts">
110
+ import { Timeline, type TimelineItem } from './index.ts';
111
+
112
+ const steps: TimelineItem[] = [
113
+ { id: 'opened', title: 'Opened', progress: 'done' },
114
+ { id: 'documents', title: 'Documents', progress: 'done' },
115
+ { id: 'appraisal', title: 'Appraisal', progress: 'current' },
116
+ { id: 'report', title: 'Report', progress: 'upcoming' }
117
+ ];
118
+ </script>
119
+
120
+ <Timeline items={steps} orientation="horizontal" color="primary" />
121
+ \`\`\`
122
+
123
+ - \`done\`: the marker is filled with the item color and carries a check. Its outgoing connector is solid.
124
+ - \`current\`: the marker is a ring in the item color around a point. The \`<li>\` gets \`aria-current="step"\`. Its outgoing connector is faint.
125
+ - \`upcoming\`: the marker is a quiet neutral ring carrying the step number (the item's position, from 1). Its outgoing connector is faint.
126
+ - Markers keep the size's marker scale. Progress connectors are 2px thick instead of the hairline, so the done part of the band stands out.
127
+ - An item \`icon\` replaces the check, the point, or the number, and keeps the progress ring and fill. \`loading\` replaces the whole marker with the Spinner.
128
+ - \`progress\` is per item: Timeline does not derive it from an index, so a host may mark any combination.
129
+ - The step's state is not announced by the markers, which stay decorative. Put it in the item's own text (for example "Step 3 of 6, done") or an \`item\` snippet. \`aria-current="step"\` on the current item is the only state Timeline adds.
130
+ - The \`marker\` snippet still replaces every marker: read \`payload.item.progress\` and call \`defaultMarker()\` for the steps it leaves alone. Theme overrides reach the \`progress\` variant of the \`marker\` and \`connector\` parts, for example \`theme={{ connector: { progress: { done: 'bg-success' } } }}\`.
131
+ - Styling hooks: \`data-progress\` on the item, its marker and its outgoing connector.
101
132
 
102
133
  ## Generic snippets and defaults
103
134
 
@@ -177,6 +208,7 @@ The ID check preserves number and string identity, so \`1\` and \`'1'\` are diff
177
208
 
178
209
  - The root is an ordered list and every event is a list item.
179
210
  - Axis anchors, markers, and connectors are decorative and hidden from assistive technology. Do not use marker color or shape as the only status signal.
211
+ - An item with \`progress: 'current'\` carries \`aria-current="step"\`.
180
212
  - A default loading marker has a localized polite status announcement outside the decorative axis.
181
213
  - A default date with \`datetime\` renders as a semantic \`<time>\` element.
182
214
  - An overflowing horizontal timeline becomes a native keyboard scroll region; a non-overflowing timeline adds no tab stop.
@@ -185,5 +217,5 @@ The ID check preserves number and string identity, so \`1\` and \`'1'\` are diff
185
217
 
186
218
  \`root\`, \`item\`, \`opposite\`, \`axis\`, \`connector\`, \`marker\`, \`content\`, \`date\`, \`titleRow\`, \`title\`, \`description\`, and \`loading\`.
187
219
 
188
- Timeline is display-only. It has no selection, navigation, event callbacks, animation state, bindable controller, date processing, or built-in progress status.
220
+ Timeline is display-only. It has no selection, navigation, event callbacks, animation state, bindable controller, or date processing. Step \`progress\` is presentation the host sets per item, not a state Timeline advances.
189
221
  `;
@@ -9,6 +9,12 @@ export type TimelineOrientation = 'vertical' | 'horizontal';
9
9
  export type TimelinePlacement = 'start' | 'end' | 'alternate';
10
10
  export type TimelineSide = 'start' | 'end';
11
11
  export type TimelineVariant = 'ghost' | 'card' | 'outline' | 'soft';
12
+ /**
13
+ * Where a step stands in a process: finished, under way, or still to come. Drives the default
14
+ * marker (a filled check, a ring with a point, a quiet numbered ring) and the outgoing connector
15
+ * (solid after a finished step, faint after the others).
16
+ */
17
+ export type TimelineProgress = 'done' | 'current' | 'upcoming';
12
18
  export type TimelineItem = {
13
19
  /** Stable identity. Use an explicit ID when the array can reorder. */
14
20
  id?: string | number;
@@ -28,6 +34,13 @@ export type TimelineItem = {
28
34
  color?: Colors;
29
35
  /** Outgoing connector color. Ignored on the final item. */
30
36
  connectorColor?: Colors;
37
+ /**
38
+ * Step progress. A `done` step draws a filled check and a solid outgoing connector, the
39
+ * `current` step a ring with a point and `aria-current="step"`, an `upcoming` step a quiet ring
40
+ * with its step number; the connectors after `current` and `upcoming` steps stay faint. An
41
+ * `icon` replaces the check, the point or the number; `loading` replaces the whole marker.
42
+ */
43
+ progress?: TimelineProgress;
31
44
  /** Logical side override. Valid only when placement is alternate. */
32
45
  side?: TimelineSide;
33
46
  };
@@ -69,7 +82,10 @@ export type TimelineProps<Item extends TimelineItem = TimelineItem> = WithAttach
69
82
  density?: Density;
70
83
  /** Default semantic item accent. */
71
84
  color?: Colors;
72
- /** Default outgoing connector accent. */
85
+ /**
86
+ * Default outgoing connector accent. When omitted, an item with `progress` takes its own
87
+ * color for its connector, and every other item takes `neutral`.
88
+ */
73
89
  connectorColor?: Colors;
74
90
  /** Shows connector segments between markers. */
75
91
  showConnectors?: boolean;
@@ -109,6 +109,12 @@ export declare const timelineTheme: {
109
109
  readonly info: "";
110
110
  readonly neutral: "";
111
111
  };
112
+ progress: {
113
+ none: string;
114
+ done: string;
115
+ current: string;
116
+ upcoming: string;
117
+ };
112
118
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
113
119
  marker: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
114
120
  size: {
@@ -125,6 +131,12 @@ export declare const timelineTheme: {
125
131
  readonly info: "";
126
132
  readonly neutral: "";
127
133
  };
134
+ progress: {
135
+ none: string;
136
+ done: string;
137
+ current: string;
138
+ upcoming: string;
139
+ };
128
140
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
129
141
  content: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
130
142
  orientation: {
@@ -336,6 +348,12 @@ export declare const setTimelineTheme: (theme: InferComponentTheme<{
336
348
  readonly info: "";
337
349
  readonly neutral: "";
338
350
  };
351
+ progress: {
352
+ none: string;
353
+ done: string;
354
+ current: string;
355
+ upcoming: string;
356
+ };
339
357
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
340
358
  marker: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
341
359
  size: {
@@ -352,6 +370,12 @@ export declare const setTimelineTheme: (theme: InferComponentTheme<{
352
370
  readonly info: "";
353
371
  readonly neutral: "";
354
372
  };
373
+ progress: {
374
+ none: string;
375
+ done: string;
376
+ current: string;
377
+ upcoming: string;
378
+ };
355
379
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
356
380
  content: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
357
381
  orientation: {
@@ -561,6 +585,12 @@ export declare const useTimelineTheme: import("../../utils/cva/theme.ts").UseCom
561
585
  readonly info: "";
562
586
  readonly neutral: "";
563
587
  };
588
+ progress: {
589
+ none: string;
590
+ done: string;
591
+ current: string;
592
+ upcoming: string;
593
+ };
564
594
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
565
595
  marker: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
566
596
  size: {
@@ -577,6 +607,12 @@ export declare const useTimelineTheme: import("../../utils/cva/theme.ts").UseCom
577
607
  readonly info: "";
578
608
  readonly neutral: "";
579
609
  };
610
+ progress: {
611
+ none: string;
612
+ done: string;
613
+ current: string;
614
+ upcoming: string;
615
+ };
580
616
  }> & import("../../utils/cva/types.ts").CVAClassProp) | undefined) => string;
581
617
  content: (props?: (import("../../utils/cva/types.ts").CVAVariantSchema<{
582
618
  orientation: {
@@ -248,6 +248,8 @@ const defaultAxis = cva({
248
248
  isLast: false
249
249
  }
250
250
  });
251
+ // `progress` is the step the connector leaves: solid after a done step, faint after the
252
+ // current and upcoming ones, so the band reads as finished up to the current step.
251
253
  const defaultConnector = cva({
252
254
  base: 'pointer-events-none absolute z-0 bg-color/35',
253
255
  variants: {
@@ -255,11 +257,22 @@ const defaultConnector = cva({
255
257
  vertical: 'left-1/2 top-[calc(var(--timeline-marker-size)/2)] h-full w-px -translate-x-1/2',
256
258
  horizontal: 'start-[calc(var(--timeline-marker-size)/2)] top-1/2 h-px w-full -translate-y-1/2'
257
259
  },
258
- color: colorVariants
260
+ color: colorVariants,
261
+ progress: {
262
+ none: '',
263
+ done: 'bg-color',
264
+ current: 'bg-color/20',
265
+ upcoming: 'bg-color/20'
266
+ }
259
267
  },
268
+ compoundVariants: [
269
+ { orientation: 'vertical', progress: ['done', 'current', 'upcoming'], class: 'w-0.5' },
270
+ { orientation: 'horizontal', progress: ['done', 'current', 'upcoming'], class: 'h-0.5' }
271
+ ],
260
272
  defaultVariants: {
261
273
  orientation: 'vertical',
262
- color: 'neutral'
274
+ color: 'neutral',
275
+ progress: 'none'
263
276
  }
264
277
  });
265
278
  const defaultMarker = cva({
@@ -270,11 +283,21 @@ const defaultMarker = cva({
270
283
  normal: 'size-[1.125rem]',
271
284
  large: 'size-[1.375rem]'
272
285
  },
273
- color: colorVariants
286
+ color: colorVariants,
287
+ // A done step is filled with its check, the current one ringed around its point, an
288
+ // upcoming one a quiet ring around its step number. The number is a glyph drawn to the
289
+ // marker, like the check, so it scales with the marker size rather than the type ramp.
290
+ progress: {
291
+ none: '',
292
+ done: 'bg-color text-color-contrast ring-color',
293
+ current: 'ring-2 ring-color',
294
+ upcoming: 'text-neutral/70 ring-neutral-muted font-medium leading-none tabular-nums text-[length:calc(var(--timeline-marker-size)*0.55)]'
295
+ }
274
296
  },
275
297
  defaultVariants: {
276
298
  size: 'normal',
277
- color: 'neutral'
299
+ color: 'neutral',
300
+ progress: 'none'
278
301
  }
279
302
  });
280
303
  const defaultContent = cva({
@@ -1098,7 +1098,7 @@ export declare const componentInventory: readonly [{
1098
1098
  readonly id: "timeline";
1099
1099
  readonly subpath: "entasis/timeline";
1100
1100
  readonly sourceIndex: "src/lib/components/Timeline/index.ts";
1101
- readonly exportedSymbols: readonly ["Timeline", "TimelineItem", "TimelineItemPayload", "TimelineOrientation", "TimelinePlacement", "TimelineProps", "TimelineSide", "TimelineTheme", "TimelineThemeProps", "TimelineVariant", "setTimelineTheme", "timelineTheme", "useTimelineTheme"];
1101
+ readonly exportedSymbols: readonly ["Timeline", "TimelineItem", "TimelineItemPayload", "TimelineOrientation", "TimelinePlacement", "TimelineProgress", "TimelineProps", "TimelineSide", "TimelineTheme", "TimelineThemeProps", "TimelineVariant", "setTimelineTheme", "timelineTheme", "useTimelineTheme"];
1102
1102
  readonly docs: readonly [{
1103
1103
  readonly id: "timeline";
1104
1104
  readonly route: "/components/timeline";
@@ -2811,6 +2811,7 @@ export const componentInventory = [
2811
2811
  'TimelineItemPayload',
2812
2812
  'TimelineOrientation',
2813
2813
  'TimelinePlacement',
2814
+ 'TimelineProgress',
2814
2815
  'TimelineProps',
2815
2816
  'TimelineSide',
2816
2817
  'TimelineTheme',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entasis",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",