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
@@ -6,6 +6,12 @@
6
6
  import { isRtl, radioKeyIndex } from "../util/radio-keys.js";
7
7
  import type { SegmentedControlOption } from "../util/segmented-control.js";
8
8
  import { generateId } from "../util/ssr-safe.js";
9
+ import {
10
+ afterFrame,
11
+ measureIndicator,
12
+ observeSizes,
13
+ type IndicatorBox,
14
+ } from "../util/sliding-indicator.js";
9
15
 
10
16
  /** Other attributes (`data-*`, `id`, ...) land on the host element. */
11
17
  type Props = Omit<
@@ -34,6 +40,12 @@
34
40
  * form reset leaves it alone.
35
41
  */
36
42
  name?: string;
43
+ /**
44
+ * The look of the selected segment. `neutral`: a card-coloured thumb with
45
+ * the headline colour on a tinted track, which does not compete with the
46
+ * screen's main button. `primary`: a solid primary fill, as in 8.1.
47
+ */
48
+ tone?: "neutral" | "primary";
37
49
  disabled?: boolean;
38
50
  /** Called with the new value when the user picks another segment. */
39
51
  onchange?: (value: string) => void;
@@ -49,6 +61,7 @@
49
61
  size = "md",
50
62
  fullWidth = true,
51
63
  name = "",
64
+ tone = "neutral",
52
65
  disabled = false,
53
66
  onchange,
54
67
  ...restProps
@@ -78,6 +91,43 @@
78
91
  options.filter((option) => !disabled && !option.disabled),
79
92
  );
80
93
 
94
+ /**
95
+ * The sliding indicator. Until a box has been measured (on the server,
96
+ * before hydration, with no layout) it is null and the checked segment draws
97
+ * its own selected look; with a box, the host is marked ready and that look
98
+ * moves to the indicator, which slides between segments.
99
+ */
100
+ let host = $state<HTMLDivElement | null>(null);
101
+ let indicator = $state<IndicatorBox | null>(null);
102
+ /** Off for the first placement, so the indicator appears in place and does not fly in. */
103
+ let indicatorMotion = $state(false);
104
+
105
+ const faceOf = (input: HTMLInputElement | undefined) =>
106
+ input?.nextElementSibling as HTMLElement | null | undefined;
107
+
108
+ function measure() {
109
+ const face = value === undefined ? undefined : faceOf(inputs[value]);
110
+ indicator = host && face ? measureIndicator(host, face) : null;
111
+ }
112
+
113
+ // After the DOM has followed a change of selection, options, size or look.
114
+ $effect(() => {
115
+ void [options, size, fullWidth, tone, host, value];
116
+ measure();
117
+ });
118
+
119
+ // The segments wrap and resize with zoomed text, a loaded font or a narrower container.
120
+ $effect(() => {
121
+ void options;
122
+ if (!host) return;
123
+ return observeSizes([host, ...Object.values(inputs).map(faceOf)], measure);
124
+ });
125
+
126
+ $effect(() => {
127
+ if (!indicator || indicatorMotion) return;
128
+ return afterFrame(() => (indicatorMotion = true));
129
+ });
130
+
81
131
  /** The segment that takes Tab: the selected one, or the first that can be chosen. */
