zabi-components 8.1.0 → 9.0.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 (273) hide show
  1. package/CHANGELOG.md +1011 -0
  2. package/README.md +1 -1
  3. package/THEME.md +18 -5
  4. package/THEMING.md +308 -74
  5. package/dist/atoms/ActionPanel.svelte +1 -1
  6. package/dist/atoms/Avatar.svelte +19 -3
  7. package/dist/atoms/Avatar.svelte.d.ts +12 -0
  8. package/dist/atoms/Badge.svelte +37 -8
  9. package/dist/atoms/Badge.svelte.d.ts +6 -0
  10. package/dist/atoms/Block.svelte +74 -0
  11. package/dist/atoms/Block.svelte.d.ts +29 -0
  12. package/dist/atoms/Button.svelte +52 -15
  13. package/dist/atoms/Button.svelte.d.ts +3 -2
  14. package/dist/atoms/Card.svelte +181 -25
  15. package/dist/atoms/Card.svelte.d.ts +55 -3
  16. package/dist/atoms/Checkbox.svelte +33 -0
  17. package/dist/atoms/Checkbox.svelte.d.ts +27 -0
  18. package/dist/atoms/Chip.svelte +351 -0
  19. package/dist/atoms/Chip.svelte.d.ts +57 -0
  20. package/dist/atoms/CodeField.svelte +385 -0
  21. package/dist/atoms/CodeField.svelte.d.ts +61 -0
  22. package/dist/atoms/ColorPicker.svelte +1 -0
  23. package/dist/atoms/DateField.svelte +41 -3
  24. package/dist/atoms/DateField.svelte.d.ts +31 -3
  25. package/dist/atoms/FloatingActionButton.svelte +5 -3
  26. package/dist/atoms/FloatingActionButton.svelte.d.ts +1 -1
  27. package/dist/atoms/Heading.svelte +27 -1
  28. package/dist/atoms/Heading.svelte.d.ts +7 -0
  29. package/dist/atoms/IconButton.svelte +81 -9
  30. package/dist/atoms/IconButton.svelte.d.ts +13 -0
  31. package/dist/atoms/Input.svelte +10 -5
  32. package/dist/atoms/Input.svelte.d.ts +3 -2
  33. package/dist/atoms/List.svelte +3 -2
  34. package/dist/atoms/ListItem.svelte +31 -7
  35. package/dist/atoms/ListItem.svelte.d.ts +6 -0
  36. package/dist/atoms/PickerField.svelte +239 -0
  37. package/dist/atoms/PickerField.svelte.d.ts +67 -0
  38. package/dist/atoms/Progress.svelte +78 -17
  39. package/dist/atoms/Progress.svelte.d.ts +11 -1
  40. package/dist/atoms/Radio.svelte +32 -0
  41. package/dist/atoms/Radio.svelte.d.ts +26 -0
  42. package/dist/atoms/Rating.svelte +132 -6
  43. package/dist/atoms/Rating.svelte.d.ts +24 -3
  44. package/dist/atoms/Select.svelte +24 -26
  45. package/dist/atoms/Select.svelte.d.ts +12 -0
  46. package/dist/atoms/SelectionControl.svelte +99 -6
  47. package/dist/atoms/SelectionControl.svelte.d.ts +25 -0
  48. package/dist/atoms/Skeleton.svelte +114 -18
  49. package/dist/atoms/Skeleton.svelte.d.ts +30 -3
  50. package/dist/atoms/SkeletonGroup.svelte +43 -0
  51. package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
  52. package/dist/atoms/Slider.svelte +35 -0
  53. package/dist/atoms/Sparkline.svelte +109 -0
  54. package/dist/atoms/Sparkline.svelte.d.ts +27 -0
  55. package/dist/atoms/SpeechBubble.svelte +187 -0
  56. package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
  57. package/dist/atoms/Stat.svelte +72 -0
  58. package/dist/atoms/Stat.svelte.d.ts +19 -0
  59. package/dist/atoms/TemporalField.svelte +342 -10
  60. package/dist/atoms/TemporalField.svelte.d.ts +20 -4
  61. package/dist/atoms/Textarea.svelte +100 -5
  62. package/dist/atoms/Textarea.svelte.d.ts +21 -0
  63. package/dist/atoms/TimeColumn.svelte +150 -0
  64. package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
  65. package/dist/atoms/TimeField.svelte +46 -3
  66. package/dist/atoms/TimeField.svelte.d.ts +34 -3
  67. package/dist/atoms/Toast.svelte +6 -5
  68. package/dist/atoms/Toggle.svelte +61 -10
  69. package/dist/atoms/Toggle.svelte.d.ts +15 -0
  70. package/dist/atoms/Tooltip.svelte +20 -1
  71. package/dist/atoms/Tooltip.svelte.d.ts +7 -0
  72. package/dist/atoms/index.d.ts +21 -0
  73. package/dist/atoms/index.js +17 -0
  74. package/dist/atoms/selection-control.styles.d.ts +15 -4
  75. package/dist/atoms/selection-control.styles.js +16 -5
  76. package/dist/components/atoms/index.d.ts +21 -0
  77. package/dist/components/atoms/index.d.ts.map +1 -1
  78. package/dist/components/atoms/selection-control.styles.d.ts +15 -4
  79. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  80. package/dist/components/index.d.ts +27 -3
  81. package/dist/components/index.d.ts.map +1 -1
  82. package/dist/components/map.d.ts +11 -0
  83. package/dist/components/map.d.ts.map +1 -0
  84. package/dist/components/molecules/index.d.ts +7 -2
  85. package/dist/components/molecules/index.d.ts.map +1 -1
  86. package/dist/components/molecules/toast-store.d.ts +30 -0
  87. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  88. package/dist/components/organisms/index.d.ts +6 -1
  89. package/dist/components/organisms/index.d.ts.map +1 -1
  90. package/dist/components/types/variants.d.ts +12 -3
  91. package/dist/components/types/variants.d.ts.map +1 -1
  92. package/dist/components/util/app-shell.d.ts +55 -4
  93. package/dist/components/util/app-shell.d.ts.map +1 -1
  94. package/dist/components/util/avatar.d.ts +3 -0
  95. package/dist/components/util/avatar.d.ts.map +1 -1
  96. package/dist/components/util/bottom-tab-bar.d.ts +6 -0
  97. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -1
  98. package/dist/components/util/chart.d.ts +140 -0
  99. package/dist/components/util/chart.d.ts.map +1 -0
  100. package/dist/components/util/chip.d.ts +66 -0
  101. package/dist/components/util/chip.d.ts.map +1 -0
  102. package/dist/components/util/cn.d.ts.map +1 -1
  103. package/dist/components/util/code-field.d.ts +63 -0
  104. package/dist/components/util/code-field.d.ts.map +1 -0
  105. package/dist/components/util/collapsible.d.ts +6 -0
  106. package/dist/components/util/collapsible.d.ts.map +1 -1
  107. package/dist/components/util/display-voice.d.ts +10 -0
  108. package/dist/components/util/display-voice.d.ts.map +1 -0
  109. package/dist/components/util/field-control.d.ts +30 -0
  110. package/dist/components/util/field-control.d.ts.map +1 -0
  111. package/dist/components/util/field.d.ts +6 -0
  112. package/dist/components/util/field.d.ts.map +1 -1
  113. package/dist/components/util/focus-utils.d.ts +2 -0
  114. package/dist/components/util/focus-utils.d.ts.map +1 -1
  115. package/dist/components/util/map-view.d.ts +144 -0
  116. package/dist/components/util/map-view.d.ts.map +1 -0
  117. package/dist/components/util/photo.d.ts +19 -0
  118. package/dist/components/util/photo.d.ts.map +1 -1
  119. package/dist/components/util/progress.d.ts +19 -0
  120. package/dist/components/util/progress.d.ts.map +1 -0
  121. package/dist/components/util/scroll-edge.d.ts +33 -0
  122. package/dist/components/util/scroll-edge.d.ts.map +1 -0
  123. package/dist/components/util/sheet-drag.d.ts +25 -1
  124. package/dist/components/util/sheet-drag.d.ts.map +1 -1
  125. package/dist/components/util/skeleton.d.ts +15 -0
  126. package/dist/components/util/skeleton.d.ts.map +1 -0
  127. package/dist/components/util/sliding-indicator.d.ts +25 -0
  128. package/dist/components/util/sliding-indicator.d.ts.map +1 -0
  129. package/dist/components/util/temporal-field.d.ts +89 -0
  130. package/dist/components/util/temporal-field.d.ts.map +1 -0
  131. package/dist/components/util/toaster.d.ts +40 -0
  132. package/dist/components/util/toaster.d.ts.map +1 -1
  133. package/dist/components/util/tone-surface.d.ts +34 -0
  134. package/dist/components/util/tone-surface.d.ts.map +1 -0
  135. package/dist/components/util/top-navbar.d.ts +26 -2
  136. package/dist/components/util/top-navbar.d.ts.map +1 -1
  137. package/dist/components/util/zabi-strings.d.ts +9 -0
  138. package/dist/components/util/zabi-strings.d.ts.map +1 -1
  139. package/dist/create-theme/cli.js +6 -2
  140. package/dist/create-theme/index.d.ts +17 -3
  141. package/dist/create-theme/index.js +252 -35
  142. package/dist/create-theme/lib/resolve.js +97 -0
  143. package/dist/create-theme/theme-data.js +1618 -90
  144. package/dist/index.d.ts +27 -3
  145. package/dist/index.js +18 -0
  146. package/dist/map.d.ts +10 -0
  147. package/dist/map.js +9 -0
  148. package/dist/molecules/Alert.svelte +31 -12
  149. package/dist/molecules/Alert.svelte.d.ts +12 -1
  150. package/dist/molecules/AppBar.svelte +287 -28
  151. package/dist/molecules/AppBar.svelte.d.ts +62 -0
  152. package/dist/molecules/AvatarGroup.svelte +1 -1
  153. package/dist/molecules/BarChart.svelte +381 -0
  154. package/dist/molecules/BarChart.svelte.d.ts +45 -0
  155. package/dist/molecules/BottomSheet.svelte +88 -20
  156. package/dist/molecules/BottomSheet.svelte.d.ts +17 -1
  157. package/dist/molecules/BottomTabBar.svelte +134 -28
  158. package/dist/molecules/BottomTabBar.svelte.d.ts +36 -2
  159. package/dist/molecules/Calendar.svelte.d.ts +1 -1
  160. package/dist/molecules/ChipGroup.svelte +325 -0
  161. package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
  162. package/dist/molecules/Collapsible.svelte +158 -54
  163. package/dist/molecules/Collapsible.svelte.d.ts +23 -6
  164. package/dist/molecules/CollapsibleGroup.svelte +16 -6
  165. package/dist/molecules/ConfirmDialog.svelte +5 -1
  166. package/dist/molecules/ConfirmDialog.svelte.d.ts +3 -0
  167. package/dist/molecules/Drawer.svelte +33 -12
  168. package/dist/molecules/Dropdown.svelte +9 -1
  169. package/dist/molecules/Dropdown.svelte.d.ts +2 -0
  170. package/dist/molecules/DropdownItem.svelte +1 -1
  171. package/dist/molecules/ImageUpload.svelte +230 -120
  172. package/dist/molecules/ImageUpload.svelte.d.ts +38 -3
  173. package/dist/molecules/LineChart.svelte +459 -0
  174. package/dist/molecules/LineChart.svelte.d.ts +53 -0
  175. package/dist/molecules/MapView.svelte +565 -0
  176. package/dist/molecules/MapView.svelte.d.ts +88 -0
  177. package/dist/molecules/Modal.svelte +11 -6
  178. package/dist/molecules/PhotoGrid.svelte +100 -10
  179. package/dist/molecules/PhotoGrid.svelte.d.ts +11 -0
  180. package/dist/molecules/PhotoViewer.svelte +121 -24
  181. package/dist/molecules/PhotoViewer.svelte.d.ts +18 -0
  182. package/dist/molecules/PieChart.svelte +421 -0
  183. package/dist/molecules/PieChart.svelte.d.ts +51 -0
  184. package/dist/molecules/RadioGroup.svelte +20 -4
  185. package/dist/molecules/RadioGroup.svelte.d.ts +5 -0
  186. package/dist/molecules/SegmentedControl.svelte +136 -3
  187. package/dist/molecules/SegmentedControl.svelte.d.ts +6 -0
  188. package/dist/molecules/SidebarFooter.svelte +1 -1
  189. package/dist/molecules/SidebarNavSection.svelte +1 -1
  190. package/dist/molecules/SortableList.svelte +1 -0
  191. package/dist/molecules/Stepper.svelte +21 -7
  192. package/dist/molecules/StickyActionBar.svelte +67 -8
  193. package/dist/molecules/StickyActionBar.svelte.d.ts +14 -1
  194. package/dist/molecules/Tabs.svelte +79 -2
  195. package/dist/molecules/Toaster.svelte +171 -7
  196. package/dist/molecules/Toaster.svelte.d.ts +60 -2
  197. package/dist/molecules/ToasterToast.svelte +292 -60
  198. package/dist/molecules/ToasterToast.svelte.d.ts +6 -0
  199. package/dist/molecules/UnsavedChangesBar.svelte +13 -2
  200. package/dist/molecules/index.d.ts +7 -2
  201. package/dist/molecules/index.js +4 -0
  202. package/dist/molecules/toast-store.d.ts +30 -0
  203. package/dist/molecules/toast-store.js +4 -1
  204. package/dist/organisms/AppNavigation.svelte +440 -0
  205. package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
  206. package/dist/organisms/AppShell.svelte +539 -39
  207. package/dist/organisms/AppShell.svelte.d.ts +102 -6
  208. package/dist/organisms/AppShellHeaderPart.svelte +163 -0
  209. package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
  210. package/dist/organisms/SidebarNavigation.svelte +23 -36
  211. package/dist/organisms/SidebarPanel.svelte +10 -14
  212. package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
  213. package/dist/organisms/SidebarShell.svelte +2 -3
  214. package/dist/organisms/TopNavbar.svelte +117 -28
  215. package/dist/organisms/TopNavbar.svelte.d.ts +22 -1
  216. package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
  217. package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
  218. package/dist/organisms/index.d.ts +6 -1
  219. package/dist/organisms/index.js +4 -0
  220. package/dist/types/components.d.ts +17 -1
  221. package/dist/types/components.d.ts.map +1 -1
  222. package/dist/types/index.d.ts +497 -10
  223. package/dist/types/index.d.ts.map +1 -1
  224. package/dist/types/variants.ts +14 -3
  225. package/dist/util/app-shell.d.ts +55 -4
  226. package/dist/util/app-shell.js +32 -7
  227. package/dist/util/avatar.d.ts +3 -0
  228. package/dist/util/bottom-tab-bar.d.ts +6 -0
  229. package/dist/util/chart.d.ts +139 -0
  230. package/dist/util/chart.js +305 -0
  231. package/dist/util/chip.d.ts +65 -0
  232. package/dist/util/chip.js +58 -0
  233. package/dist/util/cn.js +28 -2
  234. package/dist/util/code-field.d.ts +62 -0
  235. package/dist/util/code-field.js +112 -0
  236. package/dist/util/collapsible.d.ts +6 -0
  237. package/dist/util/display-voice.d.ts +9 -0
  238. package/dist/util/display-voice.js +9 -0
  239. package/dist/util/field-control.d.ts +29 -0
  240. package/dist/util/field-control.js +32 -0
  241. package/dist/util/field.d.ts +6 -0
  242. package/dist/util/focus-utils.d.ts +2 -0
  243. package/dist/util/focus-utils.js +4 -0
  244. package/dist/util/map-view.d.ts +143 -0
  245. package/dist/util/map-view.js +121 -0
  246. package/dist/util/photo.d.ts +19 -0
  247. package/dist/util/photo.js +3 -0
  248. package/dist/util/progress.d.ts +18 -0
  249. package/dist/util/progress.js +17 -0
  250. package/dist/util/scroll-edge.d.ts +32 -0
  251. package/dist/util/scroll-edge.js +87 -0
  252. package/dist/util/sheet-drag.d.ts +25 -1
  253. package/dist/util/sheet-drag.js +71 -1
  254. package/dist/util/skeleton.d.ts +14 -0
  255. package/dist/util/skeleton.js +22 -0
  256. package/dist/util/sliding-indicator.d.ts +24 -0
  257. package/dist/util/sliding-indicator.js +44 -0
  258. package/dist/util/temporal-field.d.ts +88 -0
  259. package/dist/util/temporal-field.js +136 -0
  260. package/dist/util/toaster.d.ts +40 -0
  261. package/dist/util/toaster.js +55 -0
  262. package/dist/util/tone-surface.d.ts +33 -0
  263. package/dist/util/tone-surface.js +35 -0
  264. package/dist/util/top-navbar.d.ts +26 -2
  265. package/dist/util/top-navbar.js +23 -0
  266. package/dist/util/zabi-strings.d.ts +9 -0
  267. package/dist/zabi-components-colors.css +9 -3
  268. package/dist/zabi-components-theme-dark-only.css +474 -164
  269. package/dist/zabi-components-theme-dark.css +474 -164
  270. package/dist/zabi-components-theme-only.css +933 -99
  271. package/dist/zabi-components-theme.css +933 -99
  272. package/dist/zabi-components.css +2275 -321
  273. package/package.json +11 -1
