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
@@ -4,7 +4,7 @@
4
4
  import Star from "@lucide/svelte/icons/star";
5
5
  import X from "@lucide/svelte/icons/x";
6
6
  import type { HTMLAttributes } from "svelte/elements";
7
- import type { SizeVariant } from "../types/variants.js";
7
+ import { isDevBuild } from "../util/app-shell.js";
8
8
  import { cn } from "../util/cn.js";
9
9
  import { resetValueOf, groupBinding } from "../util/hydration.js";
10
10
  import { isRtl, radioKeyIndex } from "../util/radio-keys.js";
@@ -18,6 +18,9 @@
18
18
  } from "../util/rating.js";
19
19
  import { generateId } from "../util/ssr-safe.js";
20
20
 
21
+ /** Sizes of a rating. `xl` and `display` are for a large input or a compact score. */
22
+ type RatingSize = "sm" | "md" | "lg" | "xl" | "display";
23
+
21
24
  /** Other attributes (`data-*`, `id`, ...) land on the host element. */
22
25
  type Props = Omit<
23
26
  HTMLAttributes<HTMLDivElement>,
@@ -37,8 +40,12 @@
37
40
  "aria-label"?: string;
38
41
  /** Id of the element that names the rating, when there is no `label`. */
39
42
  "aria-labelledby"?: string;
40
- /** Size of a star. An interactive star is a 44px target at every size. */
41
- size?: SizeVariant;
43
+ /**
44
+ * Size of a star. An interactive star is a 44px target at every size.
45
+ * `xl` is 40px stars. `display` is a display-size score with `compact`
46
+ * (a 40px star and a 48px number); as a row of stars it is `xl`.
47
+ */
48
+ size?: RatingSize;
42
49
  /**
43
50
  * Colour of a filled star: the primary action colour, or the app's
44
51
  * accent (`--color-accent`). An app can also set the colours itself:
@@ -62,6 +69,22 @@
62
69
  * around it: it submits nothing and a form reset leaves it alone.
63
70
  */
64
71
  name?: string;
72
+ /**
73
+ * A score for a list, not a control: one filled star and the number
74
+ * ("4,0"), from the real `value` and `max`, with one accessible name
75
+ * ("4 of 5 stars"). Implies `readonly`: `clearable`, `name` and
76
+ * `onchange` are ignored, and `showValue` too (the number is the point).
77
+ */
78
+ compact?: boolean;
79
+ /**
80
+ * What an empty star looks like: a hollow `outline` (default), or a
81
+ * `soft` fill (no outline) in `--zabi-rating-off-fill`, falling back to
82
+ * `--color-control-track`. The soft fill is below 3:1 against the page
83
+ * by default, so it suits a rating that also has a visible label and
84
+ * value. `--zabi-rating-off-fill` sets the colour; it is declared
85
+ * nowhere, like `--zabi-rating-on`. In forced colours it is an outline.
86
+ */
87
+ empty?: "outline" | "soft";
65
88
  /** Shows the number beside the stars. On by default when `readonly`. */
66
89
  showValue?: boolean;
67
90
  /** Turns the value into text for a locale ("3,5"). At most one decimal by default. */
@@ -86,6 +109,8 @@
86
109
  clearable = false,
87
110
  disabled = false,
88
111
  name = "",
112
+ compact = false,
113
+ empty = "outline",
89
114
  showValue,
90
115
  formatValue,
91
116
  strings,
@@ -104,6 +129,13 @@
104
129
  applyDefaults();
105
130
  $effect.pre(applyDefaults);
106
131
 
132
+ $effect(() => {
133
+ if (!isDevBuild() || !compact || !(clearable || name)) return;
134
+ console.warn(
135
+ "[zabi-components] Rating: `compact` is a read-only score; `clearable` and `name` are ignored.",
136
+ );
137
+ });
138
+
107
139
  const baseId = generateId("rating");
108
140
  const labelId = `${baseId}-label`;
109
141
  const valueId = `${baseId}-value`;
@@ -127,6 +159,9 @@
127
159
  const shown = $derived(clampRating(value, count));
128
160
  const valueShown = $derived(showValue ?? readonly);
129
161
  const labelVisible = $derived(!!label && !hideLabel);
162
+ /** A row of stars has no `display` size: it is `xl`. */
163
+ const starSize = $derived(size === "display" ? "xl" : size);
164
+ const softEmpty = $derived(empty === "soft" ? "soft" : undefined);
130
165
 