82
132
  const tabStop = $derived(
83
133
  enabled.find((option) => option.value === value)?.value ?? enabled[0]?.value,
@@ -128,6 +178,10 @@ one changes nothing, and the value goes with a form. -->
128
178
  <div
129
179
  class={cn("segmented", fullWidth ? "w-full" : "", className)}
130
180
  data-size={size}
181
+ data-tone={tone}
182
+ data-indicator={indicator ? "ready" : undefined}
183
+ data-indicator-motion={indicator && indicatorMotion ? "" : undefined}
184
+ bind:this={host}
131
185
  data-full-width={fullWidth ? "" : undefined}
132
186
  role="radiogroup"
133
187
  aria-label={ariaLabelledby ? undefined : label || ariaLabel}
@@ -135,6 +189,17 @@ one changes nothing, and the value goes with a form. -->
135
189
  aria-disabled={disabled ? "true" : undefined}
136
190
  {...restProps}
137
191
  >
192
+ {#if indicator}
193
+ <!-- Under the segments (it comes first and they are positioned), and never in their way. -->
194
+ <span
195
+ class="segment-indicator"
196
+ aria-hidden="true"
197
+ data-disabled={disabled || options.find((option) => option.value === value)?.disabled
198
+ ? ""
199
+ : undefined}
200
+ style="transform: translate({indicator.x}px, {indicator.y}px); width: {indicator.width}px; height: {indicator.height}px;"
201
+ ></span>
202
+ {/if}
138
203
  {#each options as option (option.value)}
139
204
  {@const optionDisabled = disabled || !!option.disabled}
140
205
  {@const Icon = option.icon}
@@ -184,6 +249,8 @@ one changes nothing, and the value goes with a form. -->
184
249
  border: 1px solid var(--color-border);
185
250
  border-radius: var(--radius-control);
186
251
  background: var(--color-action-secondary);
252
+ /* The sliding indicator is placed against the padding box. */
253
+ position: relative;
187
254
  }
188
255
 
189
256
  .segmented:not([data-full-width]) {
@@ -241,8 +308,8 @@ one changes nothing, and the value goes with a form. -->
241
308
  line-height: 1.25;
242
309
  text-align: center;
243
310
  transition:
244
- background-color 150ms,
245
- color 150ms;
311
+ background-color var(--duration-base),
312
+ color var(--duration-base);
246
313
  }
247
314
 
248
315
  /* A long label wraps onto a second line, and breaks inside a word only when a word alone does not fit. */
@@ -263,9 +330,64 @@ one changes nothing, and the value goes with a form. -->
263
330
  background: var(--color-surface-active);
264
331
  }
265
332
 
333
+ /*
334
+ * The selected look, drawn by the segment itself: before the page hydrates,
335
+ * and wherever the indicator cannot be measured. Neutral is a thumb: the
336
+ * card colour with a soft shadow and an edge (the edge, held to 3:1 against
337
+ * the track, is what tells it from the others; the fill is only a step).
338
+ */
266
339
  input:checked + .segment-face {
340
+ background: var(--color-segment-thumb);
341
+ color: var(--color-segment-thumb-text);
342
+ box-shadow:
343
+ var(--shadow-sm),
344
+ 0 0 0 1px var(--color-segment-thumb-border);
345
+ }
346
+
347
+ :where(.segmented[data-tone="primary"]) input:checked + .segment-face {
267
348
  background: var(--color-action-primary);
268
349
  color: var(--color-action-primary-text);
350
+ box-shadow: none;
351
+ }
352
+
353
+ /* With an indicator the segment keeps only its label colour. */
354
+ @media (forced-colors: none) {
355
+ :where(.segmented[data-indicator="ready"]) input:checked + .segment-face {
356
+ background: transparent;
357
+ box-shadow: none;
358
+ }
359
+ }
360
+
361
+ .segment-indicator {
362
+ position: absolute;
363
+ top: 0;
364
+ left: 0;
365
+ pointer-events: none;
366
+ border-radius: calc(var(--radius-control) - var(--zabi-segment-inset) - 1px);
367
+ background: var(--color-segment-thumb);
368
+ box-shadow:
369
+ var(--shadow-sm),
370
+ 0 0 0 1px var(--color-segment-thumb-border);
371
+ }
372
+
373
+ .segmented[data-tone="primary"] .segment-indicator {
374
+ background: var(--color-action-primary);
375
+ box-shadow: none;
376
+ }
377
+
378
+ /* Only once the first placement is on screen: the thumb then slides and resizes. */
379
+ .segmented[data-indicator-motion] .segment-indicator {
380
+ transition:
381
+ transform var(--duration-moderate) var(--ease-spring),
382
+ width var(--duration-moderate) var(--ease-spring),
383
+ height var(--duration-moderate) var(--ease-spring);
384
+ }
385
+
386
+ /* Forced colours keep the segment's own Highlight look below. */
387
+ @media (forced-colors: active) {
388
+ .segment-indicator {
389
+ display: none;
390
+ }
269
391
  }
270
392
 
271
393
  /* Same geometry as `.focus-ring`: a 2px gap, then a 2px ring. */
@@ -283,6 +405,16 @@ one changes nothing, and the value goes with a form. -->
283
405
  opacity: 0.5;
284
406
  }
