@thomasfosterau/components 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -56
- package/dist/canvas/VectorCanvas.svelte +89 -8
- package/dist/canvas/VectorCanvas.svelte.d.ts +19 -1
- package/dist/canvas/types.d.ts +1 -1
- package/dist/charts/types.d.ts +1 -1
- package/dist/citations/list-format.js +3 -1
- package/dist/data/ChatBubble.svelte +48 -0
- package/dist/data/ChatBubble.svelte.d.ts +33 -0
- package/dist/data/DescriptionList.svelte +9 -3
- package/dist/data/DescriptionList.svelte.d.ts +1 -1
- package/dist/data/DescriptionRow.svelte +6 -5
- package/dist/data/MessageThread.svelte +29 -0
- package/dist/data/MessageThread.svelte.d.ts +17 -0
- package/dist/data/Pagination.svelte +5 -5
- package/dist/data/Pagination.svelte.d.ts +8 -0
- package/dist/data/PaginationFooter.svelte +2 -1
- package/dist/data/PaginationFooter.svelte.d.ts +2 -0
- package/dist/data/Table.svelte +9 -3
- package/dist/data/description-context.d.ts +12 -6
- package/dist/data/description-context.js +6 -5
- package/dist/data/index.d.ts +2 -0
- package/dist/data/index.js +2 -0
- package/dist/data/table-types.d.ts +7 -0
- package/dist/feedback/Dialog.svelte +19 -49
- package/dist/feedback/Dialog.svelte.d.ts +0 -1
- package/dist/feedback/DialogFrame.svelte +64 -0
- package/dist/feedback/DialogFrame.svelte.d.ts +23 -0
- package/dist/feedback/Drawer.svelte +43 -0
- package/dist/feedback/Drawer.svelte.d.ts +43 -0
- package/dist/feedback/SegmentBar.svelte.d.ts +1 -0
- package/dist/feedback/StatTile.svelte.d.ts +1 -0
- package/dist/feedback/Steps.svelte +72 -0
- package/dist/feedback/Steps.svelte.d.ts +61 -0
- package/dist/feedback/Timeline.svelte.d.ts +3 -0
- package/dist/feedback/index.d.ts +3 -0
- package/dist/feedback/index.js +2 -0
- package/dist/feedback/sizes.d.ts +14 -0
- package/dist/feedback/sizes.js +28 -0
- package/dist/fields/Calendar.svelte +60 -0
- package/dist/fields/Calendar.svelte.d.ts +11 -0
- package/dist/fields/CalendarMonths.svelte +86 -0
- package/dist/fields/CalendarMonths.svelte.d.ts +30 -0
- package/dist/fields/Checkbox.svelte +1 -1
- package/dist/fields/Checkbox.svelte.d.ts +6 -0
- package/dist/fields/Combobox.svelte +61 -54
- package/dist/fields/Combobox.svelte.d.ts +38 -3
- package/dist/fields/DateField.svelte +37 -0
- package/dist/fields/DateField.svelte.d.ts +20 -0
- package/dist/fields/DateInput.svelte +1 -1
- package/dist/fields/DateInput.svelte.d.ts +6 -0
- package/dist/fields/DatePicker.svelte +47 -0
- package/dist/fields/DatePicker.svelte.d.ts +25 -0
- package/dist/fields/DateRangePicker.svelte +70 -0
- package/dist/fields/DateRangePicker.svelte.d.ts +43 -0
- package/dist/fields/Dropzone.svelte +232 -0
- package/dist/fields/Dropzone.svelte.d.ts +103 -0
- package/dist/fields/Fieldset.svelte +7 -2
- package/dist/fields/Fieldset.svelte.d.ts +3 -2
- package/dist/fields/FileInput.svelte +3 -2
- package/dist/fields/FileInput.svelte.d.ts +6 -0
- package/dist/fields/FormField.svelte +27 -3
- package/dist/fields/ImageInput.svelte.d.ts +6 -0
- package/dist/fields/MultiSelect.svelte +112 -0
- package/dist/fields/MultiSelect.svelte.d.ts +75 -0
- package/dist/fields/NumberField.svelte +190 -0
- package/dist/fields/NumberField.svelte.d.ts +76 -0
- package/dist/fields/PasswordInput.svelte +95 -0
- package/dist/fields/PasswordInput.svelte.d.ts +58 -0
- package/dist/fields/PickerPopover.svelte +47 -0
- package/dist/fields/PickerPopover.svelte.d.ts +16 -0
- package/dist/fields/PinInput.svelte +83 -0
- package/dist/fields/PinInput.svelte.d.ts +66 -0
- package/dist/fields/RadioGroup.svelte +3 -0
- package/dist/fields/RangeCalendar.svelte +51 -0
- package/dist/fields/RangeCalendar.svelte.d.ts +23 -0
- package/dist/fields/Rating.svelte +127 -0
- package/dist/fields/Rating.svelte.d.ts +86 -0
- package/dist/fields/SearchInput.svelte +2 -1
- package/dist/fields/SegmentGroup.svelte +91 -0
- package/dist/fields/SegmentGroup.svelte.d.ts +20 -0
- package/dist/fields/Select.svelte +1 -1
- package/dist/fields/Select.svelte.d.ts +6 -0
- package/dist/fields/Slider.svelte +194 -0
- package/dist/fields/Slider.svelte.d.ts +100 -0
- package/dist/fields/TagsInput.svelte +256 -0
- package/dist/fields/TagsInput.svelte.d.ts +102 -0
- package/dist/fields/TextInput.svelte +1 -1
- package/dist/fields/TextInput.svelte.d.ts +6 -0
- package/dist/fields/Textarea.svelte +1 -1
- package/dist/fields/Textarea.svelte.d.ts +6 -0
- package/dist/fields/TimeField.svelte +30 -0
- package/dist/fields/TimeField.svelte.d.ts +19 -0
- package/dist/fields/Toggle.svelte +1 -1
- package/dist/fields/Toggle.svelte.d.ts +6 -0
- package/dist/fields/VideoInput.svelte.d.ts +6 -0
- package/dist/fields/calendar-props.d.ts +119 -0
- package/dist/fields/calendar-props.js +51 -0
- package/dist/fields/dates.d.ts +28 -0
- package/dist/fields/dates.js +26 -0
- package/dist/fields/field-types.d.ts +29 -0
- package/dist/fields/field-types.js +32 -0
- package/dist/fields/file-validation.d.ts +30 -0
- package/dist/fields/file-validation.js +51 -0
- package/dist/fields/index.d.ts +9 -0
- package/dist/fields/index.js +8 -0
- package/dist/fields/latest-search.svelte.d.ts +38 -0
- package/dist/fields/latest-search.svelte.js +85 -0
- package/dist/fields/number-format.d.ts +52 -0
- package/dist/fields/number-format.js +89 -0
- package/dist/fields/segmented-field.d.ts +128 -0
- package/dist/fields/segmented-field.js +52 -0
- package/dist/fields/slider-keys.d.ts +21 -0
- package/dist/fields/slider-keys.js +37 -0
- package/dist/formatting/Countdown.svelte +110 -0
- package/dist/formatting/Countdown.svelte.d.ts +35 -0
- package/dist/formatting/Duration.svelte +18 -0
- package/dist/formatting/Duration.svelte.d.ts +20 -0
- package/dist/formatting/Highlight.svelte +4 -0
- package/dist/formatting/Highlight.svelte.d.ts +4 -0
- package/dist/formatting/duration.d.ts +44 -0
- package/dist/formatting/duration.js +125 -0
- package/dist/formatting/index.d.ts +3 -0
- package/dist/formatting/index.js +3 -0
- package/dist/forms/EntityForm.svelte +53 -45
- package/dist/forms/EntityForm.svelte.d.ts +12 -3
- package/dist/hotkeys/index.d.ts +5 -0
- package/dist/hotkeys/index.js +5 -0
- package/dist/hotkeys/match.js +12 -10
- package/dist/hotkeys/types.d.ts +4 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/infobox/Infobox.svelte +2 -2
- package/dist/infobox/Infobox.svelte.d.ts +7 -0
- package/dist/infobox/InfoboxHeader.svelte +15 -7
- package/dist/infobox/InfoboxHeader.svelte.d.ts +7 -0
- package/dist/infobox/types.d.ts +5 -5
- package/dist/layout/AppShell.svelte +50 -0
- package/dist/layout/AppShell.svelte.d.ts +28 -0
- package/dist/layout/Cluster.svelte +28 -0
- package/dist/layout/Cluster.svelte.d.ts +27 -0
- package/dist/layout/Fits.svelte +27 -0
- package/dist/layout/Fits.svelte.d.ts +13 -0
- package/dist/layout/Flow.svelte +46 -0
- package/dist/layout/Flow.svelte.d.ts +16 -0
- package/dist/layout/Grid.svelte +23 -0
- package/dist/layout/Grid.svelte.d.ts +26 -0
- package/dist/layout/HStack.svelte +12 -0
- package/dist/layout/HStack.svelte.d.ts +34 -0
- package/dist/layout/Hero.svelte +76 -0
- package/dist/layout/Hero.svelte.d.ts +36 -0
- package/dist/layout/Navbar.svelte +2 -2
- package/dist/layout/Sidebar.svelte +1 -1
- package/dist/layout/Spacer.svelte +12 -0
- package/dist/layout/Spacer.svelte.d.ts +6 -0
- package/dist/layout/VStack.svelte +12 -0
- package/dist/layout/VStack.svelte.d.ts +29 -0
- package/dist/layout/index.d.ts +9 -0
- package/dist/layout/index.js +9 -0
- package/dist/layout/space.d.ts +44 -0
- package/dist/layout/space.js +76 -0
- package/dist/search/types.d.ts +3 -0
- package/dist/seo/og-image.d.ts +4 -3
- package/dist/seo/og-image.js +4 -3
- package/dist/tips/tip-stack.d.ts +1 -0
- package/dist/tips/tip-store.svelte.d.ts +2 -1
- package/dist/ui/Accordion.svelte +75 -0
- package/dist/ui/Accordion.svelte.d.ts +50 -0
- package/dist/ui/AccordionItem.svelte +73 -0
- package/dist/ui/AccordionItem.svelte.d.ts +18 -0
- package/dist/ui/Button.svelte +3 -2
- package/dist/ui/ButtonGroup.svelte +24 -0
- package/dist/ui/ButtonGroup.svelte.d.ts +17 -0
- package/dist/ui/ContextMenu.svelte +75 -0
- package/dist/ui/ContextMenu.svelte.d.ts +25 -0
- package/dist/ui/Disclosure.svelte +7 -0
- package/dist/ui/Disclosure.svelte.d.ts +2 -2
- package/dist/ui/Divider.svelte +47 -0
- package/dist/ui/Divider.svelte.d.ts +28 -0
- package/dist/ui/DropdownMenu.svelte +44 -0
- package/dist/ui/DropdownMenu.svelte.d.ts +30 -0
- package/dist/ui/Menu.svelte +17 -47
- package/dist/ui/Menu.svelte.d.ts +3 -24
- package/dist/ui/MenuCheckboxItem.svelte +33 -0
- package/dist/ui/MenuCheckboxItem.svelte.d.ts +20 -0
- package/dist/ui/MenuItem.svelte +29 -31
- package/dist/ui/MenuItem.svelte.d.ts +2 -2
- package/dist/ui/MenuRadioGroup.svelte +25 -0
- package/dist/ui/MenuRadioGroup.svelte.d.ts +20 -0
- package/dist/ui/MenuRadioItem.svelte +20 -0
- package/dist/ui/MenuRadioItem.svelte.d.ts +15 -0
- package/dist/ui/MenuSeparator.svelte +12 -11
- package/dist/ui/MenuSub.svelte +24 -0
- package/dist/ui/MenuSub.svelte.d.ts +15 -0
- package/dist/ui/Spinner.svelte +41 -0
- package/dist/ui/Spinner.svelte.d.ts +35 -0
- package/dist/ui/Stack.svelte +36 -0
- package/dist/ui/Stack.svelte.d.ts +48 -0
- package/dist/ui/ToggleButton.svelte +58 -0
- package/dist/ui/ToggleButton.svelte.d.ts +45 -0
- package/dist/ui/ToggleGroup.svelte +142 -0
- package/dist/ui/ToggleGroup.svelte.d.ts +86 -0
- package/dist/ui/Toolbar.svelte +40 -0
- package/dist/ui/Toolbar.svelte.d.ts +20 -0
- package/dist/ui/ToolbarButton.svelte +29 -0
- package/dist/ui/ToolbarButton.svelte.d.ts +21 -0
- package/dist/ui/ToolbarLink.svelte +21 -0
- package/dist/ui/ToolbarLink.svelte.d.ts +12 -0
- package/dist/ui/ToolbarSeparator.svelte +22 -0
- package/dist/ui/ToolbarSeparator.svelte.d.ts +12 -0
- package/dist/ui/ToolbarToggleGroup.svelte +66 -0
- package/dist/ui/ToolbarToggleGroup.svelte.d.ts +52 -0
- package/dist/ui/accordion-context.d.ts +26 -0
- package/dist/ui/accordion-context.js +23 -0
- package/dist/ui/context-menu.d.ts +13 -0
- package/dist/ui/context-menu.js +13 -0
- package/dist/ui/index.d.ts +19 -0
- package/dist/ui/index.js +18 -0
- package/dist/ui/menu-context.d.ts +13 -15
- package/dist/ui/menu-context.js +14 -18
- package/dist/ui/menu-parts/CheckboxItemPart.svelte +26 -0
- package/dist/ui/menu-parts/CheckboxItemPart.svelte.d.ts +5 -0
- package/dist/ui/menu-parts/ItemPart.svelte +29 -0
- package/dist/ui/menu-parts/ItemPart.svelte.d.ts +5 -0
- package/dist/ui/menu-parts/RadioGroupPart.svelte +24 -0
- package/dist/ui/menu-parts/RadioGroupPart.svelte.d.ts +5 -0
- package/dist/ui/menu-parts/RadioItemPart.svelte +22 -0
- package/dist/ui/menu-parts/RadioItemPart.svelte.d.ts +5 -0
- package/dist/ui/menu-parts/SeparatorPart.svelte +16 -0
- package/dist/ui/menu-parts/SeparatorPart.svelte.d.ts +5 -0
- package/dist/ui/menu-parts/SubPart.svelte +28 -0
- package/dist/ui/menu-parts/SubPart.svelte.d.ts +5 -0
- package/dist/ui/menu-parts/menu-parts.d.ts +104 -0
- package/dist/ui/menu-parts/menu-parts.js +31 -0
- package/dist/ui/menu-parts/parts.d.ts +12 -0
- package/dist/ui/menu-parts/parts.js +27 -0
- package/dist/ui/toggle-classes.d.ts +39 -0
- package/dist/ui/toggle-classes.js +36 -0
- package/dist/ui/tones.d.ts +35 -0
- package/dist/ui/tones.js +90 -0
- package/package.json +34 -18
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @thomasfosterau/components
|
|
2
2
|
|
|
3
|
-
Accessible Svelte 5 UI primitives, styled with [Tailwind CSS 4](https://tailwindcss.com) + [daisyUI 5](https://daisyui.com) and built on [bits-ui](https://bits-ui.com). The core families are in the root barrel and the heavier ones behind their own [entry points](#entry-points). The maps components live in the sibling package [`@thomasfosterau/components-mapbox`](
|
|
3
|
+
Accessible Svelte 5 UI primitives, styled with [Tailwind CSS 4](https://tailwindcss.com) + [daisyUI 5](https://daisyui.com) and built on [bits-ui](https://bits-ui.com). The core families are in the root barrel and the heavier ones behind their own [entry points](#entry-points). The maps components live in the sibling package [`@thomasfosterau/components-mapbox`](https://github.com/thomasfosterau/components/blob/trunk/packages/mapbox/README.md).
|
|
4
4
|
|
|
5
5
|
Four rules hold across all of them:
|
|
6
6
|
|
|
@@ -71,7 +71,7 @@ Every component that needs to resolve a link, navigate, know the current page or
|
|
|
71
71
|
| `navigate` | `(url: string) => Promise<void> \| void` | Client-side navigation (`ConfirmDialog`'s `redirectTo`, `ConfirmButton`'s `redirectTo`, `CommandPalette`'s `href` rows). |
|
|
72
72
|
| `isCurrent` | `(href: string) => boolean` | `aria-current="page"` decisions (`Breadcrumbs`, `Tabs` in `links` mode, `FilterBar`, `Sidebar`). |
|
|
73
73
|
|
|
74
|
-
Every member is optional in the object you pass — an omitted one falls back individually to `defaultComponentsContext` (identity `href`, `window.location.assign` for navigation, pathname equality for `isCurrent`, runtime-default locale), so every component works unwired in a story or a test.
|
|
74
|
+
Every member is optional in the object you pass — an omitted one falls back individually to `defaultComponentsContext` (identity `href`, `window.location.assign` for navigation, pathname equality for `isCurrent`, runtime-default locale), so every component works unwired in a story or a test. The object's type is exported as `ComponentsContext` (every member required; `setComponentsContext` takes the same members, each optional), and `getComponentsContext()` reads the merged result back for a component of your own.
|
|
75
75
|
|
|
76
76
|
**Passing i18n messages.** There is no message catalogue in this package — every accessible-name prop has an English default and nothing more. An app using [Paraglide](https://inlang.com/m/gerre34r/library-inlang-paraglideJs) (or any other i18n library) passes its own messages at the call site:
|
|
77
77
|
|
|
@@ -94,7 +94,10 @@ Heavier families are separate `exports` entries so a consumer that never uses th
|
|
|
94
94
|
| `@thomasfosterau/components/citations` | citations | `Citation`, `Footnote`, `ReferenceList`, `FootnoteList`, `CreditList`, `DOILink`/`ISBNLink`/`ISSNLink`, the CSL-type renderers (`BookCitation`, `ChapterCitation`, `ScholarlyArticleCitation`, `ThesisCitation`, `ReportCitation`, `Publisher`), `formatCitation`, `BibliographyManager`. |
|
|
95
95
|
| `@thomasfosterau/components/canvas` | canvas | `VectorCanvas` and the pan/zoom/history/geometry toolkit underneath it. |
|
|
96
96
|
| `@thomasfosterau/components/charts` | charts | `Chart` — one component, plain SVG, no charting dependency. |
|
|
97
|
+
| `@thomasfosterau/components/dates` | fields (date controls) | `Calendar`, `RangeCalendar`, `DateField`, `TimeField`, `DatePicker` and `DateRangePicker` on bits-ui, plus `parseDate`, `parseTime`, `Time` and the `DateValue` / `TimeValue` types from `@internationalized/date`. Not in the barrel: the calendar state machines and date maths load only where imported, or lazily with `import()`. |
|
|
98
|
+
| `@thomasfosterau/components/hotkeys` | hotkeys | The same exports the barrel re-exports, as their own entry, so `import('@thomasfosterau/components/hotkeys')` can load `HotkeyHelpDialog` lazily. |
|
|
97
99
|
| `@thomasfosterau/components/tips` | tips | `Tip`, `PopoverTip`, `createTipStore`, `createTipStack`. |
|
|
100
|
+
| `@thomasfosterau/components/context-menu` | ui | `ContextMenu` — kept out of the barrel so bits-ui's menu runtime loads only where it is used. Its children (`MenuItem`, `MenuCheckboxItem`, …) come from the barrel. |
|
|
98
101
|
| `@thomasfosterau/components/entity-form` | forms | `EntityForm` — needs the optional peer `@thomasfosterau/effect-forms-svelte` (see [entity-form](#entity-form)). |
|
|
99
102
|
| `@thomasfosterau/components/seo` | seo | `Head`, `JsonLd`, `serializeJsonLd`, `ogImageUrl` and the share-card constants. |
|
|
100
103
|
| `@thomasfosterau/components/theme.css` | — | The daisyUI theme skeleton (see [Install](#install)). |
|
|
@@ -116,20 +119,47 @@ Buttons, badges and surfaces — the family every other family builds on: `Alert
|
|
|
116
119
|
| `Card` | The surface | `as? = 'article'`, `title?`, `headingLevel? = 2`, `variant? = 'border'`, `size? = 'md'`, `padded? = true`, `bodyClass?` | `figure?`, `actions?`, `children` | `title` is a real heading at `headingLevel`; `as` picks the semantics (`article`/`section`/`div`) |
|
|
117
120
|
| `Alert` | Tone-coloured notice | `tone? = 'info'`, `variant? = 'soft'`, `title?`, `live?: 'assertive'\|'polite'\|'off'`, `onDismiss?`, `dismissLabel? = 'Dismiss'` | `icon?`, `children` | `live` → `role="alert"` \| `role="status"` \| no role; defaults assertive for `error`, polite otherwise; dismiss is a labelled `Button` |
|
|
118
121
|
| `EmptyState` | The quiet "nothing here" card | `title`, `description?`, `headingLevel? = 2` | `icon?`, `action?` | real heading; icon wrapped `aria-hidden` |
|
|
119
|
-
| `Skeleton` | Loading placeholders | `variant?: 'block'\|'text'\|'circle'`, `lines? = 3`, `label? = 'Loading'` | — | `role="status"` + `aria-busy="true"`, named by `label`; every shimmering box `aria-hidden` |
|
|
122
|
+
| `Skeleton` | Loading placeholders when the content's shape is known (use `Spinner` when it is not) | `variant?: 'block'\|'text'\|'circle'`, `lines? = 3`, `label? = 'Loading'` | — | `role="status"` + `aria-busy="true"`, named by `label`; every shimmering box `aria-hidden` |
|
|
123
|
+
| `Spinner` | An indeterminate loading indicator when the content's shape is unknown | `variant? = 'spinner'` (`spinner\|dots\|ring\|ball\|bars\|infinity`), `size?: Size = 'md'`, `tone?: Tone`, `label? = 'Loading'`, `showLabel? = false`, `decorative? = false` + `HTMLAttributes<HTMLSpanElement>` | — | `role="status"` named by `label`, the shape `aria-hidden`; `decorative` is the shape alone, `aria-hidden`, no role |
|
|
124
|
+
| `Stack` | Overlapping cards or images with a peek of the ones behind (daisyUI `stack`) | `items: readonly T[]`, `direction? = 'bottom'` (`'top'\|'bottom'\|'start'\|'end'`), `count? = items.length`, `countLabel? = (n) => \`${n} items\`` | `item(entry, top)` | only the first item is exposed: the rest are `inert` + `aria-hidden`, and the top item reads `countLabel(count)` so the depth is not visual-only. Not a flow layout: for that, `layout/VStack` / `HStack` |
|
|
120
125
|
| `Heading` | Addressable section heading | `level: 1…6`, `id?`, `anchor?`, `anchorLabel? = 'Link to this section'`, `children: string \| Snippet` | `children` | real `<h1…6>`; id slugged from string children at render (SSR-safe) |
|
|
121
126
|
| `VisuallyHidden` | Content for assistive tech only | — | `children` | `sr-only` |
|
|
122
|
-
| `
|
|
127
|
+
| `Accordion` | A stack of headed, collapsible sections | `type: 'single'\|'multiple'`, `value? ⚓` (`string` / `readonly string[]`), `required?` (single), `headingLevel? = 3`, `variant?: 'arrow'\|'plus'\|'plain'`, `joined? = true`, `searchable? = true`, `disabled?` | `children` (`AccordionItem`s) | APG accordion on bits-ui: each title a native `<h{headingLevel}>` around an `aria-expanded` button that `aria-controls` a `region` it labels; ↑/↓, Home/End between headers; a `required` open item reports `aria-disabled`; closed panels are `hidden="until-found"`. Use a `Disclosure` for one native section, an `Accordion` when the sections need headings or a bound open value |
|
|
128
|
+
| `AccordionItem` | One section of an `Accordion` | `title: string \| Snippet`, `value?`, `disabled?`, `contentClass?` | `title?`, `children` | heading level and marker come from the `Accordion`, never per item |
|
|
129
|
+
| `Disclosure` | Expandable section | `open? ⚓`, `title: string \| Snippet`, `name?`, `variant?: 'arrow'\|'plus'\|'plain'`, `contentClass?` | `title?`, `children` | native `<details>`/`<summary>` — no hand-written `aria-expanded`; several sharing a `name` form an exclusive group |
|
|
130
|
+
| `Divider` | A horizontal or vertical rule, optionally labelled ("or") | `orientation? = 'horizontal'`, `decorative? = false`, `align?: 'start'\|'center'\|'end' = 'center'`, `tone?: Tone` + `HTMLAttributes<HTMLDivElement>` | `children?` (the label) | unlabelled: `role="separator"` (+ `aria-orientation="vertical"`); labelled: `role="none"`, the text reads in flow; `decorative`: `aria-hidden` |
|
|
123
131
|
| `Tooltip` | Hover/focus supplement | `text?`, `side? = 'top'`, `sideOffset? = 6`, `openDelay? = 400`, `open? ⚓` | `content?`, `children({ props })` | bits-ui `aria-describedby`; Escape closes |
|
|
132
|
+
| `Toolbar` | A row of controls that is one Tab stop (formatting bar, bulk actions, a tool palette) | `label`, `orientation? = 'horizontal'`, `loop? = true` | `children` (the parts below) | bits-ui `role="toolbar"` named by `label`, `aria-orientation`; one Tab stop; ←/→ (↑/↓ vertical), Home/End across every control. A toolbar is one composite widget; for independent actions that should each be a Tab stop, use `ButtonGroup` |
|
|
133
|
+
| `ToolbarButton` | A button in a `Toolbar` | `tone?`, `variant? = 'ghost'`, `size? = 'sm'`, `icon?`, `iconOnly?` | `children` | the library's `Button` on bits-ui `Toolbar.Button`; `iconOnly` keeps the text as a visually hidden name |
|
|
134
|
+
| `ToolbarLink` | A link in a `Toolbar` | `href` (through the context's `href()`) | `children` | a roving-focus control like the buttons |
|
|
135
|
+
| `ToolbarToggleGroup` | A toggle set inside a `Toolbar` | as `ToggleGroup`: `type`, `items`, `value? ⚓`, `onValueChange?`, `required?` (single), `label`, `tone?`, `variant? = 'ghost'`, `size? = 'sm'`, `disabled?` | — | on bits-ui `Toolbar.Group`, so it shares the toolbar's one Tab stop (a nested `ToggleGroup` would not); single is a `radiogroup` where arrows move and Space/Enter selects |
|
|
136
|
+
| `ToolbarSeparator` | A rule between groups in a `Toolbar` | `toolbarOrientation? = 'horizontal'` | — | `role="separator"` with the perpendicular `aria-orientation`; not focusable |
|
|
137
|
+
| `ButtonGroup` | A named cluster of independent buttons | `label`, `orientation? = 'horizontal'` (`'vertical'`, `'responsive'`) | `children` (each with `class="join-item"`) | `role="group"` named by `label`; no key handling, so every button stays a Tab stop |
|
|
138
|
+
| `ToggleButton` | A button that stays pressed (Bold, Mute, Favourite) | `pressed? ⚓`, `onPressedChange?`, `tone? = 'neutral'`, `variant? = 'ghost'`, `size?`, `iconOnly?` + `label` (required when icon-only), `tooltip?` | `children`, `icon?`, `pressedIcon?` | bits-ui `aria-pressed`; Space/Enter toggle; the name never changes with state; pressed is a fill + `btn-active`, not colour alone; `data-state="on"\|"off"` is a styling hook |
|
|
139
|
+
| `ToggleGroup` | Toggle buttons answering one question: a segmented control or a formatting bar | `type: 'single'\|'multiple'`, `items: readonly ToggleGroupItem[]` (`value`, `label`, `icon?`, `iconOnly?`, `disabled?`), `value? ⚓`, `onValueChange?`, `required?` (single), `label?` or `labelId?`, `describedBy?`, `invalid?`, `name?`, `orientation?`, `loop? = true`, `tone? = 'primary'`, `variant? = 'solid'`, `size?`, `block?`, `showCheck?`, `tooltip?`, `disabled?` | `item?(entry, { pressed })` | single: `role="radiogroup"` of radios, arrows move and select, Tab lands on the checked item, `required` keeps one selected; multiple: `role="group"` of `aria-pressed` buttons, arrows move only; one Tab stop; `name` posts one hidden input per selected value |
|
|
124
140
|
| `Popover` | Floating interactive panel | `open? ⚓`, `side? = 'bottom'`, `align? = 'center'`, `sideOffset? = 8`, `title?`, `closeLabel? = 'Close'` | `trigger({ props })`, `children` | bits-ui dialog ARIA: `aria-haspopup="dialog"`, `aria-expanded`, focus trap, Escape, focus return |
|
|
125
|
-
| `Menu` |
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
141
|
+
| `Menu` | Navigation list | `label`, `class?` | `children` | `<nav aria-label>` + `<ul class="menu">`; imports nothing from bits-ui |
|
|
142
|
+
| `DropdownMenu` | Application menu behind a trigger | `label`, `open? ⚓`, `side? = 'bottom'`, `align? = 'end'`, `class?` | `trigger?({ props })`, `children` | bits-ui `role="menu"`: `aria-haspopup`/`aria-expanded`, roving focus, typeahead, Escape, focus return. The only `Menu*` component that loads bits-ui's dropdown |
|
|
143
|
+
| `ContextMenu` (`./context-menu` entry) | Actions for a region, at the pointer | `label`, `open? ⚓`, `disabled? = false` (the browser's menu shows instead), `class?` | `trigger({ props })` (the region; spread `props`), `children` | bits-ui `role="menu"` named by `label`; opens on right-click, long-press, and Shift+F10 / the Menu key on the focused region (`props` carry `tabindex="0"`); roving focus, typeahead, → / ← submenus, Escape returns focus to the region. **Never the only route to an action** — see below |
|
|
144
|
+
| `MenuItem` | One entry of a `Menu`, `DropdownMenu` or `ContextMenu` | `href?`, `onSelect?`, `current?`, `disabled?` | `icon?`, `children` | in a `Menu`: `aria-current="page"` + `menu-active`; in a bits-ui menu: `role="menuitem"` |
|
|
145
|
+
| `MenuSeparator` | Rule between item groups | — | — | `role="separator"` (bits-ui's in a bits-ui menu, inside a `role="none"` `<li>`) |
|
|
146
|
+
| `MenuCheckboxItem` | An on/off setting in a bits-ui menu | `checked? ⚓`, `onCheckedChange?`, `disabled?` | `children` | `role="menuitemcheckbox"` + `aria-checked`; renders nothing in a `Menu` |
|
|
147
|
+
| `MenuRadioGroup` | One choice from a few, in a bits-ui menu | `value? ⚓`, `onValueChange?`, `label?` (visible heading, names the group) | `children` (the `MenuRadioItem`s) | `role="group"`; renders nothing in a `Menu` |
|
|
148
|
+
| `MenuRadioItem` | One option of a `MenuRadioGroup` | `value`, `disabled?` | `children` | `role="menuitemradio"` + `aria-checked`, exclusive within the group |
|
|
149
|
+
| `MenuSub` | An item that opens a submenu | `label`, `disabled?` | `children` (the submenu's items) | opener `role="menuitem"` with `aria-haspopup="menu"`; → opens, ← closes; renders nothing in a `Menu` |
|
|
128
150
|
| `ShareButton` | Platform share sheet | `title?`, `text?`, `url?`, `label? = 'Share'`, `tone?`, `variant? = 'ghost'`, `size? = 'sm'`, `onShare?`, `onError?` | `children?` | renders **nothing** without `navigator.share` (decided after mount) |
|
|
129
151
|
| `ThemeToggle` | Light / dark switch | `lightTheme`, `darkTheme`, `storageKey? = 'theme'`, `label? = 'Toggle dark mode'`, `value? ⚓` | — | checkbox with `role="switch"` named by `label` |
|
|
130
152
|
| `ConfirmButton` | Two-step inline confirm | `action: () => Promise<unknown>`, `idleLabel? = 'Delete'`, `confirmLabel?`, `cancelLabel?`, `pendingLabel?`, `confirmMessage?`, `failureMessage?`, `tone? = 'error'`, `redirectTo?` | — | failure is an `Alert live="assertive"`; both buttons `disabled` while pending; the confirm button keeps a name (`pendingLabel`) |
|
|
131
153
|
|
|
132
|
-
`ui` also exports
|
|
154
|
+
`ui` also exports:
|
|
155
|
+
|
|
156
|
+
- **`tones.ts`** — the daisyUI vocabulary as types: `Tone` (the semantic colours), `StatusTone` (`'info' | 'success' | 'warning' | 'error'`, what `Alert` takes), `Size` (`'xs'`…`'xl'`), `HeadingLevel` (`1`…`6`), `ButtonVariant`, `BadgeVariant`, `AlertVariant`, `LinkVariant` (`'primary' | 'muted' | 'plain'`), `CardVariant` (`'border' | 'dash' | 'plain'`), `DividerAlign`, `SpinnerVariant`, `DisclosureVariant` (`'arrow' | 'plus' | 'plain'`) and `ChipSize` (`'sm' | 'md'`). Each has a literal `Record<K, string>` class table, so Tailwind's scanner sees every class: `buttonTone`, `buttonVariant`, `buttonSize`, `badgeTone`, `badgeVariant`, `badgeSize`, `alertTone`, `alertVariant`, `progressTone`, `dividerTone`, `dividerAlign`, `dividerOrientation`, `loadingVariant`, `loadingSize`, `rangeTone`, `linkVariant`, `cardVariant`, `cardSize`, `collapseVariant` (the `Disclosure` marker), `chipSize`, `chipSwatchSize`, plus the prefix-free `backgroundTone` and `textTone` for a swatch or segment of your own.
|
|
157
|
+
- **`colour-token.ts`** — `colourTokenStyle`, for binding a component's colour custom property to a theme token, and the two properties it binds: `BADGE_COLOUR_PROPERTY` (`'--badge-colour'`, `Badge`'s) and `CHIP_COLOUR_PROPERTY` (`'--chip-colour'`, `Chip`'s).
|
|
158
|
+
- **`menu-context.ts`** — `MenuMode` (`'dropdown' | 'context' | 'nav'`) and `getMenuMode()`, which tells a menu item of your own which kind of menu it is in: `'dropdown'` inside a `DropdownMenu`, `'context'` inside a `ContextMenu`, otherwise `'nav'`.
|
|
159
|
+
|
|
160
|
+
**A context menu is never the only route to an action.** It is invisible until summoned: keyboard users can open it with Shift+F10 or the Menu key on the focused region, but touch screen-reader users and many keyboard users never discover it. Put every action in a `ContextMenu` somewhere visible as well — a row's "More" `DropdownMenu`, a toolbar. The menu children (`MenuItem`, `MenuSeparator`, `MenuCheckboxItem`, `MenuRadioGroup` / `MenuRadioItem`, `MenuSub`) import nothing from bits-ui: `DropdownMenu` and `ContextMenu` lend them their bits-ui parts through context, so a page that only renders a navigation `Menu` never loads the menu runtime.
|
|
161
|
+
|
|
162
|
+
- **`slug.ts`** — `slugify`, used by `Heading`.
|
|
133
163
|
|
|
134
164
|
```svelte
|
|
135
165
|
<script lang="ts">
|
|
@@ -163,7 +193,7 @@ Buttons, badges and surfaces — the family every other family builds on: `Alert
|
|
|
163
193
|
|
|
164
194
|
```svelte
|
|
165
195
|
<script lang="ts">
|
|
166
|
-
import { Menu, MenuItem, MenuSeparator, Button, ThemeToggle } from '@thomasfosterau/components';
|
|
196
|
+
import { Menu, DropdownMenu, MenuItem, MenuSeparator, Button, ThemeToggle } from '@thomasfosterau/components';
|
|
167
197
|
import { ChevronDown } from '@lucide/svelte';
|
|
168
198
|
</script>
|
|
169
199
|
|
|
@@ -174,14 +204,14 @@ Buttons, badges and surfaces — the family every other family builds on: `Alert
|
|
|
174
204
|
</Menu>
|
|
175
205
|
|
|
176
206
|
<!-- Account menu: bits-ui roving focus, typeahead, Escape, focus return. -->
|
|
177
|
-
<
|
|
207
|
+
<DropdownMenu label="Account">
|
|
178
208
|
{#snippet trigger({ props })}
|
|
179
209
|
<Button {...props} variant="ghost">Ada Lovelace<ChevronDown class="size-4" aria-hidden="true" /></Button>
|
|
180
210
|
{/snippet}
|
|
181
211
|
<MenuItem href="/account">Your profile</MenuItem>
|
|
182
212
|
<MenuSeparator />
|
|
183
213
|
<MenuItem onSelect={signOut}>Sign out</MenuItem>
|
|
184
|
-
</
|
|
214
|
+
</DropdownMenu>
|
|
185
215
|
|
|
186
216
|
<ThemeToggle lightTheme="garden-light" darkTheme="garden-dark" storageKey="garden:theme" />
|
|
187
217
|
```
|
|
@@ -193,7 +223,9 @@ The landmarks a page is assembled from, and the blocks that sit inside them. Str
|
|
|
193
223
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
194
224
|
| --- | --- | --- | --- | --- |
|
|
195
225
|
| `Main` | The page content column | `as? = 'main'` (`'main'\|'section'`) + `HTMLAttributes<HTMLElement>` | `children` | `<main>` is THE landmark, one per page |
|
|
226
|
+
| `Hero` | The top of a landing or "about" page: headline, copy and calls to action | `title: string \| Snippet`, `description?`, `headingLevel? = 1`, `align?: 'start' \| 'center'`, `backgroundImage?`, `actionsLabel? = 'Actions'` | `actions?`, `figure?`, `children?` | A `<section>` named by its heading; `actions` is a `role="group"`; a background image is decorative CSS under an overlay, never an `<img>`. A page has one `<h1>`: don't pair a `Hero` with a `PageHeader` at level 1 |
|
|
196
227
|
| `PageHeader` | What the page is about, and what you can do to it | `title: string \| Snippet`, `subtitle?`, `headingLevel? = 1`, `actionsLabel? = 'Page actions'` | `eyebrow?`, `icon?`, `meta?`, `actions?`, `children?` | `title` is a real heading; `actions` is a `role="group"` named by `actionsLabel`; `icon` is `aria-hidden` |
|
|
228
|
+
| `AppShell` | The responsive app frame | `navOpen? ⚓`, `navLabel? = 'Menu'`, `navTitle? = 'Navigation'` | `sidebar`, `header?({ triggerProps })`, `children` | the sidebar is a plain column from `lg` up and a `Drawer side="start"` below it, with exactly one `navigation` landmark in the tree at any width; close it on navigation with `afterNavigate(() => (navOpen = false))` |
|
|
197
229
|
| `Breadcrumbs` | The ancestor trail | `items: readonly Crumb[]`, `label? = 'Breadcrumb'` | `item?(crumb, index)` | `<nav aria-label>` + `<ol>`; ancestors are links, the trailing crumb is text with `aria-current="page"` |
|
|
198
230
|
| `Navbar` | The site banner | `label? = 'Primary'`, `menuLabel? = 'Menu'`, `menuOpen? ⚓` | `start`, `center?`, `end?`, `menu?` | `<header>` (`banner`) > `<nav aria-label>`; the responsive toggle is a bits-ui `Collapsible` |
|
|
199
231
|
| `Footer` | The site footer | — + `HTMLAttributes<HTMLElement>` | `children`, `aside?` | `<footer>` (`contentinfo`); `aside` renders first |
|
|
@@ -201,7 +233,7 @@ The landmarks a page is assembled from, and the blocks that sit inside them. Str
|
|
|
201
233
|
| `Section` | A titled block of a page | `title`, `headingLevel? = 2`, `headingId?`, `count?`, `description?`, `empty? = false`, `emptyMessage? = 'Nothing here yet.'`, `locales?` | `children`, `actions?`, `emptyState?` | `<section aria-labelledby>` pointing at its own heading; `headingId` defaults to a slug of `title` |
|
|
202
234
|
| `Tabs` | One strip, two things | `items: readonly TabItem[]`, `mode? = 'panels'`, `value? ⚓`, `label? = 'Sections'`, `variant? = 'border'`, `size? = 'md'` | `panel?(item)` | panels: bits-ui `Tabs` (tablist/tab/tabpanel, roving tabindex, arrows); links: `<nav>` of anchors with `aria-current="page"` |
|
|
203
235
|
| `FilterBar` | The filter row above a list | `options: readonly FilterOption[]`, `label` | — | `<nav aria-label>` of `btn`-styled anchors; the active one carries `aria-current="page"` |
|
|
204
|
-
| `Sidebar` | The standing navigation column | `label?`, `title?`, `headingLevel? = 2` | `brand?`, `footer?`, `children` (the `MenuItem`s) | the landmark is `Menu
|
|
236
|
+
| `Sidebar` | The standing navigation column | `label?`, `title?`, `headingLevel? = 2` | `brand?`, `footer?`, `children` (the `MenuItem`s) | the landmark is `Menu`'s; deliberately not an `<aside>` — `complementary` is the wrong landmark for primary navigation; for narrow viewports put it in an `AppShell` |
|
|
205
237
|
|
|
206
238
|
`layout` also exports `types.ts` (`Crumb`, `TabItem`, `FilterOption`, `TabsMode`, `TabsVariant`, `tabsVariant`, `tabsSize`).
|
|
207
239
|
|
|
@@ -246,6 +278,26 @@ The landmarks a page is assembled from, and the blocks that sit inside them. Str
|
|
|
246
278
|
</Sidebar>
|
|
247
279
|
```
|
|
248
280
|
|
|
281
|
+
### Layout primitives and spacing scale
|
|
282
|
+
|
|
283
|
+
`VStack`, `HStack`, `Cluster`, `Grid`, `Spacer` and `Fits` are thin, typed wrappers over Tailwind utilities with a constrained vocabulary. Use them where the spacing should follow the library's rhythm (a stack of fields, a row of buttons, a grid of cards). Write raw Tailwind for one-off layout the scale has no step for. The tokens are literal tables in `layout/space.ts`, exported from the barrel:
|
|
284
|
+
|
|
285
|
+
| Token | Steps |
|
|
286
|
+
| --- | --- |
|
|
287
|
+
| `Gap` → `gapClass` | `none` 0, `xs` 0.25rem, `sm` 0.5rem, `md` 1rem (default), `lg` 2rem, `xl` 3rem |
|
|
288
|
+
| `MinItem` → `minItemClass` | `xs` 10rem, `sm` 12rem, `md` 16rem, `lg` 20rem: the narrowest a `Grid` item gets |
|
|
289
|
+
| `FitsBreakpoint` | `20rem`, `28rem`, `36rem`, `48rem`, `64rem` |
|
|
290
|
+
|
|
291
|
+
| Component | Purpose | Props | Snippets | ARIA |
|
|
292
|
+
| --- | --- | --- | --- | --- |
|
|
293
|
+
| `VStack` / `HStack` | Children in a column / row (SwiftUI `VStack` / `HStack`) | `gap? = 'md'`, `align?`, `justify?` (`'start'\|'center'\|'end'\|'between'`), `divider?`, `as? = 'div'` (`'div'\|'ul'\|'ol'\|'section'\|'nav'`); `HStack` adds `responsive?` (a column below `sm`) | `children` | none of their own; `as="ul"`/`"ol"` restores `role="list"` (children `<li>`); `divider` is a decorative rule between children only; no `reverse`, so visual order never contradicts the DOM (WCAG 1.3.2) |
|
|
294
|
+
| `Cluster` | Wrapping inline items: tags, buttons | `gap? = 'sm'`, `align? = 'center'`, `justify?`, `as?` | `children` | as above |
|
|
295
|
+
| `Grid` | Columns by minimum item width, not breakpoints | `minItem? = 'md'`, `gap? = 'md'`, `as?` | `children` | as above |
|
|
296
|
+
| `Spacer` | Flexible space in a stack: pushes the rest to the far end | — | — | `aria-hidden` |
|
|
297
|
+
| `Fits` | The first layout that fits its container (SwiftUI `ViewThatFits`) | `layouts: Snippet[]` (widest first), `breakpoints: FitsBreakpoint[]` (one fewer, descending) | — | switches at declared container widths with `@container` queries, no JavaScript (CSS cannot measure content). Inactive layouts are `display: none`, so exactly one copy of each control is in the accessibility tree |
|
|
298
|
+
|
|
299
|
+
`ui/Stack` is a different thing: daisyUI's overlapping pile of cards.
|
|
300
|
+
|
|
249
301
|
## feedback
|
|
250
302
|
|
|
251
303
|
Dialogs, toasts, progress — chrome around something that just happened, or is happening now. What these share is the live region and the announcement order, not the markup.
|
|
@@ -253,16 +305,26 @@ Dialogs, toasts, progress — chrome around something that just happened, or is
|
|
|
253
305
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
254
306
|
| --- | --- | --- | --- | --- |
|
|
255
307
|
| `Dialog` | The modal | `open? ⚓`, `title`, `description?`, `closeLabel? = 'Close'`, `size?: 'sm'\|'md'\|'lg'\|'xl'` | `trigger?({ props })`, `children`, `actions?({ close })` | bits-ui `Dialog` owns it all: `role="dialog"` + `aria-modal`, `aria-labelledby`/`describedby`, focus trap, Escape, scroll lock, focus return. daisyUI `modal-box` is styling only |
|
|
256
|
-
| `
|
|
308
|
+
| `Drawer` | A modal panel at an edge: a filters sheet, an action sheet, mobile navigation | `open? ⚓`, `title`, `description?`, `side? = 'end'` (`'start'\|'end'\|'top'\|'bottom'`), `size? = 'md'` (`'sm'\|'md'\|'lg'\|'full'`), `closeLabel? = 'Close'`, `hideTitle?` | `trigger?({ props })`, `children`, `actions?({ close })` | `Dialog`'s contract through a shared internal body: `role="dialog"` + `aria-modal`, named by `title` (kept as the name with `hideTitle`), focus moved in, trapped and returned, Escape and overlay dismissal, scroll lock. `start` / `end` follow the writing direction. Not daisyUI's checkbox `drawer`, which has none of that. No swipe-to-dismiss in v1 |
|
|
309
|
+
| `ConfirmDialog` | A destructive action behind a confirmation | `open? ⚓`, `title`, `description?`, `confirmLabel? = 'Delete'`, `cancelLabel? = 'Cancel'`, `pendingLabel? = 'Working…'`, `failureMessage? = 'That didn’t work. Try again.'`, `closeLabel? = 'Close'`, `tone? = 'error'`, `size? = 'md'`, **either** `onConfirm: () => Promise<unknown>` **or** `form: ConfirmForm`, `redirectTo?` | `trigger?({ props })`, `children?` | `Dialog`'s, plus a failure as an `Alert live="assertive"` inside the still-open dialog; both buttons `disabled` while pending |
|
|
257
310
|
| `Toaster` | The notification region | `position? = 'bottom-end'`, `regionLabel? = 'Notifications'`, `dismissLabel? = 'Dismiss'`, `durationMs? = 6000` | `children` (the app) | ONE `aria-live="polite"` region, present from mount, named by `regionLabel`; nothing live nested inside it — `getToaster()` gives `add({ title, description?, tone? }, durationMs?) → id` and `dismiss(id)` |
|
|
258
311
|
| `LoadingBoundary` | Chrome around one read | `state: 'loading'\|'refreshing'\|'error'\|'ready'`, `loadingLabel? = 'Loading…'`, `errorLabel?`, `tone?: 'page'\|'section'` | `children`, `loading?`, `failed?({ message })` | loading chrome `role="status"`; failure `role="alert"`; children stay mounted through `refreshing` |
|
|
259
312
|
| `ProgressBar` | Determinate or indeterminate progress | `label`, `value?`, `max? = 100`, `tone? = 'primary'`, `showValue?`, `locales?` | — | native `<progress>`, named by `label`; no `value` attribute at all when indeterminate |
|
|
260
|
-
| `SegmentBar` | A proportional stacked bar | `label`, `segments:
|
|
261
|
-
| `StatTile` | One headline figure | `label`, `value: string \| number`, `delta?:
|
|
313
|
+
| `SegmentBar` | A proportional stacked bar | `label`, `segments: readonly BarSegment[]`, `showValues?`, `marker?` (a fraction), `size? = 'md'` (`'sm'\|'md'\|'lg'`), `locales?` | — | ONE `role="img"` whose `aria-label` spells out every segment ("Storage: Photos 62%, Video 31%") |
|
|
314
|
+
| `StatTile` | One headline figure | `label`, `value: string \| number`, `delta?: StatDelta`, `description?`, `upLabel? = 'Up'`, `downLabel? = 'Down'`, `flatLabel? = 'No change'`, `locales?` | `figure?` | reads label → value → change; the trend arrow is `aria-hidden`, direction is in visually hidden text |
|
|
262
315
|
| `Stats` | The wrapper a row of tiles sits in | `orientation?: 'horizontal'\|'vertical'\|'responsive'` (default `responsive`) | `children` | none of its own — name it with `aria-label` when a page has several |
|
|
263
|
-
| `
|
|
316
|
+
| `Steps` | The stages of a short multi-step flow | `items: readonly StepItem[]`, `label`, `current?: number`, `orientation?: 'horizontal'\|'vertical'\|'responsive'` (default `horizontal`), `tone? = 'primary'`, `completeLabel? = 'Completed'`, `currentLabel? = 'Current step'`, `upcomingLabel? = 'Not started'`, `errorLabel? = 'Error'` | `marker?(step, index)` | `<ol>` named by `label`; each step reads its label then its state ("Sow, Completed"), never colour alone; the marker is `aria-hidden`; the current step (or its link) is `aria-current="step"`; a step's own `state` overrides the one derived from `current` |
|
|
317
|
+
| `Timeline` | A dated sequence | `items: readonly TimelineEntry[]`, `label? = 'Timeline'`, `presentLabel? = 'present'`, `toLabel? = 'to'`, `currentLabel? = 'Current'` | `item?(entry)` | `<ol>` named by `label`; an open-ended entry is introduced by `currentLabel` |
|
|
264
318
|
|
|
265
|
-
`feedback` also exports `sizes.ts` (`DialogSize`, `ToastPosition`, `BarSize`, `StatsOrientation` and their class tables) and `toaster.ts` (`getToaster`, `setToaster`, `Toast`, `NewToast`, `ToastTone`, `ToasterApi`). `colourToken` on a `SegmentBar` segment or a `TimelineEntry` is a CSS custom-property name your theme declares — see `ui`'s `colourTokenStyle`.
|
|
319
|
+
`feedback` also exports `sizes.ts` (`DialogSize`, `ToastPosition`, `BarSize`, `StatsOrientation` and their class tables `dialogSize`, `toastPosition`, `barSize`, `statsOrientation`) and `toaster.ts` (`getToaster`, `setToaster`, `Toast`, `NewToast`, `ToastTone`, `ToasterApi`), plus the prop types below. `colourToken` on a `SegmentBar` segment or a `TimelineEntry` is a CSS custom-property name your theme declares — see `ui`'s `colourTokenStyle`.
|
|
320
|
+
|
|
321
|
+
| Type | What it is |
|
|
322
|
+
| --- | --- |
|
|
323
|
+
| `ConfirmForm` | `ConfirmDialog`'s `form` prop — a progressive-enhancement POST: `{ action, method?: 'POST', fields?: Record<string, string>, enhance?: (form) => void \| (() => void) }`. `fields` become hidden inputs inside the form; `enhance` is your app's own `use:enhance`. |
|
|
324
|
+
| `BarSegment` | One `SegmentBar` part: `{ label, fraction, colourToken?, tone? }`, `fraction` in `[0, 1]`; `tone` applies only when there is no `colourToken`. |
|
|
325
|
+
| `StatDelta` | `StatTile`'s change line: `{ value, unit?: 'count' \| 'percent' \| 'points', label? }`. Positive is up; with `unit: 'percent'`, `3.2` renders as `+3.2%`. |
|
|
326
|
+
| `TimelineEntry` | One `Timeline` entry: `{ id, label, subtitle?, start: TimelineDate, end?: TimelineDate, href?, colourToken? }`. Omit `end` for an open-ended, still-current entry; `href` resolves through the components context. |
|
|
327
|
+
| `TimelineDate` | One end of a span: `{ label, dateTime? }` — the display text and the machine-readable `<time datetime>` value. |
|
|
266
328
|
|
|
267
329
|
```svelte
|
|
268
330
|
<script lang="ts">
|
|
@@ -312,6 +374,7 @@ Form controls wired through the `FormField` + `FieldControl` contract. `FormFiel
|
|
|
312
374
|
```ts
|
|
313
375
|
interface FieldControl {
|
|
314
376
|
id: string;
|
|
377
|
+
labelId: string; // the visible <label>'s id
|
|
315
378
|
describedBy: string;
|
|
316
379
|
invalid: boolean;
|
|
317
380
|
required: boolean;
|
|
@@ -320,27 +383,60 @@ interface FieldControl {
|
|
|
320
383
|
|
|
321
384
|
Every primitive accepts it by spread — `<TextInput {...control} bind:value />` — so `for`/`id`, `aria-describedby`, `aria-invalid` and `required` cannot drift apart. `errors` is always `readonly string[]`; a field that fails three rules says three things.
|
|
322
385
|
|
|
386
|
+
**When a control uses `labelId`.** `<label for>` names only a labelable element (`input`, `select`, `textarea`, `button`, `meter`, `output`, `progress`); every primitive above is one, and ignores `labelId` (it never reaches the DOM as an attribute). A control whose focusable part is a role on a `span` or `div` — a `role="slider"` thumb, a `role="group"` or `radiogroup` root, a segmented date group — points `aria-labelledby={control.labelId}` at the label instead, and `FormField` forwards a click on its label to it (the control itself if focusable, else its first focusable descendant). `Fieldset` hands its `children` a `FieldsetControl` (`{ labelId }`, the `<legend>`'s id) for the same purpose. `RadioGroup` is its own frame with its own `legend`, so it takes neither.
|
|
387
|
+
|
|
323
388
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
324
389
|
| --- | --- | --- | --- | --- |
|
|
325
|
-
| `FormField` | Label + hint + errors around one control | `id?`, `label`, `hint?`, `errors?: readonly string[]`, `required?`, `requiredLabel? = '(required)'` + `HTMLAttributes<HTMLDivElement>` | `children(control)` | `<label for>` names the control; `describedBy` lists hint id then error id; `aria-invalid` when there are errors |
|
|
326
|
-
| `Fieldset` | The same frame for a GROUP | `legend`, `hint?`, `errors?`, `required?`, `requiredLabel?` + `HTMLAttributes<HTMLFieldSetElement>` | `children` | real `<fieldset>`/`<legend>`; hint/errors are `aria-describedby` on the fieldset; `data-invalid` instead of `aria-invalid` (the implicit `group` role rejects it) |
|
|
327
|
-
| `TextInput` | The daisyUI `input` | `value ⚓`, `type? = 'text'` (`text\|email\|url\|password\|tel\|number\|search`), `size?: FieldSize`, `invalid?`, `describedBy?` + `HTMLInputAttributes` | — | native `<input
|
|
390
|
+
| `FormField` | Label + hint + errors around one control | `id?`, `label`, `hint?`, `errors?: readonly string[]`, `required?`, `requiredLabel? = '(required)'` + `HTMLAttributes<HTMLDivElement>` | `children(control)` | `<label for>` names the control (a role-based control uses `aria-labelledby={labelId}`; label clicks are forwarded to it); `describedBy` lists hint id then error id; `aria-invalid` when there are errors |
|
|
391
|
+
| `Fieldset` | The same frame for a GROUP | `legend`, `hint?`, `errors?`, `required?`, `requiredLabel?` + `HTMLAttributes<HTMLFieldSetElement>` | `children({ labelId })` | real `<fieldset>`/`<legend>`; `labelId` is the legend's id; hint/errors are `aria-describedby` on the fieldset; `data-invalid` instead of `aria-invalid` (the implicit `group` role rejects it) |
|
|
392
|
+
| `TextInput` | The daisyUI `input` | `value ⚓`, `type? = 'text'` (`text\|email\|url\|password\|tel\|number\|search`), `size?: FieldSize`, `invalid?`, `describedBy?` + `HTMLInputAttributes` | — | native `<input>`; for a password with a show/hide toggle use `PasswordInput`, and for a number `NumberField` (`type="number"` neither formats nor parses the reader's locale and returns a string) |
|
|
328
393
|
| `Textarea` | The daisyUI `textarea` | `value ⚓`, `rows? = 4`, `size?`, `invalid?`, `describedBy?` | — | native `<textarea>` |
|
|
329
394
|
| `Select` | One choice, natively | `options: (FieldOption \| string)[]`, `value ⚓`, `placeholder?`, `size?`, `invalid?`, `describedBy?` | — | native `<select>`; placeholder is a disabled first `<option>` |
|
|
395
|
+
| `Slider` | A number, or a `[low, high]` range, from a bounded stepped scale | `type? = 'single'` (`'single'` binds `value ⚓: number`, `'multiple'` binds `value ⚓: number[]`, default `[min, max]`), `min? = 0`, `max? = 100`, `step? = 1` (`number \| readonly number[]` snap points), `orientation? = 'horizontal'`, `ticks? = false`, `formatValue?: (v) => string` (default `Intl.NumberFormat(locales)`), `locales?`, `showValue? = true`, `thumbLabels? = ['Minimum', 'Maximum']`, `onCommit?: (value) => void`, `name?`, `disabled?`, `tone?: Tone = 'primary'`, `size?: FieldSize`, `label?`, `labelId?`, `id?`, `describedBy?`, `invalid?` + `HTMLAttributes<HTMLSpanElement>` | — | bits-ui `Slider`: each thumb `role="slider"` with `aria-valuetext` from `formatValue`; arrows step, Page Up/Down jump a tenth, Home/End; named by `aria-labelledby={labelId}` (range thumbs add their own `thumbLabels` entry) or by `label`; one hidden input per thumb under `name` |
|
|
396
|
+
| `Rating` | A score out of N as stars, or a static "4.5 out of 5" display | `value ⚓ = 0`, `max? = 5`, `allowHalf? = false`, `readonly? = false`, `hoverPreview? = true`, `formatValue?: (value, max) => string` (default `` `${value} out of ${max}` ``), `name?`, `disabled?`, `tone?: Tone = 'warning'`, `size?: Size = 'md'`, `label? = 'Rating'`, `labelId?`, `id?`, `describedBy?`, `invalid?`, `required?` + `HTMLAttributes<HTMLDivElement>` | `item?: { state }` (replace the star glyph) | bits-ui `RatingGroup`: root `role="slider"` (one Tab stop) with `aria-valuetext` from `formatValue`; arrows step (half steps with `allowHalf`), Home/End, digits jump; named by `aria-labelledby={labelId}` or `label`; `readonly` is `role="img"` named by `formatValue`, nothing focusable |
|
|
397
|
+
| `PinInput` | A fixed-length code (sign-in code, TOTP, invite) typed one character per cell | `value ⚓`, `length? = 6`, `charset? = 'numeric'` (`'numeric' \| 'alphanumeric'`), `masked? = false`, `onComplete?: (code) => void`, `pasteTransformer? = strip whitespace and hyphens`, `autocomplete? = 'one-time-code'`, `size?: FieldSize`, `disabled?`, `id?`, `describedBy?`, `invalid?` + `HTMLInputAttributes` (on the real input) | — | ONE real `<input>` (bits-ui `PinInput`) named by `FormField`'s `<label for>`, with `inputmode` and `pattern` from `charset`; the cells are `aria-hidden` presentation |
|
|
398
|
+
| `PasswordInput` | A password with a show/hide toggle and the right `autocomplete` | `value ⚓`, `purpose? = 'current'` (`'current' \| 'new'` → `current-password` / `new-password`), `revealed ⚓ = false`, `revealable? = true`, `showLabel? = 'Show password'`, `size?`, `inputClass?`, `id?`, `describedBy?`, `invalid?` + `HTMLInputAttributes` (on the inner input; `class` on the wrapper) | — | native password `<input>` named by `FormField`'s `<label for>`; the toggle is a `type="button"` with a constant name and `aria-pressed`, `aria-controls` the input, keeps the selection; the field hides again when its form submits |
|
|
399
|
+
| `MultiSelect` | Several choices from a longer known list, summarised in a compact trigger | `options: (FieldOption \| string)[]`, `values ⚓: string[]` (kept in option order), `placeholder? = 'Select…'`, `name?`, `maxShown? = 2`, `formatOverflow? = (n) => \`+${n}\``, `portal? = true`, `disabled?`, `size?`, `id?`, `describedBy?`, `invalid?`, `required?`+`HTMLAttributes<HTMLDivElement>` on the wrapper | `option?(option, { selected })` | bits-ui `Select` (`type="multiple"`): trigger `role="combobox"` named by `FormField`'s `<label for>`, `role="listbox"` with `aria-multiselectable`, options with `aria-selected` / `aria-disabled`; stays open while toggling; one hidden input per value under `name` |
|
|
400
|
+
| `NumberField` | An exact number, locale-formatted, with − / + steppers | `value ⚓: number \| undefined`, `min?`, `max?`, `step? = 1`, `largeStep? = step * 10`, `formatOptions?: Intl.NumberFormatOptions` (unit, percent, currency, digits), `locales?`, `showStepper? = true`, `decrementLabel? = 'Decrease'`, `incrementLabel? = 'Increase'`, `onCommit?: (value) => void` (blur, Enter or a step), `name?` (posts the raw number), `size?`, `disabled?`, `id?`, `describedBy?`, `invalid?` + `HTMLInputAttributes` | — | APG spinbutton: `role="spinbutton"` with `aria-valuenow`/`min`/`max` and the formatted `aria-valuetext`; arrows ± `step`, Page Up/Down ± `largeStep`, Home/End to the bounds; − / + are `tabindex="-1"` buttons that `aria-controls` the input, disabled at the bounds, and auto-repeat while held |
|
|
401
|
+
| `TagsInput` | An open-ended list of short strings, each a removable chip | `values ⚓: string[]`, `name?`, `delimiters? = [',']`, `normalise?: (text) => string \| undefined` (default trims, refuses empty), `allowDuplicates? = false`, `max?`, `suggestions?: readonly string[]` **or** `onSearch?: (query, signal) => Promise<readonly string[]>`, `debounceMs? = 150`, `placeholder? = 'Add a tag'`, `removeLabel? = (tag) => \`Remove ${tag}\``, `addedMessage?`, `removedMessage?`, `disabled?`, `size?`, `id?`, `describedBy?`, `invalid?`, `required?`(at least one committed tag),`requiredMessage? = 'Add at least one tag.'`+`HTMLAttributes<HTMLDivElement>` | `tag?(tag)` | `role="list"` of tags with named remove buttons; the text input (a bits-ui combobox when suggestions are on) is named by `FormField`; Enter / delimiter commits, Backspace and arrows reach the tags, a paste is split; focus never lost after removal; adds / removes announced in a polite live region |
|
|
402
|
+
| `Dropzone` | Several files by drop or by a "Choose files" button, listed and removable; never uploads | `files ⚓: File[]`, `accept?` (enforced on drop too), `maxBytes?`, `maxFiles?`, `multiple? = true`, `name?`, `onSelect?(accepted, rejected: FileRejection[])`, `previews? = true`, `promptLabel? = 'Drop files here or'`, `chooseLabel? = 'Choose files'`, `removeLabel?(file)`, `tooLargeMessage?(file)`, `wrongTypeMessage?(file)`, `tooManyMessage?(max)`, `selectedMessage?(count)`, `locales?`, `disabled?`, `id?`, `describedBy?`, `invalid?`, `required?` + `HTMLAttributes<HTMLDivElement>` | `file?(file, { remove })` | the choose `<button>` is named by `FormField`'s `<label for>`; files are a `<ul>` with named remove buttons (focus moves to the neighbour); rejections in one `role="alert"`; a polite live region counts the selection; a hidden native input posts the files |
|
|
403
|
+
| `Calendar` (`/dates`) | An inline month grid for one date | `value ⚓: DateValue`, `month ⚓`, `minValue?`, `maxValue?`, `isDateDisabled?(date)`, `isDateUnavailable?(date)`, `numberOfMonths? = 1`, `weekStartsOn?`, `fixedWeeks?`, `locales?`, `calendarLabel? = 'Calendar'`, `previousLabel? = 'Previous month'`, `nextLabel? = 'Next month'`, `disabled?`, `readonly?`, `id?`, `labelId?`, `describedBy?`, `invalid?` | `day?({ date, selected, unavailable })` | bits-ui `role="application"` named by `labelId` (or `calendarLabel` + month), a `role="grid"` per month named "October 2026", roving tabindex; arrows / Home / End / Page Up / Page Down / Enter; unavailable days struck through and `aria-disabled` |
|
|
404
|
+
| `RangeCalendar` (`/dates`) | An inline month grid for a start–end range | as `Calendar`, with `value ⚓: { start, end }`, `minDays?`, `maxDays?`, `excludeDisabled? = false` | `day?(…)` | `Calendar`'s; the band reads bits-ui's `data-range-*` |
|
|
405
|
+
| `DateField` (`/dates`) | Segmented day / month / year entry | `value ⚓: DateValue`, `minValue?`, `maxValue?`, `granularity? = 'day'` (`'day'\|'hour'\|'minute'\|'second'`), `hourCycle?: 12\|24`, `name?`, `locales?`, `disabled?`, `readonly?`, `size?`, `id?`, `labelId?`, `describedBy?`, `invalid?`, `required?` | — | a `role="group"` named by `labelId`, described by `describedBy`, `aria-invalid` in error; each segment a `role="spinbutton"` named "day, <label>"; digits, ↑/↓, ←/→, Backspace; separators `aria-hidden`; the locale sets the order; `name` posts ISO `YYYY-MM-DD` |
|
|
406
|
+
| `TimeField` (`/dates`) | Segmented hour / minute entry | as `DateField`, with `value ⚓: TimeValue` and `granularity? = 'minute'` (`'hour'\|'minute'\|'second'`) | — | `DateField`'s; AM/PM is a segment only on a 12-hour clock; `name` posts ISO `HH:MM:SS` |
|
|
407
|
+
| `DatePicker` (`/dates`) | A `DateField` plus a calendar popover | as `DateField`, plus `open? ⚓`, `closeOnDateSelect? = true`, `isDateDisabled?`, `isDateUnavailable?`, `weekStartsOn?`, `numberOfMonths? = 1`, `triggerLabel? = 'Choose date'`, `calendarLabel? = 'Calendar'`, `previousLabel?`, `nextLabel?` | — | `DateField`'s group, with an icon-only trigger named by `triggerLabel` (`aria-haspopup="dialog"`, `aria-expanded`) opening a `role="dialog"` named by `calendarLabel` around `Calendar`'s grid; picking a day closes it and returns focus to the trigger |
|
|
408
|
+
| `DateRangePicker` (`/dates`) | Start and end date fields plus a range-calendar popover | as `DatePicker`, with `value ⚓: { start, end }`, `minDays?`, `maxDays?`, `closeOnRangeSelect? = true`, `startName?` / `endName?` (in place of `name`), `startLabel? = 'Start date'`, `endLabel? = 'End date'` | — | one `role="group"` named by `labelId` and described by `describedBy`, holding a group per half named by `startLabel` / `endLabel` (segments read "day, <label> Start date"); the dash is `aria-hidden` |
|
|
330
409
|
| `Checkbox` | One box with its own label | `label`, `checked ⚓`, `size?`, `invalid?`, `describedBy?` + `HTMLInputAttributes` | — | `<label>`-wrapped `<input type="checkbox">` — no `id`/`for` pair needed |
|
|
331
410
|
| `CheckboxGroup` | Several boxes, one question | `legend`, `options`, `values ⚓: string[]`, `name?`, `hint?`, `errors?`, `required?`, `disabled?`, `size?` | — | a `Fieldset` of `Checkbox` |
|
|
332
411
|
| `RadioGroup` | One choice from a short list | `legend`, `options`, `value ⚓`, `orientation? = 'vertical'`, `name?`, `hint?`, `errors?`, `required?`, `disabled?` | — | bits-ui `RadioGroup`: one tab stop, arrows move and select, wrap-around |
|
|
333
|
-
| `Toggle` | A setting that takes effect now | `checked ⚓`, `label`, `description?`, `onChange?: (next) => Promise<boolean> \| boolean`, `size?`, `invalid?`, `describedBy?` | — | `role="switch"` on a native checkbox; `description` is `aria-describedby`, never folded into the name; `aria-busy` while `onChange` is in flight |
|
|
412
|
+
| `Toggle` | A setting that takes effect now | `checked ⚓`, `label`, `description?`, `onChange?: (next) => Promise<boolean> \| boolean`, `size?`, `invalid?`, `describedBy?` | — | `role="switch"` on a native checkbox; `description` is `aria-describedby`, never folded into the name; `aria-busy` while `onChange` is in flight — a switch; for a button that stays pressed, see `ToggleButton` |
|
|
334
413
|
| `PushToggle` | Web Push for THIS browser behind a `Toggle`: reads the subscription on mount, subscribes / unsubscribes through the app's own client | `available: boolean`, `enable: (signal) => Promise<PushEnableResult>` (`{ _tag: 'enabled' \| 'unsupported' \| 'unavailable' \| 'denied' }` — resolves, never throws), `disable: (signal) => Promise<unknown>`, `supported?` / `readSubscribed?` (default: the browser's own checks), `label? = 'Send push notifications to this browser'`, `description?`, `unavailableMessage?`, `unsupportedMessage?`, `deniedMessage?` (English defaults) | — | `Toggle`'s; the refusal message is a polite live region |
|
|
335
414
|
| `DateInput` | An ISO calendar date | `value ⚓` (`YYYY-MM-DD`), `min?`, `max?`, `size?`, `invalid?`, `describedBy?` | — | native `<input type="date">` |
|
|
336
415
|
| `FileInput` | A file, with a size cap | `file ⚓: File \| undefined`, `accept?`, `maxBytes?`, `tooLargeMessage? = 'That file is too large.'`, `onSelect?`, `size?`, `invalid?`, `describedBy?` | — | native file input; an over-size file is cleared and explained in an `alert` |
|
|
337
416
|
| `ImageInput` | `FileInput` + preview + dimension probe | `file ⚓`, `width ⚓`, `height ⚓`, `previewAlt? = 'Preview of the selected image'`, `name?`, `accept?`, `maxBytes?`, `tooLargeMessage?` | — | preview `<img>` named by `previewAlt`; emits hidden `width`/`height` inputs once `name` is set |
|
|
338
417
|
| `VideoInput` | `FileInput` + a progress readout | `file ⚓`, `progress ⚓` (0–100), `progressLabel? = 'Upload progress'`, `onSelect?`, `accept? = 'video/*'`, `maxBytes?`, `tooLargeMessage?` | — | native `<progress>` named by `progressLabel`, shown only while `0 < progress < 100` |
|
|
339
418
|
| `SearchInput` | A search field with clear and spinner | `label`, `value ⚓`, `onInput?`, `onSubmit?`, `onClear?`, `pending?`, `clearLabel? = 'Clear search'`, `size?`, `name? = 'q'`, `placeholder? = 'Search'` | — | composite `<div class="input">`; icon/spinner `aria-hidden`; clear button labelled |
|
|
340
|
-
| `Combobox<T>` | Typeahead over a list or a search | `value ⚓: string \| undefined`, `items?: readonly T[]` **or** `onSearch: (query, signal) => Promise<readonly T[]>`, `debounceMs? = 150`, `getId`, `getLabel`, `getBadge?`, `label`, `placeholder?`, `name?`, `emptyLabel? = 'No matches'`, `pendingLabel? = 'Searching…'`, `errorMessage?`, `id?`, `describedBy?`, `invalid?`, `required?`, `size
|
|
419
|
+
| `Combobox<T>` | Typeahead over a list or a search | `value ⚓: string \| undefined`, `items?: readonly T[]` **or** `onSearch: (query, signal) => Promise<readonly T[]>`, `debounceMs? = 150`, `getId`, `getLabel`, `getBadge?`, `label`, `hideLabel?`, `placeholder?`, `name?`, `postedValue? = 'id'` (`'id' \| 'query'`), `initialQuery?`, `onQueryChange?: (query) => void`, `onSelect?: (item) => void`, `emptyLabel? = 'No matches'`, `pendingLabel? = 'Searching…'`, `errorMessage?`, `id?`, `describedBy?`, `invalid?`, `required?`, `size?`, `inputClass?`, `portal? = true` + `HTMLAttributes<HTMLDivElement>` on the root | `item?(item)`, `footer?({ query, pending })`, `empty?({ query })` | bits-ui listbox ARIA; Enter with nothing highlighted lets a wrapping `<form>` submit |
|
|
341
420
|
| `FieldErrors` | The messages under one control | `errors: readonly string[]`, `id?` + `HTMLAttributes<HTMLDivElement>` | — | ONE `role="alert"` region holding one `<p>` per message; renders nothing while empty |
|
|
342
421
|
|
|
343
|
-
|
|
422
|
+
Which date control: `DateInput` (the native control, in the barrel) for a single date on a form; `DateField` / `TimeField` from `@thomasfosterau/components/dates` when the design needs the same control on every browser, or a time with a fixed 12/24-hour clock; `DatePicker` when the reader benefits from seeing the month but the field should stay compact, `Calendar` when the month should always show; `DateRangePicker` / `RangeCalendar` for a span. Their values are `DateValue`s: `parseDate('2026-10-23')` in, `value.toString()` out.
|
|
423
|
+
|
|
424
|
+
Which multi-choice control: `CheckboxGroup` when every option fits on screen (about seven or fewer), `MultiSelect` for a longer list, `Select` for a single native choice.
|
|
425
|
+
|
|
426
|
+
`Combobox` posts the selected id under `name` (a hidden input) by default. `postedValue="query"` makes it a search box instead: `name` goes on the visible input, so a plain `<form method="GET">` posts the typed text with JavaScript on or off, and Enter submits that form unless the reader moved to a suggestion with the arrow keys (clicking a suggestion still commits it, and `onSelect` receives it). `hideLabel` keeps `label` as the accessible name but renders it `sr-only`; `portal={false}` renders the listbox inside the component rather than in `<body>`, still floating.
|
|
427
|
+
|
|
428
|
+
```svelte
|
|
429
|
+
<form method="GET" action="/search" class="join">
|
|
430
|
+
<Combobox
|
|
431
|
+
label="Search" hideLabel name="query" postedValue="query" initialQuery={currentQuery}
|
|
432
|
+
onSearch={suggest} getId={(hit) => hit.id} getLabel={(hit) => hit.title}
|
|
433
|
+
inputClass="join-item" onSelect={(hit) => navigate(hit.href)}
|
|
434
|
+
/>
|
|
435
|
+
<button type="submit" class="btn join-item">Search</button>
|
|
436
|
+
</form>
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`fields` also exports `field-types.ts` (`FieldControl`, `FieldsetControl`, `FieldSize`, `FieldOption`, `FieldOptionInput`, `toFieldOptions`) and the daisyUI size tables (`inputSize`, `textareaSize`, `selectSize`, `checkboxSize`, `toggleSize`, `fileInputSize`, `rangeSize`, `pinCellSize`, `inputButtonSize`, each a `Record<FieldSize, string>`), and from `PushToggle` the `PushEnableResult` type (`{ _tag: 'enabled' | PushDeclineReason }`) and `PushDeclineReason` (`'unsupported' | 'unavailable' | 'denied'` — why `enable` did not subscribe, each with its own message prop).
|
|
344
440
|
|
|
345
441
|
```svelte
|
|
346
442
|
<script lang="ts">
|
|
@@ -404,24 +500,28 @@ The tables, lists and pagers an index page and a detail page are built from. Two
|
|
|
404
500
|
|
|
405
501
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
406
502
|
| --- | --- | --- | --- | --- |
|
|
407
|
-
| `Table<T>` | Column-driven `table` over a window the caller chose | `rows`, `columns: TableColumn<T>[]`, `rowKey`, `caption?`, `captionHidden?`, `size?: Size`, `zebra?`, `pinRows?`, `sort? ⚓: SortState`, `onSort?`, `onSelect?(row)`, `emptyMessage? = 'Nothing to show.'`, `sortLabel?(column)`, `containerClass?` + `HTMLTableAttributes` | `emptyState?`, `foot?` | `<caption>` is the accessible name (`sr-only` when `captionHidden`); `<th scope="col">`; sortable header is a real `<button>`, `<th>` carries `aria-sort`; `onSelect` rows take `role="button"` + `tabindex="0"` + Enter/Space |
|
|
503
|
+
| `Table<T>` | Column-driven `table` over a window the caller chose | `rows`, `columns: TableColumn<T>[]`, `rowKey`, `caption?`, `captionHidden?`, `size?: Size`, `zebra?`, `pinRows?`, `sort? ⚓: SortState`, `onSort?`, `onSelect?(row)`, `emptyMessage? = 'Nothing to show.'`, `sortLabel?(column)`, `containerClass?` + `HTMLTableAttributes` | `emptyState?`, `foot?` | `<caption>` is the accessible name (`sr-only` when `captionHidden`); `<th scope="col">`; a `rowHeader` column's body cells are `<th scope="row">`; sortable header is a real `<button>`, `<th>` carries `aria-sort`; `onSelect` rows take `role="button"` + `tabindex="0"` + Enter/Space |
|
|
408
504
|
| `DataGrid<T>` | Client-side sort + filter + page over a bounded set | as `Table` plus `filterable?`, `paginated? = true`, `pageSize? = 10`, `pageSizeOptions?`, `filterLabel? = 'Search'`, `filterPlaceholder?`, `rowsPerPageLabel?`, `showingLabel?(start,end,total)`, `pageOfLabel?(page,pages)`, `previousLabel?`, `nextLabel?`, `paginationLabel?`, `locales?` | `emptyState?` | the `Table`'s and the `Pagination`'s combined |
|
|
409
|
-
| `
|
|
505
|
+
| `MessageThread` | A conversation of `ChatBubble`s | `label`, `live? = false` | `children` | a `<section>` region named by `label` around an `<ol>`; `live` makes it `role="log"` (`aria-live="polite"`) for a thread that grows while it is read |
|
|
506
|
+
| `Pagination` | The one pager: numbered window or prev/next, links or callbacks | `offset`, `limit`, `total`, `variant?: 'window'\|'prevNext'`, `href?(offset)` **or** `onPage?(offset)`, `siblings? = 1`, `label? = 'Pagination'`, `previousLabel?`, `nextLabel?`, `pageLabel?(page)`, `formatPage?(page)` (visible digits; bare by default), `size?: Size`, `showRange?`, `rangeLabel?(start,end,total)`, `statusLabel?(page,pages)`, `locales?` | — | `<nav aria-label>`; `rel="prev"`/`rel="next"`; unavailable arm is an `aria-disabled` `<span>`, never a dead link |
|
|
410
507
|
| `ResultCount` | "Showing 24 of 4,310" under a windowed list | `shown`, `total`, `label?(shown,total)`, `locales?` | — | plain `<p>`, deliberately not a live region |
|
|
411
|
-
| `PaginationFooter` | `ResultCount` + `Pagination` from one set of numbers | `shown`, `offset`, `limit`, `total`, `href?`/`onPage?`, `variant?`, `siblings?`, `size?`, `label?`, `previousLabel?`, `nextLabel?`, `pageLabel?`, `countLabel?`, `locales?` | — | its two parts' |
|
|
508
|
+
| `PaginationFooter` | `ResultCount` + `Pagination` from one set of numbers | `shown`, `offset`, `limit`, `total`, `href?`/`onPage?`, `variant?`, `siblings?`, `size?`, `label?`, `previousLabel?`, `nextLabel?`, `pageLabel?`, `formatPage?`, `countLabel?`, `locales?` | — | its two parts' |
|
|
412
509
|
| `ItemList<T>` | The list container, keying and empty state | `items`, `variant?: 'compact'\|'stacked'\|'grid'\|'cards'`, `getKey(item,index)`, `emptyMessage? = 'Nothing here yet.'`, `feed?`, `label?`, `busy?` | `item(item, index)`, `empty?` | default: `<ul>`/`<li>`; `feed`: `<div role="feed" aria-label aria-busy>` of `<article aria-posinset aria-setsize tabindex="-1">` |
|
|
413
510
|
| `LinkListItem` | The common row body | `href`, `title`, `subtitle?`, `badges?`, `headingLevel?` | `meta?` | the title is the row's only interactive element |
|
|
414
|
-
| `
|
|
511
|
+
| `ChatBubble` | One attributed, timed message | `author`, `sentAt?`, `side? = 'start'` (`'end'` for the viewer's own), `tone?`, `relative? = false`, `locales?` | `children`, `avatar?`, `footer?` | an `<li>` holding an `<article>` named by its header ("Ada Lovelace, 3 minutes ago"); the time is a `<time datetime>` (`DateTime` or `RelativeTime`); the avatar is `aria-hidden`; `side` and `tone` are visual only |
|
|
512
|
+
| `DescriptionList` | `<dl>` of label/value pairs in four shapes, or `bare` in the caller's own layout | `variant?: 'grid'\|'card'\|'divided'\|'inline'\|'bare'`, `mono?`, `showEmpty?`, `emptyLabel? = 'unrecorded'` | `children` | a real `<dl>`; in `card` and `bare` every `<dt>`/`<dd>` is a direct child of it, and `bare` adds no classes beyond the caller's `class` |
|
|
415
513
|
| `DescriptionRow` | One pair inside it | `label`, `value?` **or** `children`, `valueClass?`, `labelClass?`, `showEmpty?`, `emptyLabel?` | `children?` | `<dt>`/`<dd>`; a row with nothing renders **nothing** (or `emptyLabel` when `showEmpty`) |
|
|
416
514
|
| `TreeList` | One collapsible node of a hierarchy | `open? ⚓ = true`, `title: string \| Snippet` | `title?`, `children?` | native `<details>`/`<summary>` inside `<ul>`/`<li>`; deliberately **not** `role="tree"` |
|
|
417
515
|
| `TreeListItem` | One leaf under it | — | `children?` | a plain `<li>` |
|
|
418
516
|
| `DiffText` | A word- or character-level diff inline | `segments: DiffSegment[]`, `separator? = ' '`, `insertLabel?`, `deleteLabel?` | — | `<ins>`/`<del>` carry the meaning; colour and strike-through are the second copy, never the only one |
|
|
419
517
|
|
|
420
|
-
`data` also exports `pageWindow`, `pageHref`, `nextSortState`, `columnAriaSort`, and the types `TableColumn`, `SortState`, `LinkListItemData`, `DiffSegment`. `pageHref(base, page, names?)` writes the position into `base`'s query, preserving every other parameter and the hash (JSON:API's `page[offset]`/`page[limit]` by default) — the callback shape a filtered list's pager needs so its own filter survives.
|
|
518
|
+
`data` also exports `pageWindow`, `pageHref`, `nextSortState`, `columnAriaSort`, and the types `TableColumn`, `SortState`, `LinkListItemData`, `DiffSegment`. `pageHref(base, page, names?)` writes the position into `base`'s query, preserving every other parameter and the hash (JSON:API's `page[offset]`/`page[limit]` by default, exported as `PAGE_OFFSET_PARAM` / `PAGE_LIMIT_PARAM`; `names: PageParamNames` — `{ offset?, limit? }` — renames them) — the callback shape a filtered list's pager needs so its own filter survives. `pageWindow(page, total, siblings = 1): PageWindow` is the arithmetic `Pagination` draws: `{ page, pageCount, from, to, previous, next, items }`, page numbers 1-based, `previous` / `next` the neighbouring offsets (`undefined` at either end), and `items` a `readonly PageWindowItem[]` of `{ kind: 'page', page, offset, current }` and `{ kind: 'gap', after }` entries.
|
|
519
|
+
|
|
520
|
+
`DescriptionList` hands its presentation to its rows through context: `DescriptionListStyle` is `{ variant, mono, showEmpty, emptyLabel }`, `setDescriptionListStyle(() => style)` / `getDescriptionListStyle()` set and read it, and `defaultDescriptionListStyle` (`grid`, not mono, empty rows hidden, `'unrecorded'`) is what a `DescriptionRow` uses outside any list.
|
|
421
521
|
|
|
422
522
|
```svelte
|
|
423
523
|
<script lang="ts">
|
|
424
|
-
import { Table, DataGrid, PaginationFooter, pageHref, ItemList, LinkListItem, DescriptionList, DescriptionRow, type SortState, type TableColumn } from '@thomasfosterau/components';
|
|
524
|
+
import { Table, DataGrid, PaginationFooter, pageHref, ItemList, LinkListItem, DescriptionList, DescriptionRow, ExternalLink, type SortState, type TableColumn } from '@thomasfosterau/components';
|
|
425
525
|
|
|
426
526
|
let sort = $state<SortState | undefined>({ key: 'name', direction: 'asc' });
|
|
427
527
|
const columns: readonly TableColumn<Plant>[] = [
|
|
@@ -447,6 +547,12 @@ The tables, lists and pagers an index page and a detail page are built from. Two
|
|
|
447
547
|
<!-- No licence recorded? The row is simply not there. -->
|
|
448
548
|
<DescriptionRow label="Licence">{#if media.licence}<Link href={media.licence.href}>{media.licence.name}</Link>{/if}</DescriptionRow>
|
|
449
549
|
</DescriptionList>
|
|
550
|
+
|
|
551
|
+
<!-- `bare`: the page owns the grid; rows and its own pairs share the two columns. -->
|
|
552
|
+
<DescriptionList variant="bare" class="grid grid-cols-[auto_1fr] gap-x-4">
|
|
553
|
+
<dt>Name</dt><dd>{organisation.name}</dd>
|
|
554
|
+
<DescriptionRow label="Website"><ExternalLink href={organisation.website}>{organisation.website}</ExternalLink></DescriptionRow>
|
|
555
|
+
</DescriptionList>
|
|
450
556
|
```
|
|
451
557
|
|
|
452
558
|
## infobox
|
|
@@ -455,13 +561,23 @@ One family, two ways to fill it. Composed primitives (`InfoboxHeader`, `InfoboxI
|
|
|
455
561
|
|
|
456
562
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
457
563
|
| --- | --- | --- | --- | --- |
|
|
458
|
-
| `Infobox` | The summary rail frame | `title?`, `subtitle?`, `label='Key facts'`, `image?: InfoboxImageSpec`, `rows?: readonly InfoboxRowSpec[]`, `float?` + `HTMLAttributes<HTMLElement>` | `children?` (extra composed markup, rendered after `rows`) | `<aside aria-label={title ?? label}>` |
|
|
459
|
-
| `InfoboxHeader` | The title block | `title`, `subtitle
|
|
564
|
+
| `Infobox` | The summary rail frame | `title?`, `subtitle?`, `headingLevel? = 3` (`2`–`6`, forwarded to `InfoboxHeader`), `label='Key facts'`, `image?: InfoboxImageSpec`, `rows?: readonly InfoboxRowSpec[]`, `float?` + `HTMLAttributes<HTMLElement>` | `children?` (extra composed markup, rendered after `rows`) | `<aside aria-label={title ?? label}>` |
|
|
565
|
+
| `InfoboxHeader` | The title block | `title`, `subtitle?`, `headingLevel? = 3` (`2`–`6`) + `HTMLAttributes<HTMLDivElement>` | — | `<h3>` by default, deliberately outside the document outline; raise `headingLevel` to the page's section level when the box is a section in its own right |
|
|
460
566
|
| `InfoboxImage` | The identifying picture | `src`, `alt`, `caption?`, `width?`, `height?`, `eager?` | — | `<figure>`/`<img>`/`<figcaption>`; default lazy |
|
|
461
567
|
| `InfoboxSection` | A titled group of rows | `title?` + `HTMLAttributes<HTMLDListElement>` | `children` (the rows) | owns the `<dl>` |
|
|
462
568
|
| `InfoboxRow` | One label/value pair | `label`, `value?: string \| number`, `href?` (through the components context), `external?`, `emptyLabel='unrecorded'`, `showEmpty?` + `HTMLAttributes<HTMLDivElement>` | `children?` (wins over `value`) | `<dt>`/`<dd>` pair; **empty by default** — `showEmpty` opts into an em dash captioned by `aria-label={emptyLabel}` |
|
|
463
569
|
|
|
464
|
-
`InfoboxRowSpec` is a discriminated union
|
|
570
|
+
`InfoboxRowSpec` is a discriminated union on `kind`, and each member is exported on its own:
|
|
571
|
+
|
|
572
|
+
| Type | Shape | Skipped when |
|
|
573
|
+
| --- | --- | --- |
|
|
574
|
+
| `InfoboxFactRow` | `{ kind: 'fact', label, value: string \| number \| undefined }` — one identifying fact | `value` is `undefined` or `''` |
|
|
575
|
+
| `InfoboxLinkRow` | `{ kind: 'link', label, text, href: string \| undefined, external? }` — one named link with its own visible text | `href` is `undefined` or `''` |
|
|
576
|
+
| `InfoboxLinksRow` | `{ kind: 'links', label, links: readonly InfoboxLinkEntry[] \| undefined }` — a relationship rendered as several links (`InfoboxLinkEntry` is `{ label, href }`) | `links` is `undefined` or empty |
|
|
577
|
+
| `InfoboxImageRow` | `{ kind: 'image', ...InfoboxImageSpec }` — the identifying picture, rendered above the `<dl>` as an `InfoboxImage` | `src` is `''` |
|
|
578
|
+
| `InfoboxCustomRow` | `{ kind: 'custom', label, content: Snippet<[]> }` — arbitrary markup as the value | never |
|
|
579
|
+
|
|
580
|
+
`InfoboxImageSpec` (`{ src, alt, caption?, width?, height?, eager? }`) is also what `Infobox`'s `image` prop takes. `infoboxRowIsPresent(row): boolean` is the "skipped when" column as a function — the one definition of an empty row that `Infobox` filters `rows` with.
|
|
465
581
|
|
|
466
582
|
```svelte
|
|
467
583
|
<script lang="ts">
|
|
@@ -481,7 +597,7 @@ One family, two ways to fill it. Composed primitives (`InfoboxHeader`, `InfoboxI
|
|
|
481
597
|
<!-- Typed rows — same primitives, one array. -->
|
|
482
598
|
<script lang="ts">
|
|
483
599
|
const rows: InfoboxRowSpec[] = [
|
|
484
|
-
{ kind: 'image', src: taxon.
|
|
600
|
+
{ kind: 'image', src: taxon.imageUrl, alt: taxon.name, eager: true },
|
|
485
601
|
{ kind: 'fact', label: 'Family', value: taxon.family },
|
|
486
602
|
{ kind: 'links', label: 'Synonyms', links: taxon.synonyms.map((s) => ({ label: s.name, href: s.href })) },
|
|
487
603
|
];
|
|
@@ -496,11 +612,13 @@ Locale-aware rendering of dates, numbers, lists, relative time and search-result
|
|
|
496
612
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
497
613
|
| --- | --- | --- | --- | --- |
|
|
498
614
|
| `DateTime` | `<time>` with an ISO `datetime` and `Intl.DateTimeFormat` text | `value: Date \| number \| string`, `locales?`, `dateStyle? = 'medium'`, `timeStyle?`, `options?: Intl.DateTimeFormatOptions` (wins over the styles) | `children?` — replaces the text; `datetime` stays machine-readable | native `<time>` semantics only |
|
|
615
|
+
| `Duration` | A length of time: "2 hr, 30 min" | `value: DurationValue \| number` (ms), `style? = 'short'` (`'long'\|'short'\|'narrow'\|'digital'`), `locales?` | — | `<time datetime="PT2H30M">`; `Intl.DurationFormat`, with a localised `NumberFormat` + `ListFormat` fallback; `formatDuration`, `durationFromMilliseconds` and `toIsoDuration` are exported for plain TypeScript |
|
|
499
616
|
| `NumberFormat` (also exported as `Number`; `NumberFormat` is primary, since a `Number` export would shadow the global) | `<data>` with a raw `value` attribute and `Intl.NumberFormat` text | `value: number \| bigint`, `locales?`, `style?`, `unit?`, `unitDisplay?`, `currency?`, `currencyDisplay?`, `maximumFractionDigits?`, `minimumFractionDigits?`, `signDisplay?`, `notation?`, `options?: Intl.NumberFormatOptions` (wins) | — | native `<data>` semantics only |
|
|
500
617
|
| `FormatList<T>` | Renders items through `Intl.ListFormat.formatToParts`, so conjunction/disjunction text ("and", ", ") comes from the runtime | `items: readonly T[]`, `style? = 'long'`, `type? = 'conjunction'`, `locales?` | `children(item: T)` | no wrapper element |
|
|
618
|
+
| `Countdown` | Time left until an instant, ticking every second | `until`, `label`, `largestUnit? = 'days'` (`'days'\|'hours'\|'minutes'`), `display? = 'digits'` (`'digits'\|'text'`), `completeLabel? = 'Time is up'`, `onComplete?`, `locales?` | — | `role="timer"` named by `label`; the ticking digits are `aria-hidden` and the spoken text is coarse (whole minutes under an hour, whole hours above); a `role="status"` announces `completeLabel` once at zero; no digit animation under reduced motion; an `until` already past is complete without calling `onComplete` |
|
|
501
619
|
| `RelativeTime` | "3 hours ago" / "in 2 days" via `Intl.RelativeTimeFormat`, picking the largest unit whose magnitude rounds to ≥ 1 | `value: Date \| number \| string`, `now?` (pin it in tests), `locales?`, `numeric? = 'auto'`, `style? = 'long'` | — | `<time datetime=ISO title=absolute>` — the absolute instant is never hidden |
|
|
502
620
|
| `Year` | `<time>` with just a year — `datetime="1925"` — and its `Intl.DateTimeFormat` rendering | `value: Date \| number \| string` (a number is a calendar year; an instant contributes its UTC year), `locales?`, `era?`, `representation? = 'numeric'` (`'2-digit'`) | — | native `<time>`; `datetime` is a valid HTML year string (ISO instant before 1 CE) |
|
|
503
|
-
| `Highlight` (+ exported `tokenise
|
|
621
|
+
| `Highlight` (+ exported `tokenise(text, query): { text, match }[]`, the segmentation it renders) | Best-effort client-side token highlight for search results | `text: string`, `query: string` | — | `<mark>` per matched token; naive — a UX nicety, not the authoritative FTS match |
|
|
504
622
|
|
|
505
623
|
```svelte
|
|
506
624
|
<DateTime value={post.publishedAt} dateStyle="long" />
|
|
@@ -539,24 +657,44 @@ Locale-aware rendering of dates, numbers, lists, relative time and search-result
|
|
|
539
657
|
<Figure caption="A row of tomato seedlings"><img src={photo.src} alt="Tomato seedlings" /></Figure>
|
|
540
658
|
```
|
|
541
659
|
|
|
542
|
-
`imageDimensions(file: Blob): Promise<{ width, height }
|
|
660
|
+
`imageDimensions(file: Blob): Promise<ImageDimensions>` (`ImageDimensions` is `{ width, height }`, in pixels) reads an image file's intrinsic pixel size with `createImageBitmap`, in the browser, so an upload form can submit `width` / `height` with the binary (it is what `ImageInput` measures with). It rejects with `ImageDimensionsUnavailable` where there is no `createImageBitmap` (on a server), and with the browser's own error for a file that does not decode.
|
|
543
661
|
|
|
544
662
|
## hotkeys
|
|
545
663
|
|
|
546
664
|
One registry answers "what can the viewer do right now"; `HotkeyProvider` matches keystrokes against it, `HotkeyHelpDialog` lists what carries a trigger, and `search`'s `CommandPalette` lists what is palette-visible. An app creates the registry in its root layout, renders `HotkeyProvider` once, and registers actions from anywhere below — global ones from the layout, page ones from the page, torn down on unmount.
|
|
547
665
|
|
|
666
|
+
The family is also its own entry, `@thomasfosterau/components/hotkeys`. Import `HotkeyHelpDialog` from it dynamically (`const { HotkeyHelpDialog } = await import('@thomasfosterau/components/hotkeys')`) to keep the dialog out of your entry chunk until the first `?`.
|
|
667
|
+
|
|
548
668
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
549
669
|
| --- | --- | --- | --- | --- |
|
|
550
670
|
| `HotkeyProvider` | The single global `keydown` listener: chords, sequences, the editable guard | — (reads the registry from context) | — | renders nothing. Its obligation is negative — it never steals a keystroke from a control that needs one. While focus is in an editable target, or while `paletteOpen`/`helpOpen` is set, only `allowInEditable` actions fire |
|
|
551
671
|
| `HotkeyBadge` | A trigger as `kbd` chips | `trigger: HotkeyTrigger`, `commandLabel? = '⌘'`, `controlLabel? = 'Ctrl'`, `thenLabel? = 'then'` + `HTMLAttributes<HTMLSpanElement>` | — | `<kbd>` per key; `$mod` renders as `controlLabel` on the server and resolves per platform after mount |
|
|
552
|
-
| `HotkeyHelpDialog` | The
|
|
672
|
+
| `HotkeyHelpDialog` | The overlay listing every live hotkey, opened by an action you register (conventionally `?`) | `title? = 'Keyboard shortcuts'`, `closeLabel? = 'Close'`, `emptyLabel? = 'No keyboard shortcuts are available on this page.'`, `groupLabels? = { page: 'This page', global: 'Everywhere' }`, `commandLabel?`, `controlLabel?`, `thenLabel?` | — | this library's `Dialog`; each scope is a `<section>` with a heading over a `<ul>`. Opened/closed through the registry's `helpOpen` |
|
|
553
673
|
|
|
554
674
|
| Export | What it is |
|
|
555
675
|
| --- | --- |
|
|
556
|
-
| `createHotkeyRegistry(
|
|
557
|
-
| `
|
|
558
|
-
| `
|
|
559
|
-
| `
|
|
676
|
+
| `createHotkeyRegistry(options: HotkeyRegistryOptions): HotkeyRegistry` | Creates the registry and puts it in context. Call once, from the root layout, during component initialisation. |
|
|
677
|
+
| `HotkeyRegistryOptions` | `{ isAdmin: () => boolean, dev?: boolean }`. `isAdmin` is a function, so the gate is reactive; with `dev` set, registering a trigger that shadows an existing one logs a console warning (wire it to your bundler's dev flag). |
|
|
678
|
+
| `getHotkeyRegistry()` / `tryGetHotkeyRegistry()` | Read it back; the first throws `HotkeyRegistryMissing` (an `Error` subclass) when no registry was created above, the second returns `undefined` instead — the seam `CommandPalette` uses to work with no registry. |
|
|
679
|
+
| `registerHotkeys(() => actions, { scope? })` | Declarative registration for the calling component's lifetime; `scope` defaults to `'page'`. The thunk is reactive: when what it reads changes, the batch is replaced. |
|
|
680
|
+
| `HotkeyRegistry` | The class `createHotkeyRegistry` returns. `paletteOpen` and `helpOpen` are writable `$state` flags (what a palette or help action toggles); `active` (role-filtered, match-precedence order: page before global, later registrations first), `paletteActions` (palette-visible, registration order) and `hotkeys` (those with a trigger, registration order) are read-only `RegisteredAction[]`s; `register(scope, actions)` returns its unregister function. An action with `requiresAdmin` is absent from all three for a viewer `isAdmin()` rejects. |
|
|
681
|
+
| `CommandAction` | One registered action: `{ id, label, group: CommandActionGroup, run: () => void \| Promise<void>, trigger?: HotkeyTrigger, requiresAdmin?, paletteVisible?, allowInEditable? }`. No `trigger` makes it palette-only; `paletteVisible: false` makes it hotkey-only; `allowInEditable` lets it fire while focus is in an input (reserve it for the palette opener). |
|
|
682
|
+
| `CommandActionGroup` | `'actions' \| 'navigation'` — the palette section an action is listed under. |
|
|
683
|
+
| `HotkeyScope` | `'global' \| 'page'`. Page registrations are torn down with their page and win trigger conflicts over global ones. |
|
|
684
|
+
| `RegisteredAction` | `{ scope, action }` — what the registry's read surfaces return. |
|
|
685
|
+
| `HotkeyTrigger` | `HotkeyChord \| HotkeySequence`. A **chord** (`HotkeyChord`, a string such as `'$mod+k'`) is `+`-separated modifiers (`$mod`, `alt`, `shift`) and a key held together, `$mod` being ⌘ on Apple platforms and Ctrl elsewhere; a **sequence** (`HotkeySequence`, `readonly string[]` such as `['g', 'p']`) is plain keys pressed in order, buffered for `SEQUENCE_TIMEOUT_MS` (1000). A string without `+` (`'e'`, `'?'`) is a one-key sequence. Shift is never written for a shifted character: `'?'` already arrives as `?`. |
|
|
686
|
+
|
|
687
|
+
The matching rules `HotkeyProvider` runs are exported as pure functions, for a caller building its own listener or testing its triggers:
|
|
688
|
+
|
|
689
|
+
| Function | What it does |
|
|
690
|
+
| --- | --- |
|
|
691
|
+
| `matchChord(event, actions): CommandAction \| undefined` | The first action whose chord trigger matches the event. `actions` must already be in precedence order (`registry.active` is). The other platform's modifier must not be held, so Ctrl+K on a Mac does not fire `$mod+k`. |
|
|
692
|
+
| `matchSequence(buffer, actions): SequenceMatch` | Matches a buffer of keys against every plain-key trigger. `SequenceMatch` is `{ exact: CommandAction \| undefined, isPrefix: boolean }` — the first action the buffer completes, and whether the buffer could still grow into a longer sequence. |
|
|
693
|
+
| `isSequenceKey(event): boolean` | Whether the key can join a sequence: one printable character, no ⌘ / Ctrl / Alt. |
|
|
694
|
+
| `hasChordModifier(event): boolean` | Whether ⌘, Ctrl or Alt is held. |
|
|
695
|
+
| `isEditableTarget(target): boolean` | Whether the target is an `input`, `textarea`, `select` or contenteditable element — where only `allowInEditable` actions fire. |
|
|
696
|
+
| `isApplePlatform(): boolean` | Whether `$mod` means ⌘ here (`navigator.platform` is a Mac or iOS device); `false` where there is no `navigator`. |
|
|
697
|
+
| `triggerDisplay(trigger): TriggerDisplay` | The trigger as `HotkeyBadge` renders it: `TriggerDisplay` is `{ keys: readonly string[], sequential: boolean }`, keys upper-cased except `'$mod'`, which is left for the renderer to resolve per platform. |
|
|
560
698
|
|
|
561
699
|
```svelte
|
|
562
700
|
<!-- Root layout, once. -->
|
|
@@ -565,7 +703,8 @@ One registry answers "what can the viewer do right now"; `HotkeyProvider` matche
|
|
|
565
703
|
|
|
566
704
|
const hotkeys = createHotkeyRegistry({ isAdmin: () => user?.isAdmin === true, dev });
|
|
567
705
|
registerHotkeys(() => [
|
|
568
|
-
{ id: 'palette.toggle', label: 'Open the command palette', group: 'actions', trigger: '$mod+k', allowInEditable: true, paletteVisible: false, run: () =>
|
|
706
|
+
{ id: 'palette.toggle', label: 'Open the command palette', group: 'actions', trigger: '$mod+k', allowInEditable: true, paletteVisible: false, run: () => { hotkeys.paletteOpen = !hotkeys.paletteOpen; } },
|
|
707
|
+
{ id: 'help.toggle', label: 'Show keyboard shortcuts', group: 'actions', trigger: '?', run: () => { hotkeys.helpOpen = !hotkeys.helpOpen; } },
|
|
569
708
|
{ id: 'nav.plants', label: 'Go to plants', group: 'navigation', trigger: ['g', 'p'], run: () => goto('/plants') },
|
|
570
709
|
], { scope: 'global' });
|
|
571
710
|
</script>
|
|
@@ -589,9 +728,9 @@ One component, because a palette is one thing: a dialog, a query box, and rows f
|
|
|
589
728
|
|
|
590
729
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
591
730
|
| --- | --- | --- | --- | --- |
|
|
592
|
-
| `CommandPalette` | The ⌘K palette: actions, navigation and search results in one list | `open? ⚓` (two-way bound to the registry's `paletteOpen` when a registry exists), `label? = 'Command palette'`, `placeholder? = 'Type a command or search…'`, `loadingLabel? = 'Searching…'`, `emptyLabel? = 'No results'`, `closeLabel? = 'Close'`, `groups
|
|
731
|
+
| `CommandPalette` | The ⌘K palette: actions, navigation and search results in one list | `open? ⚓` (two-way bound to the registry's `paletteOpen` when a registry exists), `label? = 'Command palette'`, `placeholder? = 'Type a command or search…'`, `loadingLabel? = 'Searching…'`, `emptyLabel? = 'No results'`, `closeLabel? = 'Close'`, `groups? = defaultCommandGroups` (`readonly CommandGroup[]`), `items?: readonly CommandItem[]`, `onSearch?: (query, signal) => Promise<readonly CommandItem[]>`, `debounceMs? = 150`, `onSelect?: (item) => void` | `item?(item)` | `Dialog`'s plus `Command`'s: the input is a `combobox` owning a `listbox`, each row an `option`, `aria-activedescendant` follows the arrow keys. The pending row is a `progressbar` named by `loadingLabel` |
|
|
593
732
|
|
|
594
|
-
`onSearch` follows the same debounce / latest-wins (`AbortController`) / stale-while-revalidate rules as `fields/Combobox`. `CommandItem` is `{ id, label, group, hint?, badge?, run?, href? }`; activation closes the palette, calls `onSelect`, then `run`, then navigates to `href` through the components context.
|
|
733
|
+
`onSearch` follows the same debounce / latest-wins (`AbortController`) / stale-while-revalidate rules as `fields/Combobox`. `CommandItem` is `{ id, label, group, hint?, badge?, run?, href? }`; activation closes the palette, calls `onSelect`, then `run`, then navigates to `href` through the components context. A row's `group` names a `CommandGroup` — `{ id, label }`, a section and its heading — and `groups` sets which sections render, in order; a row whose `group` is not listed there is not shown. `defaultCommandGroups` is `[{ id: 'actions', label: 'Actions' }, { id: 'navigation', label: 'Go to' }, { id: 'results', label: 'Results' }]`: the registry's two `CommandActionGroup`s, then a `results` section for `onSearch` rows. Pass your own array to rename or translate the headings.
|
|
595
734
|
|
|
596
735
|
```svelte
|
|
597
736
|
<!-- With a hotkey registry mounted, no props are needed at all. -->
|
|
@@ -637,7 +776,7 @@ One component, because a palette is one thing: a dialog, a query box, and rows f
|
|
|
637
776
|
|
|
638
777
|
**The CSL-type records are local.** `BookRecord`, `ChapterRecord`, `ScholarlyArticleRecord`, `ThesisRecord`, `ReportRecord`, `PublisherRecord` and `CitationDate` are defined in this package for now, because the bibliographic model they will align with, `@thomasfosterau/biblio`, is not published yet. Contributors are resolved `Credit`s in credit order, not linkage. An app maps its resources onto these shapes at its own seam. The JSON:API `included` resolver belongs to effect-jsonapi's client helpers, not here.
|
|
639
778
|
|
|
640
|
-
`formatCitation`, `BibliographyManager`, `setupBibliography`, `getBibliographyContext`, and the `CitationData`/`CitationKind`/`FootnoteData`/`Credit` types are also exported. (`Citation` and `Footnote` name the components; the data shapes are `CitationData`/`FootnoteData` to avoid a value/type collision.)
|
|
779
|
+
`formatCitation`, `BibliographyManager`, `setupBibliography`, `getBibliographyContext`, and the `CitationData`/`CitationKind`/`FootnoteData`/`Credit` types are also exported. (`Citation` and `Footnote` name the components; the data shapes are `CitationData`/`FootnoteData` to avoid a value/type collision.) `BibliographyManager.citations()` returns `readonly BibliographyEntry[]` — `{ citation: CitationData, order }`, one per distinct source in first-cited order, `order` being the number its markers show. `CreditedRecord` (`{ authors?, editors?, translators? }`, each a `readonly Credit[]` in credit order, never re-sorted) is the contributor shape every CSL record above extends.
|
|
641
780
|
|
|
642
781
|
```svelte
|
|
643
782
|
<script lang="ts">
|
|
@@ -662,7 +801,7 @@ A `Citation` or `Footnote` rendered with no `setupBibliography()` provider above
|
|
|
662
801
|
|
|
663
802
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
664
803
|
| --- | --- | --- | --- | --- |
|
|
665
|
-
| `EntityForm` | Walks `definition.entriesFor(mode)` in declaration order. Scalar entries render as `FormField` + `TextInput` (`url` / numeric `inputmode`), `DateInput` (`type: 'date'`) or `Textarea` (multiline). Select entries render as `Select` over the field's derived enum values. Widget entries render the entry's own component through the bridge | `definition: EntityFormDefinition`, `mode: 'create' \| 'update'`, `cancelHref?`, `action?` (a named action), `initial?: Record<string, string>` (seeded once, at mount, so it lands in SSR HTML), `initialRows?` (per `rows` widget), `fieldProps?` (per widget key), `enhance?: (form) => attributes` (default: the store's own `form.props`), `cancelLabel? = 'Cancel'`, `pendingLabel? = 'Saving…'`, `selectPlaceholder? = 'Choose…'`, `requiredLabel?`, plus native `<form>` attributes | — | each control is labelled by its `FormField`. Errors are `aria-describedby`. Root errors are
|
|
804
|
+
| `EntityForm` | Walks `definition.entriesFor(mode)` in declaration order. Scalar entries render as `FormField` + `TextInput` (`url` / numeric `inputmode`), `DateInput` (`type: 'date'`) or `Textarea` (multiline). Select entries render as `Select` over the field's derived enum values. Widget entries render the entry's own component through the bridge | `definition: EntityFormDefinition`, `mode: 'create' \| 'update'`, `cancelHref?`, `action?` (a named action), `initial?: Record<string, string>` (seeded once, at mount, so it lands in SSR HTML), `initialRows?` (per `rows` widget), `fieldProps?` (per widget key), `enhance?: (form) => attributes` (default: the store's own `form.props`), `cancelLabel? = 'Cancel'`, `pendingLabel? = 'Saving…'`, `selectPlaceholder? = 'Choose…'`, `requiredLabel?`, `messages?: readonly string[]` (form-level messages from outside the store, e.g. `page.form?.message`; rendered after the store's root errors), plus native `<form>` attributes | — | each control is labelled by its `FormField`. Errors are `aria-describedby`. Root errors and `messages` are `FormErrors` alerts, one `role="alert"` each |
|
|
666
805
|
|
|
667
806
|
Field labels, submit labels and option labels come from the definition's own message thunks. Every other string is a prop.
|
|
668
807
|
|
|
@@ -688,10 +827,23 @@ Field labels, submit labels and option labels come from the definition's own mes
|
|
|
688
827
|
|
|
689
828
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
690
829
|
| --- | --- | --- | --- | --- |
|
|
691
|
-
| `Head` | Title (with a site-name suffix), description, keywords, robots, canonical URL, `hreflang` alternates, a feed link, Open Graph + Twitter / X tags, share image, `article:*`, `prev` / `next` | `title: string`, `siteName?`, `titleSeparator? = ' \| '`, `description?`, `keywords?`, `type?: SchemaPageType` (derives `og:type`), `openGraphType
|
|
830
|
+
| `Head` | Title (with a site-name suffix), description, keywords, robots, canonical URL, `hreflang` alternates, a feed link, Open Graph + Twitter / X tags, share image, `article:*`, `prev` / `next` | `title: string`, `siteName?`, `titleSeparator? = ' \| '`, `description?`, `keywords?`, `type?: SchemaPageType` (derives `og:type`), `openGraphType?: OpenGraphType`, `canonical?`, `pathname?` (defaults to `window.location.pathname`; pass `page.url.pathname` for SSR), `canonicalHref?` (defaults to the context `href`), `alternates?: AlternateLink[]` or `locales?` (`alternates` wins), `ogLocale?` (defaults to the context locale, `-` → `_`), `feed?: FeedLink`, `robots?: Robots`, `image?: HeadImage` → `shareCard?: OgImageCard` → `defaultImage?: HeadImage` (precedence), `shareCardPath? = '/og'`, `shareCardAlt?: (card) => string` (default `"<card title> — <siteName>"`, the title alone without a `siteName`), `published?`, `modified?`, `section?`, `tags?`, `twitterCard?: TwitterCard = 'summary_large_image'`, `twitterSite?`, `twitterCreator?`, `prevUrl?`, `nextUrl?` | — | — (head metadata only) |
|
|
692
831
|
| `JsonLd` | One `<script type="application/ld+json">` block, serialised by `serializeJsonLd` | `data: JsonLdObject \| null \| undefined` — nullish renders nothing | — | — |
|
|
693
832
|
|
|
694
|
-
`serializeJsonLd(data)` escapes `<`, `>`, `&`, U+2028 and U+2029 so authored text cannot close the script element; the output is still valid JSON. `ogImageUrl(card, { href?, basePath? })` builds the content-addressed share-card URL `<basePath>/<slug>.png?t=…&d=…&v=…`, where the `v` token is the card's `updatedAt` in epoch milliseconds, so a content change always mints a new URL (link-preview scrapers only re-fetch a URL they have not seen). It returns `undefined` for a blank title. The param names (`OG_TITLE_PARAM`, `OG_SUBTITLE_PARAM`, `OG_VERSION_PARAM`), the length caps and `OG_IMAGE_WIDTH` / `OG_IMAGE_HEIGHT` are exported so a card renderer reads back exactly what `Head` wrote. The renderer itself is server code and does not live here.
|
|
833
|
+
`serializeJsonLd(data)` escapes `<`, `>`, `&`, U+2028 and U+2029 so authored text cannot close the script element; the output is still valid JSON. `ogImageUrl(card, { href?, basePath? })` builds the content-addressed share-card URL `<basePath>/<slug>.png?t=…&d=…&v=…`, where the `v` token is the card's `updatedAt` in epoch milliseconds, so a content change always mints a new URL (link-preview scrapers only re-fetch a URL they have not seen). It returns `undefined` for a blank title. The param names (`OG_TITLE_PARAM` = `'t'`, `OG_SUBTITLE_PARAM` = `'d'`, `OG_VERSION_PARAM` = `'v'`), the length caps (`OG_TITLE_MAX_LENGTH` = 120 and `OG_SUBTITLE_MAX_LENGTH` = 200 characters, to which `ogImageUrl` truncates the title and description) and `OG_IMAGE_WIDTH` / `OG_IMAGE_HEIGHT` (1200 × 630) are exported so a card renderer reads back exactly what `Head` wrote, and should re-apply the caps itself. The renderer itself is server code and does not live here.
|
|
834
|
+
|
|
835
|
+
| Type | What it is |
|
|
836
|
+
| --- | --- |
|
|
837
|
+
| `OgImageCard` | What a share card is built from: `{ id, title, description?, updatedAt? }`. `id` only builds the readable `<slug>.png` path segment; `updatedAt` (a `Date`, ISO string or epoch number) drives the `v` token, and when absent or unparseable the URL is keyed by title and description alone. `Head`'s `shareCard` prop. |
|
|
838
|
+
| `OgImageUrlOptions` | `ogImageUrl`'s second argument: `{ href?, basePath? }`. `href` resolves the card path to the emitted URL (identity by default; `Head` passes its `canonicalHref`), `basePath` is where the renderer is mounted (default `'/og'`). |
|
|
839
|
+
| `HeadImage` | A share image: `{ url, alt?, width?, height?, type? }`; `url` must be absolute, `type` is the MIME type. `Head`'s `image` and `defaultImage`. |
|
|
840
|
+
| `AlternateLink` | One `<link rel="alternate" hreflang>`: `{ href, lang }`, `lang` a BCP 47 tag or `x-default`. `Head`'s `alternates`. |
|
|
841
|
+
| `FeedLink` | `Head`'s `feed`: `{ path, title? = 'Latest changes', type? = 'application/feed+json' }`; `path` is resolved through `canonicalHref` like the canonical URL. |
|
|
842
|
+
| `Robots` | `Head`'s `robots`: `{ index?, follow? }`, both defaulting to `true`. The `robots` meta is always emitted. |
|
|
843
|
+
| `OpenGraphType` | `'website' \| 'article' \| 'profile'` — `Head`'s `openGraphType`, which wins over the one derived from `type`. |
|
|
844
|
+
| `SchemaPageType` | `'WebPage' \| 'WebSite' \| 'CollectionPage' \| 'Article' \| 'BlogPosting' \| 'ProfilePage'` — `Head`'s `type`, used only to derive `og:type`. |
|
|
845
|
+
| `TwitterCard` | `'summary' \| 'summary_large_image'` — `Head`'s `twitterCard`. |
|
|
846
|
+
| `JsonLdObject` | A JSON-LD node with a top-level `@context` — what `JsonLd`'s `data` and `serializeJsonLd` take. Deliberately loose, so `schema-dts`'s `WithContext<Thing>` is assignable to it without this package depending on a vocabulary. |
|
|
695
847
|
|
|
696
848
|
**Canonical on the production origin.** `Head` resolves the canonical URL, the feed and the share card through `canonicalHref`, which defaults to the components context's `href`. If your `href` returns relative, locale-prefixed paths, pass an absolute resolver instead:
|
|
697
849
|
|
|
@@ -726,10 +878,23 @@ Field labels, submit labels and option labels come from the definition's own mes
|
|
|
726
878
|
|
|
727
879
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
728
880
|
| --- | --- | --- | --- | --- |
|
|
729
|
-
| `Tip` | One piece of inline guidance — title, optional message, actions. Shows itself only when its store says it may, and remembers that it did | `config: TipConfig`, `store: TipStore`, `stack
|
|
730
|
-
| `PopoverTip` | A `Tip` anchored to the thing it explains, floating beside it via bits-ui's `Popover` | `config: TipConfig`, `store: TipStore`, `stack
|
|
881
|
+
| `Tip` | One piece of inline guidance — title, optional message, actions. Shows itself only when its store says it may, and remembers that it did | `config: TipConfig`, `store: TipStore`, `stack? = soloTipStack`, `dismissLabel? = 'Dismiss'`, `class?` | — | `role="status" aria-live="polite"`; the dismiss button has an accessible name from `dismissLabel` |
|
|
882
|
+
| `PopoverTip` | A `Tip` anchored to the thing it explains, floating beside it via bits-ui's `Popover` | `config: TipConfig`, `store: TipStore`, `stack? = soloTipStack`, `placement?: TipPlacement = 'top'`, `dismissLabel? = 'Dismiss'`, `class?` | `children` — the trigger content | bits-ui `Popover` dialog ARIA on the trigger; the bubble carries `Tip`'s `role="status"` |
|
|
731
883
|
|
|
732
|
-
|
|
884
|
+
| Export | What it is |
|
|
885
|
+
| --- | --- |
|
|
886
|
+
| `createTipStore(options?: TipStoreOptions): TipStore` | The store. `TipStoreOptions` is `{ storage?: TipStorage, prefix? = 'tips:' }`; `storage` defaults to `browserTipStorage()`. Call once, high in the tree. |
|
|
887
|
+
| `TipStore` | `register(config)` (re-registering an id replaces its config), `shouldDisplay(id)`, `getState(id)`, `markShown(id)`, `dismiss(id)`, `complete(id)`, `resetAll()`. An unregistered id never displays. |
|
|
888
|
+
| `TipStorage` | `{ getItem, setItem, removeItem }` — the subset of the DOM `Storage` interface the store needs, so `localStorage` satisfies it and a test can pass a plain object. Every access is guarded; a refusing storage only costs persistence. |
|
|
889
|
+
| `browserTipStorage()` / `memoryTipStorage()` | `localStorage` where it exists and accepts a write, else in-memory; and always in-memory (for tests and stories). |
|
|
890
|
+
| `createTipStack(): TipStack` / `soloTipStack` | `TipStack` is `{ push, pop, isTopmost }` — which tips are on screen, most recent last, so Escape closes only the topmost. `soloTipStack` always answers "topmost", the default when a tip gets no `stack`. |
|
|
891
|
+
| `TipConfig` | A tip as declared: `{ id, title, message?, actions?: TipAction[], rules?: TipRule[], frequency?: TipFrequency, maxDisplayCount? }`. |
|
|
892
|
+
| `TipAction` | A button on the tip: `{ id, title, handler: () => void \| Promise<void> }`. Taking one awaits `handler`, then **completes** the tip. |
|
|
893
|
+
| `TipRule` | `{ id, evaluate: () => boolean }`; every rule must hold for the tip to show. Synchronous on purpose — await your data first, then register the tip. |
|
|
894
|
+
| `TipFrequency` | `'immediate' \| 'hourly' \| 'daily' \| 'weekly' \| 'once'` (default `'immediate'`): how soon a tip may reappear after it was last shown. |
|
|
895
|
+
| `TipStatus` | `'pending' \| 'available' \| 'dismissed' \| 'completed'`. `dismissed` and `completed` are both terminal — `shouldDisplay` checks status before frequency, so neither shows again, even at `'immediate'`. |
|
|
896
|
+
| `TipState` | What is persisted per tip: `{ id, status, displayCount, lastShown?, dismissedAt?, completedAt? }` (ISO 8601 timestamps). |
|
|
897
|
+
| `TipPlacement` | `'top' \| 'bottom' \| 'left' \| 'right'` — `PopoverTip`'s `placement`. |
|
|
733
898
|
|
|
734
899
|
```ts
|
|
735
900
|
import { createTipStore } from '@thomasfosterau/components/tips';
|
|
@@ -765,7 +930,7 @@ store.register({
|
|
|
765
930
|
|
|
766
931
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
767
932
|
| --- | --- | --- | --- | --- |
|
|
768
|
-
| `Chart` | A line, area or bar chart over one y axis, with a data table always present and a hover readout | `series`, `kind
|
|
933
|
+
| `Chart` | A line, area or bar chart over one y axis, with a data table always present and a hover readout | `series: readonly ChartSeries[]`, `kind?: ChartKind = 'line'`, `title`, `description?`, `height? = 240`, `showTable? = false`, `legendLabel? = 'Series'`, `tableLabel?` (defaults to `title`), `categoryLabel?`, `formatValue?`, `class?` | — | `<svg role="img">` named by `title`; the figures are always reachable through an (optionally hidden) `<table>` captioned by `tableLabel`; the hover readout is `role="status"` |
|
|
769
934
|
|
|
770
935
|
```svelte
|
|
771
936
|
<script lang="ts">
|
|
@@ -780,9 +945,11 @@ store.register({
|
|
|
780
945
|
<Chart {series} kind="bar" title="Observations by kind" formatValue={(v) => `${v} kg`} showTable />
|
|
781
946
|
```
|
|
782
947
|
|
|
948
|
+
`ChartSeries` is `{ key, label, points: readonly ChartPoint[] }` and `ChartPoint` is `{ label, value }` — `label` the x-axis category, already localised; every series shares the first series' categories, in x order. `ChartKind` is `'line' | 'area' | 'bar'`.
|
|
949
|
+
|
|
783
950
|
Rules it enforces rather than leaving to the caller: one y axis (two measures of different scale are two charts); the zero line is always drawn; identity is never colour alone (a legend for 2+ series, a direct end-of-line label for ≤4); the numbers are always readable as a `<table>`, visually hidden by default. `key` is stable identity and picks the colour, so a filtered-out series never repaints the survivors that remain.
|
|
784
951
|
|
|
785
|
-
Series colours come from four validated theme slots, `--color-chart-1…4`, assigned by series order and never cycled — a fifth series draws in `--color-neutral` and the legend says which one it is. See [`theme.css`](src/lib/theme.css) for the measured separations, and
|
|
952
|
+
Series colours come from four validated theme slots, `--color-chart-1…4`, assigned by series order and never cycled — a fifth series draws in `--color-neutral` and the legend says which one it is. See [`theme.css`](https://github.com/thomasfosterau/components/blob/trunk/packages/components/src/lib/theme.css) for the measured separations, and the [package guide's Terms section](https://github.com/thomasfosterau/components/blob/trunk/packages/components/CLAUDE.md#terms) for the rules those measurements exist to keep.
|
|
786
953
|
|
|
787
954
|
## canvas
|
|
788
955
|
|
|
@@ -790,11 +957,11 @@ Series colours come from four validated theme slots, `--color-chart-1…4`, assi
|
|
|
790
957
|
|
|
791
958
|
| Component | Purpose | Props | Snippets | ARIA |
|
|
792
959
|
| --- | --- | --- | --- | --- |
|
|
793
|
-
| `VectorCanvas` | A pan-and-zoom SVG surface over a `VectorDocument`, with elements drawn by a renderer the caller supplies. Reports gestures; mutates nothing | `doc
|
|
960
|
+
| `VectorCanvas` | A pan-and-zoom SVG surface over a `VectorDocument`, with elements drawn by a renderer the caller supplies. Reports gestures; mutates nothing | `doc: VectorDocument<T>`, `selected?: ReadonlySet<string>` (default empty), `tool? = 'select'` (`'pan'` is the one it handles itself), `width? = 800`, `height? = 600`, `interactive? = true`, `label? = 'Vector editing canvas'`, `activedescendant? = ''`, `describedby? = ''`, `onkey?` (every keydown and keyup, before the surface's own keys; `preventDefault` claims one), `onfocuschange?`, `onviewportchange?`, `oninteract?`, `class?`, plus the native `<svg>` attributes except the key and focus handlers | `element(shape, selected)` — draws one element; omitted, a fallback marker is drawn. `overlay()` — drawn above every element, in canvas coordinates | `<svg role="application">` named by `label`, one Tab stop when `interactive`, with `aria-activedescendant` / `aria-describedby` from the props above. Keys the application leaves unclaimed (and not a Ctrl, Alt or Meta chord): arrows pan by 40 px with the `pan` tool (Shift: 160), `+` / `=` / `-` zoom about the centre with any tool |
|
|
794
961
|
|
|
795
962
|
```svelte
|
|
796
963
|
<script lang="ts">
|
|
797
|
-
import { VectorCanvas, type CanvasElement, type Viewport } from '@thomasfosterau/components/canvas';
|
|
964
|
+
import { VectorCanvas, type CanvasElement, type VectorDocument, type Viewport } from '@thomasfosterau/components/canvas';
|
|
798
965
|
let doc = $state<VectorDocument>(plan);
|
|
799
966
|
</script>
|
|
800
967
|
|
|
@@ -807,11 +974,29 @@ Series colours come from four validated theme slots, `--color-chart-1…4`, assi
|
|
|
807
974
|
</VectorCanvas>
|
|
808
975
|
```
|
|
809
976
|
|
|
810
|
-
`oninteract` reports `{ phase: 'down'|'move'|'up', tool, point, detail, native }` in CANVAS coordinates, already snapped when `doc.grid.snap` is set
|
|
977
|
+
`oninteract` reports `{ phase: 'down'|'move'|'up', tool, point, detail, native }` in CANVAS coordinates, already snapped when `doc.grid.snap` is set; `detail` counts rapid presses at one spot (2 on the second of a double-press, always 1 on `move` and `up`).
|
|
978
|
+
|
|
979
|
+
The document model is plain data:
|
|
980
|
+
|
|
981
|
+
| Type | What it is |
|
|
982
|
+
| --- | --- |
|
|
983
|
+
| `VectorDocument<T>` | `{ version, metadata, viewport: Viewport, grid: GridConfig, elements: readonly T[] }` — the whole editable document, `version` being your schema version for migrations. |
|
|
984
|
+
| `CanvasElement` | What every element carries: `{ id: ElementId, type, position: Point, rotation, zIndex, strokeColor, strokeWidth, fillColor, opacity, locked, visible?, label?, metadata? }`. Extend it with your own element types; `type` is the discriminator your renderer switches on. |
|
|
985
|
+
| `ElementId` | `string` — an element's stable identity (`generateId()` mints one with `crypto.randomUUID()`). |
|
|
986
|
+
| `Point` | `{ x, y }` in canvas coordinates — the document's own space, not the screen's. |
|
|
987
|
+
| `Rect` | `{ x, y, width, height }` — an axis-aligned box in canvas coordinates (`boundingBox`, `pointInRect`). |
|
|
988
|
+
| `Viewport` | `{ offsetX, offsetY, zoom }` — pan and zoom, `zoom: 1` being 100%. |
|
|
989
|
+
| `GridConfig` | `{ visible, size, snap, color }` — the background grid, its cell size in canvas units, whether pointer positions snap to it, and a CSS colour. |
|
|
990
|
+
| `Transform<T>` | A change as data: `{ type: 'add' \| 'update' \| 'delete' \| 'batch', elementIds, elements?, previousElements? }`. Carrying the previous elements is what makes it invertible. |
|
|
991
|
+
| `HistoryEntry<T>` | One undo-stack entry: `{ transform, timestamp, description? }` (`timestamp` in epoch milliseconds). |
|
|
992
|
+
| `History<T>` | `{ past, future }` — the undo stack (oldest first) and the redo stack (nearest first). The caller owns it, as it owns the document. |
|
|
993
|
+
| `HistoryStep<T>` | `{ doc, history }` — the result of one `undo` or `redo`. |
|
|
994
|
+
|
|
995
|
+
`canvas/history.ts` gives pure undo/redo over `Transform`s rather than document snapshots (`emptyHistory`, `applyTransform`, `invertTransform`, `diffTransform`, `recordEdit`, `undo`, `redo`, `HISTORY_LIMIT = 100`) — a history of edits is bounded by the size of the edits, not the size of the plan. `undo(doc, history)` and `redo(doc, history)` return `readonly HistoryStep[]` holding one step, or none when there is nothing to take back, so `const [step] = undo(doc, history)` needs no sentinel. `canvas/utils.ts` gives the geometry underneath it: `screenToCanvas`/`canvasToScreen`, `snapToGrid`, `distance`, `pointInRect`, `pointNearLine`, `sortByZIndex`, `boundingBox`, `rotatePoint`, `panBy`, `zoomAbout`, `generateId`.
|
|
811
996
|
|
|
812
997
|
## maps — moved
|
|
813
998
|
|
|
814
|
-
Maps live in their own package, [`@thomasfosterau/components-mapbox`](
|
|
999
|
+
Maps live in their own package, [`@thomasfosterau/components-mapbox`](https://github.com/thomasfosterau/components/blob/trunk/packages/mapbox/README.md) (`pnpm add @thomasfosterau/components-mapbox mapbox-gl`), because they need `mapbox-gl`; nothing in this package imports it, and `@thomasfosterau/components/maps` is no longer an entry point.
|
|
815
1000
|
|
|
816
1001
|
## License
|
|
817
1002
|
|