@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.
Files changed (240) hide show
  1. package/README.md +241 -56
  2. package/dist/canvas/VectorCanvas.svelte +89 -8
  3. package/dist/canvas/VectorCanvas.svelte.d.ts +19 -1
  4. package/dist/canvas/types.d.ts +1 -1
  5. package/dist/charts/types.d.ts +1 -1
  6. package/dist/citations/list-format.js +3 -1
  7. package/dist/data/ChatBubble.svelte +48 -0
  8. package/dist/data/ChatBubble.svelte.d.ts +33 -0
  9. package/dist/data/DescriptionList.svelte +9 -3
  10. package/dist/data/DescriptionList.svelte.d.ts +1 -1
  11. package/dist/data/DescriptionRow.svelte +6 -5
  12. package/dist/data/MessageThread.svelte +29 -0
  13. package/dist/data/MessageThread.svelte.d.ts +17 -0
  14. package/dist/data/Pagination.svelte +5 -5
  15. package/dist/data/Pagination.svelte.d.ts +8 -0
  16. package/dist/data/PaginationFooter.svelte +2 -1
  17. package/dist/data/PaginationFooter.svelte.d.ts +2 -0
  18. package/dist/data/Table.svelte +9 -3
  19. package/dist/data/description-context.d.ts +12 -6
  20. package/dist/data/description-context.js +6 -5
  21. package/dist/data/index.d.ts +2 -0
  22. package/dist/data/index.js +2 -0
  23. package/dist/data/table-types.d.ts +7 -0
  24. package/dist/feedback/Dialog.svelte +19 -49
  25. package/dist/feedback/Dialog.svelte.d.ts +0 -1
  26. package/dist/feedback/DialogFrame.svelte +64 -0
  27. package/dist/feedback/DialogFrame.svelte.d.ts +23 -0
  28. package/dist/feedback/Drawer.svelte +43 -0
  29. package/dist/feedback/Drawer.svelte.d.ts +43 -0
  30. package/dist/feedback/SegmentBar.svelte.d.ts +1 -0
  31. package/dist/feedback/StatTile.svelte.d.ts +1 -0
  32. package/dist/feedback/Steps.svelte +72 -0
  33. package/dist/feedback/Steps.svelte.d.ts +61 -0
  34. package/dist/feedback/Timeline.svelte.d.ts +3 -0
  35. package/dist/feedback/index.d.ts +3 -0
  36. package/dist/feedback/index.js +2 -0
  37. package/dist/feedback/sizes.d.ts +14 -0
  38. package/dist/feedback/sizes.js +28 -0
  39. package/dist/fields/Calendar.svelte +60 -0
  40. package/dist/fields/Calendar.svelte.d.ts +11 -0
  41. package/dist/fields/CalendarMonths.svelte +86 -0
  42. package/dist/fields/CalendarMonths.svelte.d.ts +30 -0
  43. package/dist/fields/Checkbox.svelte +1 -1
  44. package/dist/fields/Checkbox.svelte.d.ts +6 -0
  45. package/dist/fields/Combobox.svelte +61 -54
  46. package/dist/fields/Combobox.svelte.d.ts +38 -3
  47. package/dist/fields/DateField.svelte +37 -0
  48. package/dist/fields/DateField.svelte.d.ts +20 -0
  49. package/dist/fields/DateInput.svelte +1 -1
  50. package/dist/fields/DateInput.svelte.d.ts +6 -0
  51. package/dist/fields/DatePicker.svelte +47 -0
  52. package/dist/fields/DatePicker.svelte.d.ts +25 -0
  53. package/dist/fields/DateRangePicker.svelte +70 -0
  54. package/dist/fields/DateRangePicker.svelte.d.ts +43 -0
  55. package/dist/fields/Dropzone.svelte +232 -0
  56. package/dist/fields/Dropzone.svelte.d.ts +103 -0
  57. package/dist/fields/Fieldset.svelte +7 -2
  58. package/dist/fields/Fieldset.svelte.d.ts +3 -2
  59. package/dist/fields/FileInput.svelte +3 -2
  60. package/dist/fields/FileInput.svelte.d.ts +6 -0
  61. package/dist/fields/FormField.svelte +27 -3
  62. package/dist/fields/ImageInput.svelte.d.ts +6 -0
  63. package/dist/fields/MultiSelect.svelte +112 -0
  64. package/dist/fields/MultiSelect.svelte.d.ts +75 -0
  65. package/dist/fields/NumberField.svelte +190 -0
  66. package/dist/fields/NumberField.svelte.d.ts +76 -0
  67. package/dist/fields/PasswordInput.svelte +95 -0
  68. package/dist/fields/PasswordInput.svelte.d.ts +58 -0
  69. package/dist/fields/PickerPopover.svelte +47 -0
  70. package/dist/fields/PickerPopover.svelte.d.ts +16 -0
  71. package/dist/fields/PinInput.svelte +83 -0
  72. package/dist/fields/PinInput.svelte.d.ts +66 -0
  73. package/dist/fields/RadioGroup.svelte +3 -0
  74. package/dist/fields/RangeCalendar.svelte +51 -0
  75. package/dist/fields/RangeCalendar.svelte.d.ts +23 -0
  76. package/dist/fields/Rating.svelte +127 -0
  77. package/dist/fields/Rating.svelte.d.ts +86 -0
  78. package/dist/fields/SearchInput.svelte +2 -1
  79. package/dist/fields/SegmentGroup.svelte +91 -0
  80. package/dist/fields/SegmentGroup.svelte.d.ts +20 -0
  81. package/dist/fields/Select.svelte +1 -1
  82. package/dist/fields/Select.svelte.d.ts +6 -0
  83. package/dist/fields/Slider.svelte +194 -0
  84. package/dist/fields/Slider.svelte.d.ts +100 -0
  85. package/dist/fields/TagsInput.svelte +256 -0
  86. package/dist/fields/TagsInput.svelte.d.ts +102 -0
  87. package/dist/fields/TextInput.svelte +1 -1
  88. package/dist/fields/TextInput.svelte.d.ts +6 -0
  89. package/dist/fields/Textarea.svelte +1 -1
  90. package/dist/fields/Textarea.svelte.d.ts +6 -0
  91. package/dist/fields/TimeField.svelte +30 -0
  92. package/dist/fields/TimeField.svelte.d.ts +19 -0
  93. package/dist/fields/Toggle.svelte +1 -1
  94. package/dist/fields/Toggle.svelte.d.ts +6 -0
  95. package/dist/fields/VideoInput.svelte.d.ts +6 -0
  96. package/dist/fields/calendar-props.d.ts +119 -0
  97. package/dist/fields/calendar-props.js +51 -0
  98. package/dist/fields/dates.d.ts +28 -0
  99. package/dist/fields/dates.js +26 -0
  100. package/dist/fields/field-types.d.ts +29 -0
  101. package/dist/fields/field-types.js +32 -0
  102. package/dist/fields/file-validation.d.ts +30 -0
  103. package/dist/fields/file-validation.js +51 -0
  104. package/dist/fields/index.d.ts +9 -0
  105. package/dist/fields/index.js +8 -0
  106. package/dist/fields/latest-search.svelte.d.ts +38 -0
  107. package/dist/fields/latest-search.svelte.js +85 -0
  108. package/dist/fields/number-format.d.ts +52 -0
  109. package/dist/fields/number-format.js +89 -0
  110. package/dist/fields/segmented-field.d.ts +128 -0
  111. package/dist/fields/segmented-field.js +52 -0
  112. package/dist/fields/slider-keys.d.ts +21 -0
  113. package/dist/fields/slider-keys.js +37 -0
  114. package/dist/formatting/Countdown.svelte +110 -0
  115. package/dist/formatting/Countdown.svelte.d.ts +35 -0
  116. package/dist/formatting/Duration.svelte +18 -0
  117. package/dist/formatting/Duration.svelte.d.ts +20 -0
  118. package/dist/formatting/Highlight.svelte +4 -0
  119. package/dist/formatting/Highlight.svelte.d.ts +4 -0
  120. package/dist/formatting/duration.d.ts +44 -0
  121. package/dist/formatting/duration.js +125 -0
  122. package/dist/formatting/index.d.ts +3 -0
  123. package/dist/formatting/index.js +3 -0
  124. package/dist/forms/EntityForm.svelte +53 -45
  125. package/dist/forms/EntityForm.svelte.d.ts +12 -3
  126. package/dist/hotkeys/index.d.ts +5 -0
  127. package/dist/hotkeys/index.js +5 -0
  128. package/dist/hotkeys/match.js +12 -10
  129. package/dist/hotkeys/types.d.ts +4 -2
  130. package/dist/index.d.ts +2 -1
  131. package/dist/index.js +2 -1
  132. package/dist/infobox/Infobox.svelte +2 -2
  133. package/dist/infobox/Infobox.svelte.d.ts +7 -0
  134. package/dist/infobox/InfoboxHeader.svelte +15 -7
  135. package/dist/infobox/InfoboxHeader.svelte.d.ts +7 -0
  136. package/dist/infobox/types.d.ts +5 -5
  137. package/dist/layout/AppShell.svelte +50 -0
  138. package/dist/layout/AppShell.svelte.d.ts +28 -0
  139. package/dist/layout/Cluster.svelte +28 -0
  140. package/dist/layout/Cluster.svelte.d.ts +27 -0
  141. package/dist/layout/Fits.svelte +27 -0
  142. package/dist/layout/Fits.svelte.d.ts +13 -0
  143. package/dist/layout/Flow.svelte +46 -0
  144. package/dist/layout/Flow.svelte.d.ts +16 -0
  145. package/dist/layout/Grid.svelte +23 -0
  146. package/dist/layout/Grid.svelte.d.ts +26 -0
  147. package/dist/layout/HStack.svelte +12 -0
  148. package/dist/layout/HStack.svelte.d.ts +34 -0
  149. package/dist/layout/Hero.svelte +76 -0
  150. package/dist/layout/Hero.svelte.d.ts +36 -0
  151. package/dist/layout/Navbar.svelte +2 -2
  152. package/dist/layout/Sidebar.svelte +1 -1
  153. package/dist/layout/Spacer.svelte +12 -0
  154. package/dist/layout/Spacer.svelte.d.ts +6 -0
  155. package/dist/layout/VStack.svelte +12 -0
  156. package/dist/layout/VStack.svelte.d.ts +29 -0
  157. package/dist/layout/index.d.ts +9 -0
  158. package/dist/layout/index.js +9 -0
  159. package/dist/layout/space.d.ts +44 -0
  160. package/dist/layout/space.js +76 -0
  161. package/dist/search/types.d.ts +3 -0
  162. package/dist/seo/og-image.d.ts +4 -3
  163. package/dist/seo/og-image.js +4 -3
  164. package/dist/tips/tip-stack.d.ts +1 -0
  165. package/dist/tips/tip-store.svelte.d.ts +2 -1
  166. package/dist/ui/Accordion.svelte +75 -0
  167. package/dist/ui/Accordion.svelte.d.ts +50 -0
  168. package/dist/ui/AccordionItem.svelte +73 -0
  169. package/dist/ui/AccordionItem.svelte.d.ts +18 -0
  170. package/dist/ui/Button.svelte +3 -2
  171. package/dist/ui/ButtonGroup.svelte +24 -0
  172. package/dist/ui/ButtonGroup.svelte.d.ts +17 -0
  173. package/dist/ui/ContextMenu.svelte +75 -0
  174. package/dist/ui/ContextMenu.svelte.d.ts +25 -0
  175. package/dist/ui/Disclosure.svelte +7 -0
  176. package/dist/ui/Disclosure.svelte.d.ts +2 -2
  177. package/dist/ui/Divider.svelte +47 -0
  178. package/dist/ui/Divider.svelte.d.ts +28 -0
  179. package/dist/ui/DropdownMenu.svelte +44 -0
  180. package/dist/ui/DropdownMenu.svelte.d.ts +30 -0
  181. package/dist/ui/Menu.svelte +17 -47
  182. package/dist/ui/Menu.svelte.d.ts +3 -24
  183. package/dist/ui/MenuCheckboxItem.svelte +33 -0
  184. package/dist/ui/MenuCheckboxItem.svelte.d.ts +20 -0
  185. package/dist/ui/MenuItem.svelte +29 -31
  186. package/dist/ui/MenuItem.svelte.d.ts +2 -2
  187. package/dist/ui/MenuRadioGroup.svelte +25 -0
  188. package/dist/ui/MenuRadioGroup.svelte.d.ts +20 -0
  189. package/dist/ui/MenuRadioItem.svelte +20 -0
  190. package/dist/ui/MenuRadioItem.svelte.d.ts +15 -0
  191. package/dist/ui/MenuSeparator.svelte +12 -11
  192. package/dist/ui/MenuSub.svelte +24 -0
  193. package/dist/ui/MenuSub.svelte.d.ts +15 -0
  194. package/dist/ui/Spinner.svelte +41 -0
  195. package/dist/ui/Spinner.svelte.d.ts +35 -0
  196. package/dist/ui/Stack.svelte +36 -0
  197. package/dist/ui/Stack.svelte.d.ts +48 -0
  198. package/dist/ui/ToggleButton.svelte +58 -0
  199. package/dist/ui/ToggleButton.svelte.d.ts +45 -0
  200. package/dist/ui/ToggleGroup.svelte +142 -0
  201. package/dist/ui/ToggleGroup.svelte.d.ts +86 -0
  202. package/dist/ui/Toolbar.svelte +40 -0
  203. package/dist/ui/Toolbar.svelte.d.ts +20 -0
  204. package/dist/ui/ToolbarButton.svelte +29 -0
  205. package/dist/ui/ToolbarButton.svelte.d.ts +21 -0
  206. package/dist/ui/ToolbarLink.svelte +21 -0
  207. package/dist/ui/ToolbarLink.svelte.d.ts +12 -0
  208. package/dist/ui/ToolbarSeparator.svelte +22 -0
  209. package/dist/ui/ToolbarSeparator.svelte.d.ts +12 -0
  210. package/dist/ui/ToolbarToggleGroup.svelte +66 -0
  211. package/dist/ui/ToolbarToggleGroup.svelte.d.ts +52 -0
  212. package/dist/ui/accordion-context.d.ts +26 -0
  213. package/dist/ui/accordion-context.js +23 -0
  214. package/dist/ui/context-menu.d.ts +13 -0
  215. package/dist/ui/context-menu.js +13 -0
  216. package/dist/ui/index.d.ts +19 -0
  217. package/dist/ui/index.js +18 -0
  218. package/dist/ui/menu-context.d.ts +13 -15
  219. package/dist/ui/menu-context.js +14 -18
  220. package/dist/ui/menu-parts/CheckboxItemPart.svelte +26 -0
  221. package/dist/ui/menu-parts/CheckboxItemPart.svelte.d.ts +5 -0
  222. package/dist/ui/menu-parts/ItemPart.svelte +29 -0
  223. package/dist/ui/menu-parts/ItemPart.svelte.d.ts +5 -0
  224. package/dist/ui/menu-parts/RadioGroupPart.svelte +24 -0
  225. package/dist/ui/menu-parts/RadioGroupPart.svelte.d.ts +5 -0
  226. package/dist/ui/menu-parts/RadioItemPart.svelte +22 -0
  227. package/dist/ui/menu-parts/RadioItemPart.svelte.d.ts +5 -0
  228. package/dist/ui/menu-parts/SeparatorPart.svelte +16 -0
  229. package/dist/ui/menu-parts/SeparatorPart.svelte.d.ts +5 -0
  230. package/dist/ui/menu-parts/SubPart.svelte +28 -0
  231. package/dist/ui/menu-parts/SubPart.svelte.d.ts +5 -0
  232. package/dist/ui/menu-parts/menu-parts.d.ts +104 -0
  233. package/dist/ui/menu-parts/menu-parts.js +31 -0
  234. package/dist/ui/menu-parts/parts.d.ts +12 -0
  235. package/dist/ui/menu-parts/parts.js +27 -0
  236. package/dist/ui/toggle-classes.d.ts +39 -0
  237. package/dist/ui/toggle-classes.js +36 -0
  238. package/dist/ui/tones.d.ts +35 -0
  239. package/dist/ui/tones.js +90 -0
  240. 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`](../maps/README.md).
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
- | `Disclosure` | Expandable section | `open? ⚓`, `title: string \| Snippet`, `variant?: 'arrow'\|'plus'\|'plain'`, `contentClass?` | `title?`, `children` | native `<details>`/`<summary>` — no hand-written `aria-expanded` |
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` | Nav list or dropdown menu | `mode?: 'dropdown'\|'nav'` (default `nav`), `label`, `open? ⚓`, `side?`, `align?` | `trigger?({ props })`, `children` | nav: `<nav aria-label>` + `<ul class="menu">`; dropdown: bits-ui `role="menu"` |
126
- | `MenuItem` | One entry of a `Menu` | `href?`, `onSelect?`, `current?`, `disabled?` | `icon?`, `children` | nav: `aria-current="page"` + `menu-active`; dropdown: bits-ui `role="menuitem"` |
127
- | `MenuSeparator` | Rule between item groups | — | — | `role="separator"` (dropdown mode) inside a `role="none"` `<li>` |
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 `tones.ts` (`Tone`, `Size`, `HeadingLevel` and the literal `Record<K, string>` class tables — `buttonTone`, `badgeVariant`, `cardVariant`, …), `colour-token.ts` (`colourTokenStyle`, for binding a component's colour custom property to a theme token), and `slug.ts` (`slugify`, used by `Heading`).
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
- <Menu mode="dropdown" label="Account">
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
- </Menu>
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 mode="nav"`'s; deliberately not an `<aside>` — `complementary` is the wrong landmark for primary navigation |
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
- | `ConfirmDialog` | A destructive action behind a confirmation | `open? ⚓`, `title`, `description?`, `confirmLabel? = 'Delete'`, `cancelLabel? = 'Cancel'`, `pendingLabel? = 'Working…'`, `failureMessage?`, `closeLabel?`, `tone? = 'error'`, `size?`, **either** `onConfirm: () => Promise<unknown>` **or** `form: { action, method? = 'POST', fields?, enhance? }`, `redirectTo?` | `trigger?({ props })`, `children?` | `Dialog`'s, plus a failure as an `Alert live="assertive"` inside the still-open dialog; both buttons `disabled` while pending |
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: { label, fraction, colourToken?, tone? }[]`, `showValues?`, `marker?`, `size?: 'sm'\|'md'\|'lg'`, `locales?` | — | ONE `role="img"` whose `aria-label` spells out every segment ("Storage: Photos 62%, Video 31%") |
261
- | `StatTile` | One headline figure | `label`, `value: string \| number`, `delta?: { value, unit?, label? }`, `description?`, `upLabel?`, `downLabel?`, `flatLabel?`, `locales?` | `figure?` | reads label → value → change; the trend arrow is `aria-hidden`, direction is in visually hidden text |
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
- | `Timeline` | A dated sequence | `items: TimelineEntry[]`, `label? = 'Timeline'`, `presentLabel? = 'present'`, `toLabel? = 'to'`, `currentLabel? = 'Current'` | `item?(entry)` | `<ol>` named by `label`; an open-ended entry is introduced by `currentLabel` |
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?` | `item?(item)`, `footer?({ query, pending })`, `empty?({ query })` | bits-ui listbox ARIA; Enter with nothing highlighted lets a wrapping `<form>` submit |
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
- `fields` also exports `field-types.ts` (`FieldControl`, `FieldSize`, `FieldOption`, `FieldOptionInput`, `toFieldOptions`) and the daisyUI size tables (`inputSize`, `selectSize`, …).
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
- | `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)`, `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 |
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
- | `DescriptionList` | `<dl>` of label/value pairs in four shapes | `variant?: 'grid'\|'card'\|'divided'\|'inline'`, `mono?`, `showEmpty?`, `emptyLabel? = 'unrecorded'` | `children` | a real `<dl>` |
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?` + `HTMLAttributes<HTMLDivElement>` | — | `<h3>`, deliberately outside the document outline |
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 — `{ kind: 'fact', label, value }` \| `{ kind: 'link', label, text, href, external? }` \| `{ kind: 'links', label, links: InfoboxLinkEntry[] }` \| `{ kind: 'image', ...InfoboxImageSpec }` \| `{ kind: 'custom', label, content: Snippet }` — filtered by the exported `infoboxRowIsPresent(row)`.
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.image?.url, alt: taxon.name, eager: true },
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`) | 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 |
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 }>` 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.
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 `?` overlay listing every live hotkey | `title? = 'Keyboard shortcuts'`, `closeLabel? = 'Close'`, `emptyLabel?`, `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` |
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({ isAdmin, dev? })` | Creates the registry and puts it in context. Call once, from the root layout. `isAdmin` is a function, so the gate is reactive. |
557
- | `getHotkeyRegistry()` / `tryGetHotkeyRegistry()` | Read it back; the second returns `undefined` instead of throwing — the seam `CommandPalette` uses to work with no registry. |
558
- | `registerHotkeys(() => actions, { scope? })` | Declarative registration for the calling component's lifetime; `scope` defaults to `'page'`. |
559
- | `HotkeyTrigger` | Either a **chord** (`'$mod+k'`) — modifiers held together, `$mod` being ⌘ on Apple platforms and Ctrl elsewhere — or a **sequence** (`'e'`, `['g', 'p']`) of plain keys pressed in order, buffered for `SEQUENCE_TIMEOUT_MS`. |
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: () => (hotkeys.paletteOpen = !hotkeys.paletteOpen) },
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?: readonly { id, label }[]`, `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` |
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 a `FormErrors` alert |
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?`, `canonical?`, `pathname?` (defaults to `window.location.pathname`; pass `page.url.pathname` for SSR), `canonicalHref?` (defaults to the context `href`), `alternates?` / `locales?`, `ogLocale?` (defaults to the context locale, `-` → `_`), `feed?: { path, title? = 'Latest changes', type? = 'application/feed+json' }`, `robots?`, `image?` → `shareCard?` → `defaultImage?` (precedence), `shareCardPath? = '/og'`, `shareCardAlt?` (defaults to `"<title> — <siteName>"`), `published?`, `modified?`, `section?`, `tags?`, `twitterCard? = 'summary_large_image'`, `twitterSite?`, `twitterCreator?`, `prevUrl?`, `nextUrl?` | — | — (head metadata only) |
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?: TipStack`, `dismissLabel? = 'Dismiss'`, `class?` | — | `role="status" aria-live="polite"`; the dismiss button has an accessible name from `dismissLabel` |
730
- | `PopoverTip` | A `Tip` anchored to the thing it explains, floating beside it via bits-ui's `Popover` | `config: TipConfig`, `store: TipStore`, `stack?: TipStack`, `placement? = 'top'` (`'top'\|'bottom'\|'left'\|'right'`), `dismissLabel? = 'Dismiss'`, `class?` | `children` — the trigger content | bits-ui `Popover` dialog ARIA on the trigger; the bubble carries `Tip`'s `role="status"` |
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
- `createTipStore`, `createTipStack`, `soloTipStack`, `memoryTipStorage`, `browserTipStorage` and the `Tip*` types are also exported.
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? = 'line'` (`'line'\|'area'\|'bar'`), `title`, `description?`, `height? = 240`, `showTable?`, `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"` |
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 `packages/components/CLAUDE.md`'s Terms section for the rules those measurements exist to keep.
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`, `selected?`, `tool?`, `width?`, `height?`, `interactive?`, `label? = 'Vector editing canvas'`, `onviewportchange?`, `oninteract?`, `class?`, plus the native `<svg>` attributes | `element(shape, selected)` — draws one element; omitted, a fallback marker is drawn | `<svg role="application">` named by `label`. `application` is the honest role for a surface whose whole vocabulary is pointer gestures — and a promise the page must keep by offering a non-pointer route to the same edits |
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. `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. `canvas/utils.ts` gives the geometry underneath it: `screenToCanvas`/`canvasToScreen`, `snapToGrid`, `distance`, `pointInRect`, `pointNearLine`, `sortByZIndex`, `boundingBox`, `rotatePoint`, `panBy`, `zoomAbout`, `generateId`.
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`](../maps/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.
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