285
407
 
408
+ /* A disabled neutral thumb keeps its edge at full strength, so it is still seen; only its label dims. */
409
+ :where(.segmented[data-tone="neutral"]) input:checked:disabled + .segment-face {
410
+ opacity: 1;
411
+ color: color-mix(in srgb, var(--color-segment-thumb-text) 50%, transparent);
412
+ }
413
+
414
+ .segmented[data-tone="primary"] .segment-indicator[data-disabled] {
415
+ opacity: 0.5;
416
+ }
417
+
286
418
  /*
287
419
  * Forced colours paint every background with the canvas colour and drop
288
420
  * every box-shadow, which would leave the selected segment looking like
@@ -316,7 +448,8 @@ one changes nothing, and the value goes with a form. -->
316
448
  }
317
449
 
318
450
  @media (prefers-reduced-motion: reduce) {
319
- .segment-face {
451
+ .segment-face,
452
+ .segmented[data-indicator-motion] .segment-indicator {
320
453
  transition: none;
321
454
  }
322
455
  }
@@ -25,6 +25,12 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange" | "aria-l
25
25
  * form reset leaves it alone.
26
26
  */
27
27
  name?: string;
28
+ /**
29
+ * The look of the selected segment. `neutral`: a card-coloured thumb with
30
+ * the headline colour on a tinted track, which does not compete with the
31
+ * screen's main button. `primary`: a solid primary fill, as in 8.1.
32
+ */
33
+ tone?: "neutral" | "primary";
28
34
  disabled?: boolean;
29
35
  /** Called with the new value when the user picks another segment. */
30
36
  onchange?: (value: string) => void;