@@ -0,0 +1,325 @@
1
+ <script lang="ts">
2
+ import { getContext, onMount, setContext, type Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { cn } from "../util/cn.js";
5
+ import { generateId } from "../util/ssr-safe.js";
6
+ import {
7
+ CHIP_GAP,
8
+ CHIP_GROUP_CONTEXT_KEY,
9
+ SELECTED_CHIP_SELECTOR,
10
+ chipBox,
11
+ measureRowOverflow,
12
+ scrollLeftToReveal,
13
+ toggleValue,
14
+ type ChipGroupContext,
15
+ type RowMetrics,
16
+ } from "../util/chip.js";
17
+
18
+ /** Other attributes (`id`, `data-*`, `aria-label`, `aria-labelledby`, ...) land on the group element. */
19
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange" | "children"> & {
20
+ class?: string;
21
+ /**
22
+ * `wrap` breaks onto as many lines as it needs; `row` is one line that
23
+ * scrolls sideways, with the chosen chip brought into view on load.
24
+ * A group inside a group takes the layout of the one around it.
25
+ */
26
+ layout?: "wrap" | "row";
27
+ /** The group's accessible name. Not shown unless `showLabel`. */
28
+ label?: string;
29
+ /** Show `label` as text above the group (beside it, in a group inside a group). */
30
+ showLabel?: boolean;
31
+ /**
32
+ * `radio` or `checkbox`: the chips are inputs that share this group's
33
+ * `name` and `value`. Without it the group only lays out its chips,
34
+ * which are links or toggle buttons.
35
+ */
36
+ type?: "radio" | "checkbox";
37
+ /** Name the inputs submit under. */
38
+ name?: string;
39
+ /**
40
+ * What is chosen. Bindable: a string, or `undefined` for none, for
41
+ * `radio`; an array of strings for `checkbox`.
42
+ */
43
+ value?: string | string[] | undefined;
44
+ /** Disables every chip in the group. */
45
+ disabled?: boolean;
46
+ /** With `layout="row"`: `fade` dims the edge that has more chips beyond it; `none` never does. */
47
+ edge?: "fade" | "none";
48
+ /** With `layout="row"`: bring the chosen chip into view when the row appears. */
49
+ scrollSelectedIntoView?: boolean;
50
+ /** Called with the new `value` when a person changes it. */
51
+ onchange?: (value: string | string[] | undefined) => void;
52
+ children?: Snippet;
53
+ };
54
+
55
+ let {
56
+ class: className = "",
57
+ layout = "wrap",
58
+ label = "",
59
+ showLabel = false,
60
+ type,
61
+ name,
62
+ value = $bindable<Props["value"]>(),
63
+ disabled = false,
64
+ edge = "fade",
65
+ scrollSelectedIntoView = true,
66
+ onchange,
67
+ children,
68
+ ...restProps
69
+ }: Props = $props();
70
+
71
+ // No fallback on a bindable prop (see Rating): a checkbox group starts
72
+ // empty, here, so the server renders it and `bind:value={undefined}` works.
73
+ const applyDefaults = () => {
74
+ if (type === "checkbox" && value === undefined) value = [];
75
+ };
76
+ applyDefaults();
77
+ $effect.pre(applyDefaults);
78
+
79
+ /** A group inside a group is a sub-group: it names its chips and does not scroll or wrap on its own. */
80
+ const parent = getContext<ChipGroupContext | undefined>(CHIP_GROUP_CONTEXT_KEY);
81
+ const nested = parent !== undefined;
82
+ const effectiveLayout = $derived(parent ? parent.layout : layout);
83
+
84
+ function isSelected(chip: string): boolean {
85
+ if (type === "radio") return value === chip;
86
+ if (type === "checkbox") return Array.isArray(value) && value.includes(chip);
87
+ return false;
88
+ }
89
+
90
+ setContext<ChipGroupContext>(CHIP_GROUP_CONTEXT_KEY, {
91
+ get type() {
92
+ return type;
93
+ },
94
+ get name() {
95
+ return name;
96
+ },
97
+ get layout() {
98
+ return effectiveLayout;
99
+ },
100
+ get disabled() {
101
+ return disabled;
102
+ },
103
+ get radioValue() {
104
+ return type === "radio" && typeof value === "string" ? value : undefined;
105
+ },
106
+ isSelected,
107
+ change(chip, checked, report) {
108
+ if (type === "radio") {
109
+ // Single selection never clears: unchecking is not a thing a radio does.
110
+ if (!checked) return;
111
+ value = chip;
112
+ } else if (type === "checkbox") {
113
+ value = toggleValue(Array.isArray(value) ? value : [], chip, checked);
114
+ } else {
115
+ return;
116
+ }
117
+ if (report) onchange?.(value);
118
+ },
119
+ restore() {
120
+ if (type === "radio") value = undefined;
121
+ },
122
+ });
123
+
124
+ const labelId = generateId("chip-group-label");
125
+ const role = $derived(type === "radio" ? "radiogroup" : "group");
126
+ const isRow = $derived(effectiveLayout === "row");
127
+ /** Only the outermost row scrolls; a sub-group is just inline. */
128
+ const scrolls = $derived(isRow && !nested);
129
+
130
+ // ---- The one-row layout: the chosen chip in view, and the edge cue. ----
131
+
132
+ let scroller = $state<HTMLDivElement | null>(null);
133
+ let overflowStart = $state(false);
134
+ let overflowEnd = $state(false);
135
+
136
+ function metricsOf(element: HTMLElement): RowMetrics {
137
+ return {
138
+ scrollLeft: element.scrollLeft,
139
+ scrollWidth: element.scrollWidth,
140
+ clientWidth: element.clientWidth,
141
+ rtl: getComputedStyle(element).direction === "rtl",
142
+ };
143
+ }
144
+
145
+ function measure() {
146
+ if (!scroller) return;
147
+ const overflow = measureRowOverflow(metricsOf(scroller));
148
+ overflowStart = overflow.start;
149
+ overflowEnd = overflow.end;
150
+ }
151
+
152
+ /** Room kept between the revealed chip and the edge: one gap, and the fade when there is one. */
153
+ const FADE_WIDTH = 24;
154
+
155
+ /** Scrolls the row, and only the row (it sets `scrollLeft`, so the page cannot move). Not animated. */
156
+ function revealSelected() {
157
+ const row = scroller;
158
+ if (!row) return;
159
+ const selected = row.querySelector(SELECTED_CHIP_SELECTOR);
160
+ if (!selected) return;
161
+ const box = row.getBoundingClientRect();
162
+ const item = chipBox(selected).getBoundingClientRect();
163
+ row.scrollLeft = scrollLeftToReveal(
164
+ metricsOf(row),
165
+ { left: box.left, right: box.right },
166
+ { left: item.left, right: item.right },
167
+ edge === "fade" ? CHIP_GAP + FADE_WIDTH : CHIP_GAP,
168
+ );
169
+ }
170
+
171
+ let revealed = false;
172
+ onMount(() => {
173
+ if (!revealed && scroller && scrolls && scrollSelectedIntoView) revealSelected();
174
+ revealed = true;
175
+ });
176
+
177
+ $effect(() => {
178
+ const row = scroller;
179
+ if (!row || !scrolls) {
180
+ overflowStart = false;
181
+ overflowEnd = false;
182
+ return;
183
+ }
184
+ measure();
185
+ row.addEventListener("scroll", measure, { passive: true });
186
+ let sizes: ResizeObserver | undefined;
187
+ let nodes: MutationObserver | undefined;
188
+ if (typeof ResizeObserver !== "undefined") {
189
+ const sized = new ResizeObserver(measure);
190
+ sizes = sized;
191
+ const watch = () => {
192
+ sized.disconnect();
193
+ sized.observe(row);
194
+ for (const child of row.children) sized.observe(child);
195
+ };
196
+ watch();
197
+ if (typeof MutationObserver !== "undefined") {
198
+ nodes = new MutationObserver(() => {
199
+ watch();
200
+ measure();
201
+ });
202
+ nodes.observe(row, { childList: true });
203
+ }
204
+ }
205
+ return () => {
206
+ row.removeEventListener("scroll", measure);
207
+ sizes?.disconnect();
208
+ nodes?.disconnect();
209
+ };
210
+ });
211
+
212
+ const hostClasses = $derived.by(() => {
213
+ if (nested) {
214
+ return cn(isRow ? "flex shrink-0 items-center gap-2" : "flex flex-wrap items-center gap-2", className);
215
+ }
216
+ if (isRow) {
217
+ // 4px of room on every side for the focus ring, which the scrolling box
218
+ // would clip, taken back by the negative margin (as Tabs does).
219
+ return cn(
220
+ "chip-row -m-1 flex flex-nowrap items-center gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain p-1 scroll-px-8 has-[>[data-chip-subgroup]]:gap-6",
221
+ className,
222
+ );
223
+ }
224
+ return cn("flex flex-wrap items-center gap-2 has-[>[data-chip-subgroup]]:gap-x-6", className);
225
+ });
226
+ </script>
227
+
228
+ {#snippet group(labelledBy: string | undefined)}
229
+ <div
230
+ bind:this={scroller}
231
+ {role}
232
+ class={hostClasses}
233
+ aria-label={labelledBy ? undefined : label || undefined}
234
+ aria-labelledby={labelledBy}
235
+ data-chip-subgroup={nested ? "" : undefined}
236
+ data-layout={nested ? undefined : effectiveLayout}
237
+ data-overflow-start={scrolls && edge === "fade" && overflowStart ? "" : undefined}
238
+ data-overflow-end={scrolls && edge === "fade" && overflowEnd ? "" : undefined}
239
+ {...restProps}
240
+ >
241
+ {#if nested && showLabel && label}
242
+ <span id={labelId} class="shrink-0 text-sm font-medium whitespace-nowrap text-label">{label}</span>
243
+ {/if}
244
+ {@render children?.()}
245
+ </div>
246
+ {/snippet}
247
+
248
+ {#if !nested && showLabel && label}
249
+ <div class="flex flex-col gap-2">
250
+ <span id={labelId} class="text-sm font-medium text-label">{label}</span>
251
+ {@render group(labelId)}
252
+ </div>
253
+ {:else if nested && showLabel && label}
254
+ {@render group(labelId)}
255
+ {:else}
256
+ {@render group(undefined)}
257
+ {/if}
258
+
259
+ <style>
260
+ /*
261
+ * One line that scrolls sideways, with no scrollbar. The edge cue is a
262
+ * mask, not a gradient laid over the chips, so it fades them into any
263
+ * background, a brand block included, and nothing sits on a chip or its
264
+ * focus ring. Only a side with more chips beyond it fades (the component
265
+ * writes data-overflow-start and -end after it has measured), and there
266
+ * is none on the server. The mask is physical; start is the right edge
267
+ * in a right-to-left row.
268
+ */
269
+ :global(.chip-row) {
270
+ scrollbar-width: none;
271
+ }
272
+
273
+ :global(.chip-row::-webkit-scrollbar) {
274
+ display: none;
275
+ }
276
+
277
+ :global(.chip-row[data-overflow-start]),
278
+ :global(.chip-row[data-overflow-end]) {
279
+ --chip-fade-start: 0px;
280
+ --chip-fade-end: 0px;
281
+ --chip-fade-left: var(--chip-fade-start);
282
+ --chip-fade-right: var(--chip-fade-end);
283
+ }
284
+
285
+ :global(.chip-row[data-overflow-start]) {
286
+ --chip-fade-start: 1.5rem;
287
+ }
288
+
289
+ :global(.chip-row[data-overflow-end]) {
290
+ --chip-fade-end: 1.5rem;
291
+ }
292
+
293
+ :global(.chip-row[data-overflow-start]:dir(rtl)),
294
+ :global(.chip-row[data-overflow-end]:dir(rtl)) {
295
+ --chip-fade-left: var(--chip-fade-end);
296
+ --chip-fade-right: var(--chip-fade-start);
297
+ }
298
+
299
+ :global(.chip-row[data-overflow-start]),
300
+ :global(.chip-row[data-overflow-end]) {
301
+ -webkit-mask-image: linear-gradient(
302
+ to right,
303
+ transparent,
304
+ black var(--chip-fade-left),
305
+ black calc(100% - var(--chip-fade-right)),
306
+ transparent
307
+ );
308
+ mask-image: linear-gradient(
309
+ to right,
310
+ transparent,
311
+ black var(--chip-fade-left),
312
+ black calc(100% - var(--chip-fade-right)),
313
+ transparent
314
+ );
315
+ }
316
+
317
+ /* Forced colours: a half-transparent label is harder to read than a cut-off one. */
318
+ @media (forced-colors: active) {
319
+ :global(.chip-row[data-overflow-start]),
320
+ :global(.chip-row[data-overflow-end]) {
321
+ -webkit-mask-image: none;
322
+ mask-image: none;
323
+ }
324
+ }
325
+ </style>
@@ -0,0 +1,41 @@
1
+ import { type Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ /** Other attributes (`id`, `data-*`, `aria-label`, `aria-labelledby`, ...) land on the group element. */
4
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange" | "children"> & {
5
+ class?: string;
6
+ /**
7
+ * `wrap` breaks onto as many lines as it needs; `row` is one line that
8
+ * scrolls sideways, with the chosen chip brought into view on load.
9
+ * A group inside a group takes the layout of the one around it.
10
+ */
11
+ layout?: "wrap" | "row";
12
+ /** The group's accessible name. Not shown unless `showLabel`. */
13
+ label?: string;
14
+ /** Show `label` as text above the group (beside it, in a group inside a group). */
15
+ showLabel?: boolean;
16
+ /**
17
+ * `radio` or `checkbox`: the chips are inputs that share this group's
18
+ * `name` and `value`. Without it the group only lays out its chips,
19
+ * which are links or toggle buttons.
20
+ */
21
+ type?: "radio" | "checkbox";
22
+ /** Name the inputs submit under. */
23
+ name?: string;
24
+ /**
25
+ * What is chosen. Bindable: a string, or `undefined` for none, for
26
+ * `radio`; an array of strings for `checkbox`.
27
+ */
28
+ value?: string | string[] | undefined;
29
+ /** Disables every chip in the group. */
30
+ disabled?: boolean;
31
+ /** With `layout="row"`: `fade` dims the edge that has more chips beyond it; `none` never does. */
32
+ edge?: "fade" | "none";
33
+ /** With `layout="row"`: bring the chosen chip into view when the row appears. */
34
+ scrollSelectedIntoView?: boolean;
35
+ /** Called with the new `value` when a person changes it. */
36
+ onchange?: (value: string | string[] | undefined) => void;
37
+ children?: Snippet;
38
+ };
39
+ declare const ChipGroup: import("svelte").Component<Props, {}, "value">;
40
+ type ChipGroup = ReturnType<typeof ChipGroup>;
41
+ export default ChipGroup;
@@ -3,7 +3,7 @@
3
3
  import type { HTMLAttributes } from "svelte/elements";
4
4
  import ChevronDown from "@lucide/svelte/icons/chevron-down";
5
5
  import { cn } from "../util/cn.js";
6
- import { generateId } from "../util/ssr-safe.js";
6
+ import { generateId, isBrowser } from "../util/ssr-safe.js";
7
7
  import {
8
8
  COLLAPSIBLE_GROUP,
9
9
  type CollapsibleGroupContext,
@@ -13,11 +13,19 @@
13
13
  type CollapsibleTriggerState,
14
14
  } from "../util/collapsible.js";
15
15
 
16
- /** Other attributes (`id`, `data-*`, ...) land on the host `<div>`. */
16
+ /**
17
+ * A trigger, then a panel of its own that it opens. It is not for a "show
18
+ * all" under a list: there the control comes after the content, and what
19
+ * it shows is more items of a list that is already on the screen. For
20
+ * that, put a `Button` with `aria-expanded` and `aria-controls` under the
21
+ * list and let the list draw the rest (Z-127).
22
+ *
23
+ * Other attributes (`id`, `data-*`, ...) land on the host `<div>`.
24
+ */
17
25
  type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "title"> & {
18
26
  /** Extra classes for the host element. */
19
27
  class?: string;
20
- /** Extra classes for the default trigger button. */
28
+ /** Extra classes for the default trigger (a `<summary>`). */
21
29
  triggerClass?: string;
22
30
  /** Extra classes for the panel. */
23
31
  panelClass?: string;
@@ -30,18 +38,27 @@
30
38
  * a group does not close a disabled panel either.
31
39
  */
32
40
  disabled?: boolean;
33
- /** Text of the default trigger, a full-width header button with a chevron. */
41
+ /**
42
+ * Text of the default trigger, a full-width header with a chevron. It is
43
+ * a native `<details>` / `<summary>`, so it opens and closes without
44
+ * scripts: before hydration, and for a visitor who has none.
45
+ */
34
46
  title?: string;
35
47
  /**
36
48
  * Your own header, in place of the default trigger. Spread the first
37
- * argument on a `<button>`; the second carries the open state.
49
+ * argument on a `<button>`; the second carries the open state. This
50
+ * path needs scripts: the button and the `hidden` panel are yours to
51
+ * wire, and nothing opens until the page has hydrated. Use `title` for
52
+ * content that must open without scripts.
38
53
  */
39
54
  trigger?: Snippet<[CollapsibleTriggerProps, CollapsibleTriggerState]>;
40
55
  /** Wraps the default trigger in `<h1>`–`<h6>`. No heading when omitted. */
41
56
  headingLevel?: CollapsibleHeadingLevel;
42
57
  /**
43
- * Closed content is kept in the DOM under `hidden`, so a form inside it
44
- * keeps its values. Set this to remove the content while closed instead.
58
+ * Closed content stays in the DOM, inside the closed `<details>`, so a
59
+ * form inside it keeps its values and still submits them: closed
60
+ * details content is part of the form. Set this to remove the content
61
+ * while closed instead.
45
62
  */
46
63
  unmountOnClose?: boolean;
47
64
  /**
@@ -167,6 +184,67 @@
167
184
  "data-collapsible-trigger": "",
168
185
  } satisfies CollapsibleTriggerProps);
169
186
 
187
+ let detailsElement: HTMLDetailsElement | undefined = $state();
188
+
189
+ /**
190
+ * What only the server writes on the `<details>`: the `open` attribute and
191
+ * a mark that says the element came from the server. The client never
192
+ * writes `open` through the template: hydration would set it from the
193
+ * state and undo a toggle the visitor made before the scripts arrived.
194
+ * The effects below adopt that choice, then keep `open` in line.
195
+ */
196
+ const SERVER_MARK = "data-collapsible-ssr";
197
+ const serverAttributes = () =>
198
+ isBrowser() ? {} : { open: isOpen, [SERVER_MARK]: "" };
199
+
200
+ // Once, after mount: a panel toggled before hydration keeps that state.
201
+ $effect(() => {
202
+ const element = detailsElement;
203
+ if (!element?.hasAttribute(SERVER_MARK)) return;
204
+ element.removeAttribute(SERVER_MARK);
205
+ untrack(() => {
206
+ if (!disabled && element.open !== isOpen) setOpen(element.open);
207
+ });
208
+ });
209
+
210
+ // `open` follows the state: an `open` bound from outside, a group closing
211
+ // a sibling. Runs after the adoption above on the first pass.
212
+ $effect(() => {
213
+ const element = detailsElement;
214
+ if (element && element.open !== isOpen) element.open = isOpen;
215
+ });
216
+
217
+ /**
218
+ * The browser's `toggle` event is where a change made on the `<details>`
219
+ * enters: a click on the summary, Enter or Space, find-in-page opening it,
220
+ * or the native exclusive group closing it. It also fires, a task later,
221
+ * for a change this component made itself; `setOpen` then sees no change.
222
+ */
223
+ function handleToggle() {
224
+ const element = detailsElement;
225
+ if (!element) return;
226
+ if (disabled) {
227
+ // Find-in-page can still open a disabled panel: undo it.
228
+ if (element.open !== isOpen) element.open = isOpen;
229
+ return;
230
+ }
231
+ setOpen(element.open);
232
+ }
233
+
234
+ /** A summary cannot be disabled, so its click is cancelled instead. */
235
+ function handleSummaryClick(event: MouseEvent) {
236
+ if (disabled) event.preventDefault();
237
+ }
238
+
239
+ /**
240
+ * Native exclusivity (the `name` attribute) for a single-open group. A
241
+ * disabled panel stays out of it: the group neither closes nor counts it.
242
+ */
243
+ const detailsName = $derived(disabled ? undefined : group?.exclusiveName);
244
+
245
+ const rowClass =
246
+ "m-0 flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium pointer-coarse:min-h-11";
247
+
170
248
  /**
171
249
  * A region is a landmark, and a page of them is noise (the WAI-ARIA
172
250
  * accordion pattern warns against it). In a single-open group at most one
@@ -178,66 +256,92 @@
178
256
  );
179
257
  </script>
180
258
 
181
- {#snippet defaultTrigger()}
182
- <button
183
- {...triggerProps}
259
+ {#snippet defaultRow()}
260
+ <span class="min-w-0 flex-1">{title}</span>
261
+ <ChevronDown
262
+ size={16}
263
+ aria-hidden="true"
184
264
  class={cn(
185
- "focus-ring flex w-full cursor-pointer items-center justify-between gap-2 rounded-control px-3 py-2 text-start text-sm font-medium text-headline transition-colors duration-150 hover:bg-surface-hover active:bg-surface-active motion-reduce:transition-none pointer-coarse:min-h-11 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:active:bg-transparent",
186
- triggerClass,
265
+ "shrink-0 text-description transition-transform duration-150 motion-reduce:transition-none",
266
+ isOpen && "rotate-180",
187
267
  )}
188
- >
189
- <span class="min-w-0 flex-1">{title}</span>
190
- <ChevronDown
191
- size={16}
192
- aria-hidden="true"
193
- class={cn(
194
- "shrink-0 text-description transition-transform duration-150 motion-reduce:transition-none",
195
- isOpen && "rotate-180",
196
- )}
197
- />
198
- </button>
268
+ />
199
269
  {/snippet}
200
270
 
201
- <div
202
- class={cn(className)}
203
- data-state={isOpen ? "open" : "closed"}
204
- data-disabled={disabled ? "" : undefined}
205
- {...restProps}
206
- >
207
- {#if trigger}
208
- {@render trigger(triggerProps, { open: isOpen, disabled })}
209
- {:else if headingLevel === 1}
210
- <!-- One branch per level, not `<svelte:element>`: hydration takes a
211
- dynamic element out and puts it back, which blurs the trigger inside
212
- it if the user had already tabbed to it. -->
213
- <h1 class="m-0">{@render defaultTrigger()}</h1>
214
- {:else if headingLevel === 2}
215
- <h2 class="m-0">{@render defaultTrigger()}</h2>
216
- {:else if headingLevel === 3}
217
- <h3 class="m-0">{@render defaultTrigger()}</h3>
218
- {:else if headingLevel === 4}
219
- <h4 class="m-0">{@render defaultTrigger()}</h4>
220
- {:else if headingLevel === 5}
221
- <h5 class="m-0">{@render defaultTrigger()}</h5>
222
- {:else if headingLevel === 6}
223
- <h6 class="m-0">{@render defaultTrigger()}</h6>
224
- {:else}
225
- {@render defaultTrigger()}
226
- {/if}
227
-
228
- <!-- `hidden` is `display: none`: closed content takes no focus and is not
229
- in the accessibility tree. The element itself stays, so `aria-controls`
230
- always points at something. -->
271
+ {#snippet panel()}
231
272
  <div
232
273
  bind:this={panelElement}
233
274
  id={panelId}
234
275
  role={panelRole}
235
276
  aria-labelledby={triggerId}
236
- hidden={!isOpen}
277
+ hidden={trigger ? !isOpen : undefined}
237
278
  class={cn(!trigger && "px-3 pb-3 pt-1", panelClass)}
238
279
  >
239
280
  {#if isOpen || !unmountOnClose}
240
281
  {@render children?.()}
241
282
  {/if}
242
283
  </div>
284
+ {/snippet}
285
+
286
+ <div
287
+ class={cn(className)}
288
+ data-state={isOpen ? "open" : "closed"}
289
+ data-disabled={disabled ? "" : undefined}
290
+ {...restProps}
291
+ >
292
+ {#if trigger}
293
+ {@render trigger(triggerProps, { open: isOpen, disabled })}
294
+ <!-- `hidden` is `display: none`: closed content takes no focus and is not
295
+ in the accessibility tree. The element itself stays, so `aria-controls`
296
+ always points at something. -->
297
+ {@render panel()}
298
+ {:else}
299
+ <!-- A native disclosure: the browser opens and closes it, so it works
300
+ before hydration and without scripts. `open` follows `isOpen` (see
301
+ `serverAttributes`); user changes come back through `toggle`. -->
302
+ <details
303
+ bind:this={detailsElement}
304
+ {...serverAttributes()}
305
+ name={detailsName}
306
+ ontoggle={handleToggle}
307
+ >
308
+ <summary
309
+ id={triggerId}
310
+ aria-controls={panelId}
311
+ aria-disabled={disabled ? "true" : undefined}
312
+ tabindex={disabled ? -1 : undefined}
313
+ data-collapsible-trigger=""
314
+ onclick={handleSummaryClick}
315
+ class={cn(
316
+ "focus-ring block w-full cursor-pointer list-none rounded-control text-start text-sm font-medium text-headline transition-colors duration-150 hover:bg-surface-hover active:bg-surface-active motion-reduce:transition-none [&::-webkit-details-marker]:hidden",
317
+ disabled &&
318
+ "pointer-events-none cursor-not-allowed opacity-50 hover:bg-transparent active:bg-transparent",
319
+ triggerClass,
320
+ )}
321
+ >
322
+ <!-- One branch per level, not `<svelte:element>`: hydration takes
323
+ a dynamic element out and puts it back, which blurs the trigger
324
+ inside it if the user had already tabbed to it. The heading goes
325
+ inside the summary: a summary inside a heading is not valid. The
326
+ flex row is on the inner element, not on the summary, which
327
+ older Safari does not lay out as flex. -->
328
+ {#if headingLevel === 1}
329
+ <h1 class={rowClass}>{@render defaultRow()}</h1>
330
+ {:else if headingLevel === 2}
331
+ <h2 class={rowClass}>{@render defaultRow()}</h2>
332
+ {:else if headingLevel === 3}
333
+ <h3 class={rowClass}>{@render defaultRow()}</h3>
334
+ {:else if headingLevel === 4}
335
+ <h4 class={rowClass}>{@render defaultRow()}</h4>
336
+ {:else if headingLevel === 5}
337
+ <h5 class={rowClass}>{@render defaultRow()}</h5>
338
+ {:else if headingLevel === 6}
339
+ <h6 class={rowClass}>{@render defaultRow()}</h6>
340
+ {:else}
341
+ <span class={rowClass}>{@render defaultRow()}</span>
342
+ {/if}
343
+ </summary>
344
+ {@render panel()}
345
+ </details>
346
+ {/if}
243
347
  </div>