131
166
  function format(current: number): string {
132
167
  return formatValue ? formatValue(current) : defaultRatingFormat(current);
@@ -214,13 +249,48 @@
214
249
  </span>
215
250
  {/snippet}
216
251
 
217
- {#if readonly}
252
+ {#if compact}
253
+ <!-- A score: one star and the number, one image with one name as `readonly`. -->
254
+ <div
255
+ class={cn("rating inline-flex items-center", className)}
256
+ data-size={size}
257
+ data-tone={tone}
258
+ data-empty={softEmpty}
259
+ data-compact
260
+ data-readonly
261
+ role="img"
262
+ aria-label={ariaLabelledby
263
+ ? undefined
264
+ : [label || ariaLabel, valueLabel].filter(Boolean).join(", ")}
265
+ aria-labelledby={ariaLabelledby ? `${ariaLabelledby} ${valueId}` : undefined}
266
+ {...restProps}
267
+ >
268
+ {#if labelVisible}
269
+ <span class="me-2 text-sm font-medium text-label">{label}</span>
270
+ {/if}
271
+ {@render glyph(shown === null ? 0 : 1)}
272
+ <span
273
+ class={cn(
274
+ "rating-compact-value font-semibold tabular-nums",
275
+ size === "display" && "font-display font-bold",
276
+ shown === null ? "text-description" : "text-headline",
277
+ )}
278
+ data-rating-value
279
+ >
280
+ {shown === null ? text.noRating : valueText}
281
+ </span>
282
+ {#if ariaLabelledby}
283
+ <span id={valueId} class="sr-only">{valueLabel}</span>
284
+ {/if}
285
+ </div>
286
+ {:else if readonly}
218
287
  <!-- One image with one name. Its children, the visible label and number
219
288
  included, are presentational, so nothing is read twice. -->
220
289
  <div
221
290
  class={cn("rating", className)}
222
- data-size={size}
291
+ data-size={starSize}
223
292
  data-tone={tone}
293
+ data-empty={softEmpty}
224
294
  data-readonly
225
295
  role="img"
226
296
  aria-label={ariaLabelledby
@@ -249,8 +319,9 @@
249
319
  {:else}
250
320
  <div
251
321
  class={cn("rating", disabled && "opacity-50", className)}
252
- data-size={size}
322
+ data-size={starSize}
253
323
  data-tone={tone}
324
+ data-empty={softEmpty}
254
325
  {...restProps}
255
326
  >
256
327
  {#if labelVisible}
@@ -371,6 +442,49 @@
371
442
  --zabi-rating-target: max(48px, var(--zabi-rating-star) + 8px);
372
443
  }
373
444
 
445
+ .rating[data-size="xl"] {
446
+ --zabi-rating-star: 2.5rem;
447
+ --zabi-rating-target: max(48px, var(--zabi-rating-star) + 8px);
448
+ }
449
+
450
+ /* A compact score: the star and the number share one size per step. */
451
+ .rating[data-compact] {
452
+ --zabi-rating-text: 1.5rem;
453
+ gap: 4px;
454
+ }
455
+
456
+ .rating[data-compact][data-size="sm"] {
457
+ --zabi-rating-star: 14px;
458
+ --zabi-rating-text: 14px;
459
+ }
460
+
461
+ .rating[data-compact][data-size="md"] {
462
+ --zabi-rating-star: 16px;
463
+ --zabi-rating-text: 16px;
464
+ }
465
+
466
+ .rating[data-compact][data-size="lg"] {
467
+ --zabi-rating-star: 20px;
468
+ --zabi-rating-text: 20px;
469
+ }
470
+
471
+ .rating[data-compact][data-size="xl"] {
472
+ --zabi-rating-star: 28px;
473
+ --zabi-rating-text: 30px;
474
+ gap: 6px;
475
+ }
476
+
477
+ .rating[data-compact][data-size="display"] {
478
+ --zabi-rating-star: 40px;
479
+ --zabi-rating-text: 48px;
480
+ gap: 6px;
481
+ }
482
+
483
+ .rating-compact-value {
484
+ font-size: var(--zabi-rating-text);
485
+ line-height: 1;
486
+ }
487
+
374
488
  .rating-stars {
375
489
  display: inline-flex;
376
490
  flex-wrap: wrap;
@@ -424,6 +538,12 @@
424
538
  color: var(--zabi-rating-off, var(--_rating-off));
425
539
  }
426
540
 
541
+ /* A soft empty star is a filled shape with no outline. */
542
+ .rating[data-empty="soft"] .rating-glyph :global(.rating-glyph-empty) {
543
+ fill: var(--zabi-rating-off-fill, var(--color-control-track));
544
+ stroke: none;
545
+ }
546
+
427
547
  /* Anchored at the start, so the filled part is on the right in a right-to-left layout. */
428
548
  .rating-glyph-fill {
429
549
  position: absolute;
@@ -484,6 +604,12 @@
484
604
  .rating-glyph :global(.rating-glyph-empty) {
485
605
  color: CanvasText;
486
606
  }
607
+
608
+ /* A soft star is hollow here, so it still differs from a filled one. */
609
+ .rating[data-empty="soft"] .rating-glyph :global(.rating-glyph-empty) {
610
+ fill: none;
611
+ stroke: CanvasText;
612
+ }
487
613
  }
488
614
 
489
615
  @media (prefers-reduced-motion: reduce) {
@@ -1,6 +1,7 @@
1
1
  import type { HTMLAttributes } from "svelte/elements";
2
- import type { SizeVariant } from "../types/variants.js";
3
2
  import { type RatingStrings } from "../util/rating.js";
3
+ /** Sizes of a rating. `xl` and `display` are for a large input or a compact score. */
4
+ type RatingSize = "sm" | "md" | "lg" | "xl" | "display";
4
5
  /** Other attributes (`data-*`, `id`, ...) land on the host element. */
5
6
  type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange" | "aria-label" | "aria-labelledby"> & {
6
7
  /** Extra classes for the host element. */
@@ -17,8 +18,12 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange" | "aria-l
17
18
  "aria-label"?: string;
18
19
  /** Id of the element that names the rating, when there is no `label`. */
19
20
  "aria-labelledby"?: string;
20
- /** Size of a star. An interactive star is a 44px target at every size. */
21
- size?: SizeVariant;
21
+ /**
22
+ * Size of a star. An interactive star is a 44px target at every size.
23
+ * `xl` is 40px stars. `display` is a display-size score with `compact`
24
+ * (a 40px star and a 48px number); as a row of stars it is `xl`.
25
+ */
26
+ size?: RatingSize;
22
27
  /**
23
28
  * Colour of a filled star: the primary action colour, or the app's
24
29
  * accent (`--color-accent`). An app can also set the colours itself:
@@ -42,6 +47,22 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onchange" | "aria-l
42
47
  * around it: it submits nothing and a form reset leaves it alone.
43
48
  */
44
49
  name?: string;
50
+ /**
51
+ * A score for a list, not a control: one filled star and the number
52
+ * ("4,0"), from the real `value` and `max`, with one accessible name
53
+ * ("4 of 5 stars"). Implies `readonly`: `clearable`, `name` and
54
+ * `onchange` are ignored, and `showValue` too (the number is the point).
55
+ */
56
+ compact?: boolean;
57
+ /**
58
+ * What an empty star looks like: a hollow `outline` (default), or a
59
+ * `soft` fill (no outline) in `--zabi-rating-off-fill`, falling back to
60
+ * `--color-control-track`. The soft fill is below 3:1 against the page
61
+ * by default, so it suits a rating that also has a visible label and
62
+ * value. `--zabi-rating-off-fill` sets the colour; it is declared
63
+ * nowhere, like `--zabi-rating-on`. In forced colours it is an outline.
64
+ */
65
+ empty?: "outline" | "soft";
45
66
  /** Shows the number beside the stars. On by default when `readonly`. */
46
67
  showValue?: boolean;
47
68
  /** Turns the value into text for a locale ("3,5"). At most one decimal by default. */
@@ -17,6 +17,7 @@
17
17
  import { generateId } from "../util/ssr-safe.js";
18
18
  import { isInsideToastRegion } from "../util/focus-utils.js";
19
19
  import { cn } from "../util/cn.js";
20
+ import { FIELD_CONTROL_SURFACE, fieldControlEdge, fieldControlSize } from "../util/field-control.js";
20
21
  import { fieldDescribedBy, fieldMessageState } from "../util/field.js";
21
22
  import FieldMessages from "./FieldMessages.svelte";
22
23
 
@@ -54,6 +55,12 @@
54
55
  value: string | number;
55
56
  label: string;
56
57
  disabled?: boolean;
58
+ /**
59
+ * The language of the label, as a BCP 47 tag (`"en"`), when it is
60
+ * not the page's (WCAG 3.1.2). Set on the option's row, on the
61
+ * trigger's text while it is chosen, and on the native `<option>`.
62
+ */
63
+ lang?: string;
57
64
  }>;
58
65
  searchable?: boolean;
59
66
  /**
@@ -76,6 +83,12 @@
76
83
  * no search field, and none of the loading or empty states.
77
84
  */
78
85
  presentation?: SelectPresentation;
86
+ /**
87
+ * In a sheet, a short list (up to six options) without a search field
88
+ * gets a sheet as tall as the list, not half the screen. `false`
89
+ * keeps the half-height sheet.
90
+ */
91
+ sheetFit?: boolean;
79
92
  noResultsText?: string;
80
93
  isLoading?: boolean;
81
94
  loadingText?: string;
@@ -118,6 +131,7 @@
118
131
  maxMenuHeight = "60dvh",
119
132
  menuWidth = "100%",
120
133
  presentation = "auto",
134
+ sheetFit = true,
121
135
  noResultsText,
122
136
  isLoading = false,
123
137
  loadingText,
@@ -185,32 +199,12 @@
185
199
  let selectContainer: HTMLDivElement;
186
200
  let searchQuery = $state("");
187
201
 
188
- // Same fixed height scale as Button, IconButton and Input (32 / 40 / 48),
189
- // and the same 16px text below `sm`, so a Select beside an Input matches it.
190
- const sizeClass = $derived(() => {
191
- // A minimum height, so a label that wraps makes the trigger taller
192
- // instead of being cut. One line is exactly 32, 40 or 48px: the
193
- // padding leaves room for the line and the border inside it, and is
194
- // a step smaller below `sm`, where the text is 16px on a 24px line. `native` is the same box for the native
195
- // select, which is always one line.
196
- if (size === "sm") return { box: "min-h-8 px-3 py-1 max-sm:py-0 pointer-coarse:min-h-11", text: "text-sm max-sm:text-base", native: "h-8 ps-3 pe-9 pointer-coarse:h-11", chevron: "end-3" };
197
- if (size === "lg") return { box: "min-h-12 px-4 py-2", text: "text-base", native: "h-12 ps-4 pe-10", chevron: "end-4" };
198
- return { box: "min-h-10 px-3 py-2 max-sm:py-1 pointer-coarse:min-h-11", text: "text-sm max-sm:text-base", native: "h-10 ps-3 pe-9 pointer-coarse:h-11", chevron: "end-3" };
199
- });
202
+ const sizeClass = $derived(() => fieldControlSize(size));
200
203
 
201
- const variantClass = $derived(() => {
202
- return status.variant === "success"
203
- ? "border-success focus-visible:border-success"
204
- : status.variant === "warning"
205
- ? "border-warning focus-visible:border-warning"
206
- : status.variant === "error"
207
- ? "border-error focus-visible:border-error"
208
- : "border-input-border enabled:hover:border-input-border-hover";
209
- });
204
+ const variantClass = $derived(() => fieldControlEdge(status.variant));
210
205
 
211
206
  /** What the trigger and the visible native select share: the field's surface, edge and states. */
212
- const fieldSurface =
213
- "focus-ring w-full min-w-0 cursor-pointer rounded-control border bg-input text-body transition-colors duration-150 hover:bg-input-hover active:bg-input-active focus-visible:bg-input-focus focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-input-disabled disabled:text-action-disabled-text";
207
+ const fieldSurface = FIELD_CONTROL_SURFACE;
214
208
 
215
209
  const triggerClasses = $derived(() => {
216
210
  const sizeStyles = sizeClass();
@@ -501,6 +495,7 @@
501
495
  <option
502
496
  value={String(option.value)}
503
497
  disabled={option.disabled}
498
+ lang={option.lang}
504
499
  selected={isSameValue(value, option.value)}
505
500
  >
506
501
  {option.label}
@@ -536,6 +531,7 @@
536
531
  sheetExpandLabel={text.expandLabel}
537
532
  sheetCollapseLabel={text.collapseLabel}
538
533
  sheetSnap={searchable && options.length > 6 ? "full" : "half"}
534
+ sheetFit={sheetFit && !searchable && options.length <= 6}
539
535
  >
540
536
  {#snippet trigger(aria)}
541
537
  <!-- A select-only combobox, as the ARIA practices describe it:
@@ -568,6 +564,7 @@
568
564
  control to read the rest. The chevron stays in the middle of
569
565
  the box, however many lines there are. -->
570
566
  <span
567
+ lang={isLoading || isEmpty() ? undefined : selectedOption?.lang}
571
568
  class="min-w-0 flex-1 text-start [overflow-wrap:anywhere] {disabled
572
569
  ? ''
573
570
  : isLoading
@@ -586,7 +583,7 @@
586
583
  </span>
587
584
  <ChevronDown
588
585
  size={20}
589
- class="shrink-0 text-description transition-transform duration-200 motion-reduce:transition-none {isOpen
586
+ class="shrink-0 text-description transition-transform duration-(--duration-moderate) motion-reduce:transition-none {isOpen
590
587
  ? 'rotate-180'
591
588
  : ''}"
592
589
  />
@@ -614,7 +611,7 @@
614
611
  {#snippet children()}
615
612
  <!-- `px-1` twice: the inner one is room for an option's focus
616
613
  ring inside the scrolling box, and together with the panel's
617
- border they put the options 9px from its corner. -->
614
+ 1px rim they put the options 9px from its corner. -->
618
615
  <div class="px-1 pb-2 pt-1" style:width={menuWidth} data-menu-width>
619
616
  <div
620
617
  class="overflow-y-auto px-1"
@@ -638,7 +635,7 @@
638
635
  type="button"
639
636
  role="option"
640
637
  aria-selected={isSameValue(value, option.value)}
641
- class="focus-ring flex w-full items-center justify-start gap-2 rounded-control border-2 px-3 py-2 pointer-coarse:py-3 text-start text-sm font-medium transition-colors focus:outline-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 {isSameValue(
638
+ class="focus-ring flex w-full items-center justify-start gap-2 rounded-control border-2 px-3 py-2 pointer-coarse:py-3 text-start text-sm font-medium transition-colors focus:outline-none cursor-pointer aria-disabled:cursor-not-allowed aria-disabled:opacity-50 {isSameValue(
642
639
  value,
643
640
  option.value,
644
641
  )
@@ -647,6 +644,7 @@
647
644
  ? 'border-transparent bg-transparent text-body'
648
645
  : 'border-transparent bg-transparent text-body hover:bg-surface-overlay-hover active:bg-surface-active'}"
649
646
  aria-disabled={option.disabled ? "true" : undefined}
647
+ lang={option.lang}
650
648
  {...buttonRestProps}
651
649
  onclick={() =>
652
650
  handleOptionClick(option.value)}
@@ -23,6 +23,12 @@ type Props = Omit<HTMLButtonAttributes, "class" | "value" | "name" | "disabled"
23
23
  value: string | number;
24
24
  label: string;
25
25
  disabled?: boolean;
26
+ /**
27
+ * The language of the label, as a BCP 47 tag (`"en"`), when it is
28
+ * not the page's (WCAG 3.1.2). Set on the option's row, on the
29
+ * trigger's text while it is chosen, and on the native `<option>`.
30
+ */
31
+ lang?: string;
26
32
  }>;
27
33
  searchable?: boolean;
28
34
  /**
@@ -45,6 +51,12 @@ type Props = Omit<HTMLButtonAttributes, "class" | "value" | "name" | "disabled"
45
51
  * no search field, and none of the loading or empty states.
46
52
  */
47
53
  presentation?: SelectPresentation;
54
+ /**
55
+ * In a sheet, a short list (up to six options) without a search field
56
+ * gets a sheet as tall as the list, not half the screen. `false`
57
+ * keeps the half-height sheet.
58
+ */
59
+ sheetFit?: boolean;
48
60
  noResultsText?: string;
49
61
  isLoading?: boolean;
50
62
  loadingText?: string;
@@ -4,6 +4,9 @@
4
4
  import { generateId } from "../util/ssr-safe.js";
5
5
  import { cn } from "../util/cn.js";
6
6
  import { groupBinding } from "../util/hydration.js";
7
+ import { fieldDescribedBy, fieldMessageState } from "../util/field.js";
8
+ import type { SemanticVariant } from "../types/variants.js";
9
+ import FieldMessages from "./FieldMessages.svelte";
7
10
  import {
8
11
  SELECTION_CONTROL_INPUT,
9
12
  SELECTION_CONTROL_LABEL_ROW,
@@ -39,6 +42,30 @@
39
42
  onChange?: (event: Event) => void;
40
43
  onchange?: (event: Event) => void;
41
44
  mark?: Snippet<[SelectionControlMarkProps]>;
45
+ /**
46
+ * Drawn inside the one label, between the control and the label text,
47
+ * as decoration for the name `label` already gives. A leading image
48
+ * should have an empty alt: the label names the row.
49
+ */
50
+ leading?: Snippet;
51
+ /**
52
+ * A line under the label, tied to the input with `aria-describedby`.
53
+ * It is not part of the name: the label names the control.
54
+ */
55
+ description?: string;
56
+ /**
57
+ * Where the control sits in the row. `end` puts it after the leading,
58
+ * the label and the description, at the far edge; the row is still
59
+ * one label and fills the width it is given.
60
+ */
61
+ controlPosition?: "start" | "end";
62
+ variant?: SemanticVariant;
63
+ /** Status text under the control. Shown with a `success`, `warning` or `error` variant; for neutral help, use `hint`. */
64
+ message?: string;
65
+ /** Help text under the control, read out with it. */
66
+ hint?: string;
67
+ /** An error under the control. It marks the input invalid and is announced; it wins over `variant` and `message`. */
68
+ error?: string;
42
69
  };
43
70
 
44
71
  let {
@@ -56,6 +83,13 @@
56
83
  onChange,
57
84
  onchange,
58
85
  mark,
86
+ leading,
87
+ description,
88
+ controlPosition = "start",
89
+ variant = "default",
90
+ message = "",
91
+ hint = "",
92
+ error = "",
59
93
  ...restProps
60
94
  }: Props = $props();
61
95
 
@@ -75,9 +109,34 @@
75
109
  const controlId = $derived(idProp ?? fallbackId);
76
110
  const isDisabled = $derived(disabled || loading);
77
111
 
78
- const labelWrapperClasses = SELECTION_CONTROL_LABEL_ROW;
112
+ const descriptionId = $derived(`${controlId}-description`);
113
+ const status = $derived(fieldMessageState({ variant, message, hint, error }));
114
+ const hasFieldText = $derived(status.showMessage || status.showHint);
115
+ // The caller's ids, then the description, the hint and the message.
116
+ const describedBy = $derived(
117
+ fieldDescribedBy(
118
+ controlId,
119
+ status,
120
+ [restProps["aria-describedby"], description ? descriptionId : undefined]
121
+ .filter(Boolean)
122
+ .join(" "),
123
+ ),
124
+ );
125
+ // `aria-invalid` is not supported by the radio role: a radio's error is said by its description.
126
+ const invalid = $derived(status.variant === "error" ? "true" : undefined);
127
+
128
+ // With a description the row is two lines: the box sits at the top, as in RadioGroup.
129
+ // At the end the control is moved by `order`, so the markup and what a
130
+ // screen reader meets first are the same in both positions.
131
+ const controlAtEnd = $derived(controlPosition === "end");
132
+ const labelWrapperClasses = $derived(
133
+ cn(SELECTION_CONTROL_LABEL_ROW, description && "items-start", controlAtEnd && "min-w-0 flex-1"),
134
+ );
79
135
 
80
- const controlContainerClasses = $derived(selectionControlShellClasses(shape));
136
+ const controlContainerClasses = $derived(
137
+ cn(selectionControlShellClasses(shape), description && "mt-0.5", controlAtEnd && "order-last"),
138
+ );
139
+ const labelTextClasses = $derived(controlAtEnd ? "min-w-0 flex-1" : "");
81
140
 
82
141
  const controlRingClasses = $derived(selectionControlRingOverlayClasses(shape));
83
142
 
@@ -132,7 +191,7 @@
132
191
  }
133
192
  </script>
134
193
 
135
- <div class={cn("flex items-center gap-2", className)}>
194
+ {#snippet row()}
136
195
  <label for={controlId} class={labelWrapperClasses}>
137
196
  <span class={controlContainerClasses}>
138
197
  <!-- One branch per type: a binding needs a type it can read. -->
@@ -149,6 +208,8 @@
149
208
  aria-busy={loading ? "true" : undefined}
150
209
  onchange={handleChange}
151
210
  {...restProps}
211
+ aria-describedby={describedBy}
212
+ aria-invalid={invalid ?? restProps["aria-invalid"]}
152
213
  />
153
214
  {:else}
154
215
  <input
@@ -163,6 +224,7 @@
163
224
  aria-busy={loading ? "true" : undefined}
164
225
  onchange={handleChange}
165
226
  {...restProps}
227
+ aria-describedby={describedBy}
166
228
  />
167
229
  {/if}
168
230
  <span class={controlRingClasses}></span>
@@ -170,10 +232,41 @@
170
232
  {@render mark({ checked: !!checked, loading, disabled: isDisabled })}
171
233
  {/if}
172
234
  </span>
173
- {#if label}
174
- <span class="text-sm font-medium text-label">
235
+ {#if leading}
236
+ <span class="inline-flex shrink-0 items-center">
237
+ {@render leading()}
238
+ </span>
239
+ {/if}
240
+ {#if description}
241
+ <span class={cn("flex flex-col gap-1", labelTextClasses)}>
242
+ <span class="text-sm font-medium text-label">
243
+ {label}
244
+ </span>
245
+ <!-- aria-hidden keeps it out of the name; aria-describedby still reads it. -->
246
+ <span id={descriptionId} class="text-sm text-description" aria-hidden="true">
247
+ {description}
248
+ </span>
249
+ </span>
250
+ {:else if label}
251
+ <span class={cn("text-sm font-medium text-label", labelTextClasses)}>
175
252
  {label}
176
253
  </span>
177
254
  {/if}
178
255
  </label>
179
- </div>
256
+ {/snippet}
257
+
258
+ {#if hasFieldText}
259
+ <div class={cn("flex flex-col", className)}>
260
+ <div class="flex items-center gap-2">
261
+ {@render row()}
262
+ </div>
263
+ <!-- Under the label text: the box is 20px and the gap 8px. With the box at the end, from the row's edge. -->
264
+ <div class={controlAtEnd ? "" : "ps-7"}>
265
+ <FieldMessages fieldId={controlId} {status} {hint} gap="mt-1" />
266
+ </div>
267
+ </div>
268
+ {:else}
269
+ <div class={cn("flex items-center gap-2", className)}>
270
+ {@render row()}
271
+ </div>
272
+ {/if}
@@ -1,5 +1,6 @@
1
1
  import { type Snippet } from "svelte";
2
2
  import type { HTMLInputAttributes } from "svelte/elements";
3
+ import type { SemanticVariant } from "../types/variants.js";
3
4
  import { type SelectionControlShape } from "./selection-control.styles";
4
5
  type SelectionControlType = "checkbox" | "radio";
5
6
  export type SelectionControlMarkProps = {
@@ -23,6 +24,30 @@ type Props = Omit<HTMLInputAttributes, "class" | "id" | "name" | "value" | "disa
23
24
  onChange?: (event: Event) => void;
24
25
  onchange?: (event: Event) => void;
25
26
  mark?: Snippet<[SelectionControlMarkProps]>;
27
+ /**
28
+ * Drawn inside the one label, between the control and the label text,
29
+ * as decoration for the name `label` already gives. A leading image
30
+ * should have an empty alt: the label names the row.
31
+ */
32
+ leading?: Snippet;
33
+ /**
34
+ * A line under the label, tied to the input with `aria-describedby`.
35
+ * It is not part of the name: the label names the control.
36
+ */
37
+ description?: string;
38
+ /**
39
+ * Where the control sits in the row. `end` puts it after the leading,
40
+ * the label and the description, at the far edge; the row is still
41
+ * one label and fills the width it is given.
42
+ */
43
+ controlPosition?: "start" | "end";
44
+ variant?: SemanticVariant;
45
+ /** Status text under the control. Shown with a `success`, `warning` or `error` variant; for neutral help, use `hint`. */
46
+ message?: string;
47
+ /** Help text under the control, read out with it. */
48
+ hint?: string;
49
+ /** An error under the control. It marks the input invalid and is announced; it wins over `variant` and `message`. */
50
+ error?: string;
26
51
  };
27
52
  declare const SelectionControl: import("svelte").Component<Props, {}, "checked">;
28
53
  type SelectionControl = ReturnType<typeof SelectionControl>;