@@ -91,7 +91,7 @@
91
91
  {#if showFooter}
92
92
  <div class="w-full shrink-0" data-sidebar-flyout-root>
93
93
  <footer
94
- class={cn(`flex w-full shrink-0 flex-col gap-3 border-t border-border pt-3 pb-1 ${className}`)}
94
+ class={cn(`flex w-full shrink-0 flex-col gap-3 border-t border-border-weak pt-3 pb-1 ${className}`)}
95
95
  aria-label={text.accountAndSettings}
96
96
  >
97
97
  {#if showPanelLauncher}
@@ -41,7 +41,7 @@
41
41
  <div class={cn(`flex w-full flex-col gap-2 ${className}`)}>
42
42
  {#if showHeading && headingId}
43
43
  <h2
44
- class="px-0 text-xs font-semibold uppercase tracking-wider text-description"
44
+ class="px-0 text-xs font-medium text-description"
45
45
  id={headingId}
46
46
  >
47
47
  {trimmedTitle}
@@ -549,6 +549,7 @@
549
549
  aria-label={text.handleLabel(label)}
550
550
  aria-describedby={descriptionId}
551
551
  disabled={rowDisabled}
552
+ data-sheet-no-drag
552
553
  data-sortable-handle
553
554
  onpointerdown={(event) => handlePointerDown(event, entry)}
554
555
  onclick={handleHandleClick}
@@ -6,6 +6,7 @@
6
6
  import type { HTMLAttributes } from "svelte/elements";
7
7
  import type { SizeVariant } from "../types/variants.js";
8
8
  import { cn } from "../util/cn.js";
9
+ import { inSkeletonGroup } from "../util/skeleton.js";
9
10
  import {
10
11
  STEPPER_STRINGS,
11
12
  clampStep,
@@ -100,6 +101,9 @@
100
101
  bodies[index]?.focus();
101
102
  }
102
103
 
104
+ /** Inside a `SkeletonGroup` the group is the one status; a second one in it would be a live region in a live region. */
105
+ const inGroup = inSkeletonGroup();
106
+
103
107
  /** Read out once when the current step changes, and never for the first render. */
104
108
  let announced = $state("");
105
109
  let previous: number | undefined;
@@ -202,7 +206,9 @@
202
206
  {/if}
203
207
  </div>
204
208
  </div>
205
- <span class="sr-only" role="status" data-stepper-status>{announced}</span>
209
+ {#if !inGroup}
210
+ <span class="sr-only" role="status" data-stepper-status>{announced}</span>
211
+ {/if}
206
212
  </nav>
207
213
  {/if}
208
214
 
@@ -375,8 +381,8 @@
375
381
  }
376
382
 
377
383
  /*
378
- * Completed and current are filled, upcoming is an outline: a difference
379
- * of shape. Completed has a check where the others have their number, and
384
+ * Completed and current are filled in the brand colour, upcoming is the
385
+ * lighter track: a difference of shape too. Completed has a check where the others have their number, and
380
386
  * the current one stands inside a ring of its own.
381
387
  */
382
388
  .stepper-marker {
@@ -419,11 +425,19 @@
419
425
  background-clip: content-box;
420
426
  }
421
427
 
422
- /* The edge is all there is of an upcoming marker, so it is the control
423
- boundary: 3:1 or better on every surface level of both themes. */
428
+ /*
429
+ * An upcoming marker is the progress track: a filled pill in the bar
430
+ * layout, with the track's own edge. An app that themes the progress
431
+ * track (an edgeless one that reaches 3:1 on its own) gets the same here.
432
+ * `--zabi-stepper-upcoming-fill` and `--zabi-stepper-upcoming-edge` are
433
+ * declared nowhere, so any ancestor can set them. The 8.1 look, a hollow
434
+ * outline, is
435
+ * `--zabi-stepper-upcoming-fill: transparent;
436
+ * --zabi-stepper-upcoming-edge: var(--color-control-border);`
437
+ */
424
438
  .stepper-step[data-state="upcoming"] .stepper-marker {
425
- border-color: var(--color-control-border);
426
- background-color: transparent;
439
+ border-color: var(--zabi-stepper-upcoming-edge, var(--color-progress-track-border));
440
+ background-color: var(--zabi-stepper-upcoming-fill, var(--color-progress-track));
427
441
  color: var(--color-description);
428
442
  }
429
443
 
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import type { HTMLAttributes } from "svelte/elements";
4
- import { findScrollParent } from "../util/app-shell.js";
4
+ import { findScrollParent, getAppShell } from "../util/app-shell.js";
5
5
  import { cn } from "../util/cn.js";
6
6
  import { watchKeyboardInset } from "../util/keyboard-inset.js";
7
7
  import { reserveScrollPaddingBottom } from "../util/scroll-padding.js";
8
+ import { resolveScrolledUnder, watchScrollEdge, type ScrollEdgeMode } from "../util/scroll-edge.js";
8
9
 
9
10
  /**
10
11
  * Keeps a form's main button on screen: at the bottom of whatever
@@ -47,7 +48,12 @@
47
48
  *
48
49
  * In an `AppShell`, a screen with a form is a task of its own: leave the
49
50
  * tab bar out (no `footer`) and let this bar be the bottom of the screen.
50
- * With a tab bar it sits directly above it.
51
+ * With a tab bar it sits directly above it: the shell's footer lies over
52
+ * the scroller, and the bar sticks to the footer's top edge, not under it.
53
+ *
54
+ * The bar is flush with the page and page-coloured while the form ends
55
+ * where the bar is; once content is under it, it turns to frosted glass
56
+ * with a hairline above it.
51
57
  *
52
58
  * `UnsavedChangesBar` is the other bar at the bottom of a form. That one
53
59
  * appears only while there is something to save, announces itself, and
@@ -60,6 +66,13 @@
60
66
  * says what the buttons belong to; without it, it is a plain box.
61
67
  */
62
68
  label?: string;
69
+ /**
70
+ * When the bar shows the glass and the hairline. `auto` follows the
71
+ * scroll position: flush at the end of the content, glass while
72
+ * content is under it. `always` is glass all the time, `never` flush
73
+ * all the time.
74
+ */
75
+ scrollEdge?: ScrollEdgeMode;
63
76
  class?: string;
64
77
  /** Added after the offset and margin the bar sets while the keyboard is up. */
65
78
  style?: string;
@@ -67,9 +80,35 @@
67
80
  children?: Snippet;
68
81
  };
69
82
 
70
- let { label, class: className = "", style = "", children, ...restProps }: Props = $props();
83
+ let {
84
+ label,
85
+ scrollEdge = "auto",
86
+ class: className = "",
87
+ style = "",
88
+ children,
89
+ ...restProps
90
+ }: Props = $props();
71
91
 
72
92
  let host: HTMLDivElement | undefined = $state();
93
+
94
+ /**
95
+ * In a shell with a footer the footer lies over the bottom of the scroller
96
+ * by this much: the bar sticks above it, and the box it scrolls in
97
+ * extends under it.
98
+ */
99
+ const shell = getAppShell();
100
+ const underFooter = $derived(shell?.footerOverlays === true);
101
+ const footerHeight = $derived(shell?.footerHeight ?? 0);
102
+
103
+ /** Content is under the bar: from the shell if there is one, else from its own scroller. */
104
+ let ownScrolled = $state(false);
105
+ $effect(() => {
106
+ if (shell || scrollEdge !== "auto" || !host) return;
107
+ return watchScrollEdge(findScrollParent(host), (edge) => (ownScrolled = edge.bottom));
108
+ });
109
+ const scrolledUnder = $derived(
110
+ resolveScrolledUnder(scrollEdge, shell ? shell.scrolledBottom : ownScrolled),
111
+ );
73
112
  /** Height of the keyboard over the page, in px. 0 on the server and where the page shrinks by itself. */
74
113
  let keyboardInset = $state(0);
75
114
  /**
@@ -151,7 +190,9 @@
151
190
  $effect(() => {
152
191
  const bar = host;
153
192
  if (!bar) return;
154
- const raised = lift;
193
+ // The bar's own offset from the bottom of the box: what the keyboard
194
+ // lifts it by, or the footer it sits above, whichever is more.
195
+ const raised = Math.max(lift, footerHeight);
155
196
  const container = findScrollParent(bar) ?? document.documentElement;
156
197
  const reservation = reserveScrollPaddingBottom(container);
157
198
  const reserve = () => {
@@ -168,6 +209,21 @@
168
209
  });
169
210
 
170
211
  const keyboardOpen = $derived(keyboardInset > 0);
212
+
213
+ /**
214
+ * Where it sticks. On its own the box ends at the bottom and the bar is
215
+ * lifted by `lift`. Over a footer it sticks above the footer, and the
216
+ * keyboard lifts it only as far as it reaches past the footer: the box
217
+ * extends under the footer, so `lift` counts from the box's bottom.
218
+ */
219
+ const offsetStyle = $derived.by(() => {
220
+ const overlay = "var(--app-shell-footer-overlay, 0px)";
221
+ if (!underFooter) return lift > 0 ? `bottom: ${lift}px; margin-bottom: ${lift}px;` : "";
222
+ if (lift > 0) {
223
+ return `bottom: max(${lift}px, ${overlay}); margin-bottom: max(0px, calc(${lift}px - ${overlay}));`;
224
+ }
225
+ return `bottom: ${overlay};`;
226
+ });
171
227
  </script>
172
228
 
173
229
  <!-- Nothing here takes focus by itself, and the bar never moves focus. -->
@@ -176,15 +232,18 @@
176
232
  role={label ? "group" : undefined}
177
233
  aria-label={label}
178
234
  data-keyboard-open={keyboardOpen ? "true" : undefined}
235
+ data-bar-edge="bottom"
236
+ data-scrolled-under={String(scrolledUnder)}
179
237
  class={cn(
180
238
  // `mt-auto`: in a column taller than its fields, the bar goes to the bottom.
181
- "sticky bottom-0 z-sticky mt-auto flex flex-wrap items-center justify-end gap-[8px] border-t border-border-weak bg-surface-elevated pt-3",
239
+ "material-bar sticky bottom-0 z-sticky mt-auto flex flex-wrap items-center justify-end gap-[8px] pt-3",
182
240
  "pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))]",
183
- // The home indicator is under the keyboard while that is up.
184
- keyboardOpen ? "pb-3" : "pb-[max(0.75rem,env(safe-area-inset-bottom))]",
241
+ // The home indicator is under the keyboard while that is up, and under
242
+ // the footer, which already clears it, when there is one.
243
+ keyboardOpen || underFooter ? "pb-3" : "pb-[max(0.75rem,env(safe-area-inset-bottom))]",
185
244
  className,
186
245
  )}
187
- style="{lift > 0 ? `bottom: ${lift}px; margin-bottom: ${lift}px;` : ''} {style}"
246
+ style="{offsetStyle} {style}"
188
247
  {...restProps}
189
248
  >
190
249
  {@render children?.()}
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from "svelte";
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
+ import { type ScrollEdgeMode } from "../util/scroll-edge.js";
3
4
  /**
4
5
  * Keeps a form's main button on screen: at the bottom of whatever
5
6
  * scrolls, and above the on-screen keyboard when that is up.
@@ -41,7 +42,12 @@ import type { HTMLAttributes } from "svelte/elements";
41
42
  *
42
43
  * In an `AppShell`, a screen with a form is a task of its own: leave the
43
44
  * tab bar out (no `footer`) and let this bar be the bottom of the screen.
44
- * With a tab bar it sits directly above it.
45
+ * With a tab bar it sits directly above it: the shell's footer lies over
46
+ * the scroller, and the bar sticks to the footer's top edge, not under it.
47
+ *
48
+ * The bar is flush with the page and page-coloured while the form ends
49
+ * where the bar is; once content is under it, it turns to frosted glass
50
+ * with a hairline above it.
45
51
  *
46
52
  * `UnsavedChangesBar` is the other bar at the bottom of a form. That one
47
53
  * appears only while there is something to save, announces itself, and
@@ -54,6 +60,13 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "style"> & {
54
60
  * says what the buttons belong to; without it, it is a plain box.
55
61
  */
56
62
  label?: string;
63
+ /**
64
+ * When the bar shows the glass and the hairline. `auto` follows the
65
+ * scroll position: flush at the end of the content, glass while
66
+ * content is under it. `always` is glass all the time, `never` flush
67
+ * all the time.
68
+ */
69
+ scrollEdge?: ScrollEdgeMode;
57
70
  class?: string;
58
71
  /** Added after the offset and margin the bar sets while the keyboard is up. */
59
72
  style?: string;
@@ -2,6 +2,12 @@
2
2
  import type { Snippet } from "svelte";
3
3
  import { generateId } from "../util/ssr-safe.js";
4
4
  import { cn } from "../util/cn.js";
5
+ import {
6
+ afterFrame,
7
+ measureIndicator,
8
+ observeSizes,
9
+ type IndicatorBox,
10
+ } from "../util/sliding-indicator.js";
5
11
 
6
12
  interface Props {
7
13
  /** Extra classes for the host element. */
@@ -140,6 +146,50 @@
140
146
  return () => observer.disconnect();
141
147
  });
142
148
 
149
+ /**
150
+ * The sliding indicator: the underline, or the pill. Until a box has been
151
+ * measured (on the server, before hydration, with no layout) it is null and
152
+ * the selected tab draws its own mark; with a box, that mark moves to the
153
+ * indicator, which slides between tabs. It lives in the scrolling list and
154
+ * is placed against the list's content, so it stays on its tab as the list
155
+ * scrolls.
156
+ */
157
+ let indicator = $state<IndicatorBox | null>(null);
158
+ /** Off for the first placement, so the indicator appears in place and does not fly in. */
159
+ let indicatorMotion = $state(false);
160
+
161
+ function measure() {
162
+ const tab = tabElements[activeTab];
163
+ indicator = listElement && tab ? measureIndicator(listElement, tab) : null;
164
+ }
165
+
166
+ $effect(() => {
167
+ void [tabs, activeTab, variant, fullWidth, listElement];
168
+ measure();
169
+ });
170
+
171
+ // The tabs resize with a loaded font, zoomed text or a wider row.
172
+ $effect(() => {
173
+ const list = listElement;
174
+ void tabs.length;
175
+ if (!list) return;
176
+ return observeSizes([list, ...Object.values(tabElements)], measure);
177
+ });
178
+
179
+ $effect(() => {
180
+ if (!indicator || indicatorMotion) return;
181
+ return afterFrame(() => (indicatorMotion = true));
182
+ });
183
+
184
+ /** The underline is the bottom 2px of the tab's box; the pill is all of it. */
185
+ const indicatorStyle = $derived.by(() => {
186
+ if (!indicator) return "";
187
+ const { x, y, width, height } = indicator;
188
+ const line = variant === "pills" ? height : 2;
189
+ const top = variant === "pills" ? y : y + height - 2;
190
+ return `transform: translate(${x}px, ${top}px); width: ${width}px; height: ${line}px;`;
191
+ });
192
+
143
193
  /**
144
194
  * Arrow keys and Tab move focus to a tab that may be out of view. Keyboard
145
195
  * focus only: a press focuses the tab on the way down, and moving the list
@@ -211,7 +261,7 @@
211
261
  }
212
262
 
213
263
  const TAB_BASE =
214
- "focus-ring cursor-pointer border-b-2 px-4 py-2 pointer-coarse:min-h-11 text-sm font-medium transition-colors focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed";
264
+ "focus-ring relative cursor-pointer border-b-2 px-4 py-2 pointer-coarse:min-h-11 text-sm font-medium transition-colors focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed";
215
265
  // A tab keeps its width and the list scrolls; sharing the row, it may shrink and its label wrap.
216
266
  const TAB_NATURAL = "shrink-0 whitespace-nowrap";
217
267
  // `min-w-min` is the tab's longest word: a label wraps between words, never
@@ -231,9 +281,20 @@
231
281
  const TAB_SELECTED_PILL =
232
282
  "border-brand-500 bg-action-primary-subtle text-link active:bg-action-primary-subtle-active";
233
283
 
284
+ // With the indicator the tab keeps its label colour and its pressed fill
285
+ // only. Forced colours drop backgrounds, so the underline of the tab itself
286
+ // stays there and the indicator is hidden.
287
+ const TAB_SELECTED_MOVED =
288
+ "border-brand-500 not-forced-colors:border-transparent text-body active:bg-surface-active";
289
+ const TAB_SELECTED_PILL_MOVED =
290
+ "border-brand-500 not-forced-colors:border-transparent text-link active:bg-action-primary-subtle-active";
291
+
234
292
  function tabClasses(tabId: string): string {
235
293
  const base = `${TAB_BASE} ${fullWidth ? TAB_SHARED : TAB_NATURAL}`;
236
294
  if (activeTab !== tabId) return `${base} ${TAB_IDLE}`;
295
+ if (indicator) {
296
+ return `${base} ${variant === "pills" ? TAB_SELECTED_PILL_MOVED : TAB_SELECTED_MOVED}`;
297
+ }
237
298
  return `${base} ${variant === "pills" ? TAB_SELECTED_PILL : TAB_SELECTED}`;
238
299
  }
239
300
  </script>
@@ -253,7 +314,7 @@
253
314
  <!-- `tabindex="-1"`: a scrolling box is otherwise a Tab stop of its own. -->
254
315
  <div
255
316
  bind:this={listElement}
256
- class="tabs-list flex"
317
+ class="tabs-list relative flex"
257
318
  role="presentation"
258
319
  tabindex="-1"
259
320
  data-fade-left={fadeLeft ? "" : undefined}
@@ -261,6 +322,22 @@
261
322
  onfocusin={handleFocusIn}
262
323
  onscroll={updateFades}
263
324
  >
325
+ {#if indicator}
326
+ <!-- Under the tabs (they are positioned and come later), and never in their way. -->
327
+ <span
328
+ class={cn(
329
+ "pointer-events-none absolute top-0 left-0 forced-colors:hidden",
330
+ variant === "pills" ? "bg-action-primary-subtle border-b-2 border-brand-500" : "bg-brand-500",
331
+ // The underline must not overshoot; the pill may spring.
332
+ indicatorMotion &&
333
+ "transition-[transform,width,height] duration-(--duration-moderate) motion-reduce:transition-none",
334
+ indicatorMotion && (variant === "pills" ? "ease-spring" : "ease-standard"),
335
+ )}
336
+ aria-hidden="true"
337
+ data-indicator={variant}
338
+ style={indicatorStyle}
339
+ ></span>
340
+ {/if}
264
341
  {#each tabs as tab (tab.id)}
265
342
  <button
266
343
  bind:this={tabElements[tab.id]}