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
@@ -3,7 +3,7 @@
3
3
  import type { SemanticVariant, SizeVariant } from '../types/variants.js';
4
4
  import { generateId } from "../util/ssr-safe.js";
5
5
  import { cn } from "../util/cn.js";
6
- import { fieldDescribedBy, fieldMessageState } from "../util/field.js";
6
+ import { fieldDescribedBy, fieldMessageState, type TextareaCount } from "../util/field.js";
7
7
  import FieldMessages from "./FieldMessages.svelte";
8
8
 
9
9
  /**
@@ -34,6 +34,16 @@
34
34
  disabled?: boolean;
35
35
  loading?: boolean;
36
36
  rows?: number;
37
+ /**
38
+ * The resize grip. `auto` (default): the browser's vertical grip with a mouse or
39
+ * trackpad, none on a touch screen, where dragging it scrolls the page. `vertical`
40
+ * shows it everywhere, `none` never. With `autogrow` there is no grip.
41
+ */
42
+ resize?: 'auto' | 'none' | 'vertical';
43
+ /** The field grows with its content, from `rows` up to `maxRows`, and scrolls after that. */
44
+ autogrow?: boolean;
45
+ /** The tallest an `autogrow` field gets, in rows. Without it the field has no limit. */
46
+ maxRows?: number;
37
47
  size?: SizeVariant;
38
48
  variant?: SemanticVariant;
39
49
  /** Status text under the field. Shown with a `success`, `warning` or `error` variant; for neutral help, use `hint`. */
@@ -42,6 +52,16 @@
42
52
  hint?: string;
43
53
  /** An error under the field. It marks the field invalid and is announced; it wins over `variant` and `message`. */
44
54
  error?: string;
55
+ /**
56
+ * A character count under the field, at the end of the line the hint is on. It needs
57
+ * `maxlength`; without one nothing is shown. It is part of the field's description
58
+ * (`aria-describedby`) and is not a live region, so it is not read out at every key.
59
+ */
60
+ count?: boolean;
61
+ /** The count is shown from this many characters on. `0` (default): always. */
62
+ countFrom?: number;
63
+ /** The words of the count, for the page's language. Default: "12 / 2000". */
64
+ countText?: (count: TextareaCount) => string;
45
65
  oninput?: (event: Event) => void;
46
66
  /** Ids of other elements that describe the field. The hint and the message are added after them. */
47
67
  "aria-describedby"?: string | null;
@@ -59,11 +79,17 @@
59
79
  disabled = false,
60
80
  loading = false,
61
81
  rows = 4,
82
+ resize = 'auto',
83
+ autogrow = false,
84
+ maxRows,
62
85
  size = 'md',
63
86
  variant = 'default',
64
87
  message = '',
65
88
  hint = '',
66
89
  error = '',
90
+ count = false,
91
+ countFrom = 0,
92
+ countText,
67
93
  oninput,
68
94
  'aria-describedby': describedBy,
69
95
  ...restProps
@@ -86,6 +112,26 @@
86
112
 
87
113
  const status = $derived(fieldMessageState({ variant, message, hint, error }));
88
114
 
115
+ // The count: only with a `maxlength`, and only once `countFrom` is reached.
116
+ // The length is the one `maxlength` counts (UTF-16 units).
117
+ const countMax = $derived(Number(restProps.maxlength));
118
+ const countLength = $derived((value ?? '').length);
119
+ const showCount = $derived(
120
+ count && Number.isFinite(countMax) && countMax > 0 && countLength >= countFrom,
121
+ );
122
+ const countLabel = $derived.by(() => {
123
+ if (!showCount) return '';
124
+ const state = { length: countLength, max: countMax, remaining: Math.max(0, countMax - countLength) };
125
+ return countText ? countText(state) : `${state.length} / ${state.max}`;
126
+ });
127
+ const countId = $derived(`${textareaId}-count`);
128
+ // The caller's ids, the hint and the message, then the count.
129
+ const fieldDescription = $derived(
130
+ [fieldDescribedBy(textareaId, status, describedBy), showCount ? countId : '']
131
+ .filter(Boolean)
132
+ .join(' ') || undefined,
133
+ );
134
+
89
135
  const variantClass = $derived(() => {
90
136
  return status.variant === 'success'
91
137
  ? 'border-success focus-visible:border-success'
@@ -96,12 +142,44 @@
96
142
  : 'border-input-border enabled:hover:border-input-border-hover';
97
143
  });
98
144
 
145
+ const resizeClass = $derived(
146
+ autogrow || resize === 'none'
147
+ ? 'resize-none'
148
+ : resize === 'vertical'
149
+ ? 'resize-y'
150
+ : 'resize-y pointer-coarse:resize-none',
151
+ );
152
+
153
+ // Autogrow: `field-sizing: content` where the browser has it, held between
154
+ // `rows` and `maxRows` lines (`leading-6`, plus `py-2` and the two borders,
155
+ // since the box is border-box). Elsewhere the height is measured from the
156
+ // content after each change; the same two limits clamp it.
157
+ const linesHeight = (lines: number) => `calc(${lines * 1.5 + 1}rem + 2px)`;
158
+ const minHeight = $derived(autogrow ? linesHeight(Math.max(1, rows)) : undefined);
159
+ const maxHeight = $derived(
160
+ autogrow && maxRows !== undefined ? linesHeight(Math.max(rows, maxRows)) : undefined,
161
+ );
162
+
163
+ let element: HTMLTextAreaElement | undefined = $state();
164
+
165
+ $effect(() => {
166
+ void value;
167
+ if (!autogrow || !element) return;
168
+ if (typeof CSS !== 'undefined' && CSS.supports?.('field-sizing', 'content')) return;
169
+ const field = element;
170
+ field.style.height = 'auto';
171
+ field.style.height = `${field.scrollHeight + field.offsetHeight - field.clientHeight}px`;
172
+ return () => {
173
+ field.style.height = '';
174
+ };
175
+ });
176
+
99
177
  // 16px below `sm`: iOS Safari zooms the page when a focused field is smaller.
100
178
  const textareaClasses = $derived(() => {
101
179
  const baseClasses =
102
- 'focus-ring w-full border bg-input hover:bg-input-hover focus-visible:bg-input-focus disabled:bg-input-disabled rounded-control transition-colors duration-150 placeholder:text-input-placeholder text-body focus:outline-none focus-visible:outline-none disabled:text-action-disabled-text disabled:cursor-not-allowed resize-y px-3 py-2 text-sm max-sm:text-base leading-6';
180
+ 'focus-ring w-full border bg-input hover:bg-input-hover focus-visible:bg-input-focus disabled:bg-input-disabled rounded-control transition-colors duration-150 placeholder:text-input-placeholder text-body focus:outline-none focus-visible:outline-none disabled:text-action-disabled-text disabled:cursor-not-allowed px-3 py-2 text-sm max-sm:text-base leading-6';
103
181
 
104
- return cn(`${baseClasses} ${variantClass()} ${className}`);
182
+ return cn(`${baseClasses} ${resizeClass} ${variantClass()} ${className}`);
105
183
  });
106
184
 
107
185
  const labelClasses = $derived(
@@ -121,6 +199,7 @@
121
199
  {/if}
122
200
  <div class="relative">
123
201
  <textarea
202
+ bind:this={element}
124
203
  id={textareaId}
125
204
  {name}
126
205
  bind:value
@@ -129,11 +208,15 @@
129
208
  disabled={isDisabled}
130
209
  {rows}
131
210
  class={textareaClasses()}
211
+ style:field-sizing={autogrow ? 'content' : undefined}
212
+ style:min-height={minHeight}
213
+ style:max-height={maxHeight}
214
+ style:overflow-y={maxHeight ? 'auto' : undefined}
132
215
  oninput={handleInput}
133
216
  aria-invalid={status.variant === 'error' ? 'true' : undefined}
134
217
  aria-required={required ? 'true' : undefined}
135
218
  aria-busy={loading ? 'true' : undefined}
136
- aria-describedby={fieldDescribedBy(textareaId, status, describedBy)}
219
+ aria-describedby={fieldDescription}
137
220
  {...restProps}
138
221
  ></textarea>
139
222
  {#if loading}
@@ -147,5 +230,17 @@
147
230
  </span>
148
231
  {/if}
149
232
  </div>
150
- <FieldMessages fieldId={textareaId} {status} {hint} gap="mt-1" />
233
+ {#if showCount}
234
+ <!-- The count ends the line the hint starts. Not a live region: it is read with the field. -->
235
+ <div class="flex items-start gap-3">
236
+ <div class="min-w-0 flex-1">
237
+ <FieldMessages fieldId={textareaId} {status} {hint} gap="mt-1" />
238
+ </div>
239
+ <p id={countId} class="mt-1 shrink-0 text-sm text-description tabular-nums" data-textarea-count>
240
+ {countLabel}
241
+ </p>
242
+ </div>
243
+ {:else}
244
+ <FieldMessages fieldId={textareaId} {status} {hint} gap="mt-1" />
245
+ {/if}
151
246
  </div>
@@ -1,5 +1,6 @@
1
1
  import type { HTMLTextareaAttributes } from 'svelte/elements';
2
2
  import type { SemanticVariant, SizeVariant } from '../types/variants.js';
3
+ import { type TextareaCount } from "../util/field.js";
3
4
  /**
4
5
  * Other attributes (`autocomplete`, `inputmode`, `maxlength`,
5
6
  * `enterkeyhint`, `data-*`, `onchange`, ...) land on the `<textarea>`.
@@ -16,6 +17,16 @@ type Props = Omit<HTMLTextareaAttributes, 'class' | 'value' | 'id' | 'name' | 'p
16
17
  disabled?: boolean;
17
18
  loading?: boolean;
18
19
  rows?: number;
20
+ /**
21
+ * The resize grip. `auto` (default): the browser's vertical grip with a mouse or
22
+ * trackpad, none on a touch screen, where dragging it scrolls the page. `vertical`
23
+ * shows it everywhere, `none` never. With `autogrow` there is no grip.
24
+ */
25
+ resize?: 'auto' | 'none' | 'vertical';
26
+ /** The field grows with its content, from `rows` up to `maxRows`, and scrolls after that. */
27
+ autogrow?: boolean;
28
+ /** The tallest an `autogrow` field gets, in rows. Without it the field has no limit. */
29
+ maxRows?: number;
19
30
  size?: SizeVariant;
20
31
  variant?: SemanticVariant;
21
32
  /** Status text under the field. Shown with a `success`, `warning` or `error` variant; for neutral help, use `hint`. */
@@ -24,6 +35,16 @@ type Props = Omit<HTMLTextareaAttributes, 'class' | 'value' | 'id' | 'name' | 'p
24
35
  hint?: string;
25
36
  /** An error under the field. It marks the field invalid and is announced; it wins over `variant` and `message`. */
26
37
  error?: string;
38
+ /**
39
+ * A character count under the field, at the end of the line the hint is on. It needs
40
+ * `maxlength`; without one nothing is shown. It is part of the field's description
41
+ * (`aria-describedby`) and is not a live region, so it is not read out at every key.
42
+ */
43
+ count?: boolean;
44
+ /** The count is shown from this many characters on. `0` (default): always. */
45
+ countFrom?: number;
46
+ /** The words of the count, for the page's language. Default: "12 / 2000". */
47
+ countText?: (count: TextareaCount) => string;
27
48
  oninput?: (event: Event) => void;
28
49
  /** Ids of other elements that describe the field. The hint and the message are added after them. */
29
50
  "aria-describedby"?: string | null;
@@ -0,0 +1,150 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from "svelte";
3
+ import { cn } from "../util/cn.js";
4
+ import type { TimeChoice } from "../util/temporal-field.js";
5
+
6
+ /**
7
+ * One scrollable column of the time picker in TimeField: the hours, or the
8
+ * minutes. Internal: it is not exported from the package.
9
+ *
10
+ * A listbox of 44px rows. One row is the Tab stop (the chosen one, else
11
+ * the first); Up and Down, Home and End move between rows, Enter and
12
+ * Space choose the focused one, and typing digits jumps to a row. A row
13
+ * that is out of range stays focusable and says `aria-disabled`. With
14
+ * nothing chosen, `openAt` is the row the column stands at and the keyboard
15
+ * starts from; it is not marked chosen.
16
+ */
17
+ interface Props {
18
+ /** Accessible name of the listbox. */
19
+ label: string;
20
+ options: TimeChoice[];
21
+ /** The `value` of the chosen option, or `""`. */
22
+ selected: string;
23
+ onselect: (value: string) => void;
24
+ /** The `value` of the row to stand at while nothing is chosen. It is not chosen. */
25
+ openAt?: string;
26
+ }
27
+
28
+ let { label, options, selected, onselect, openAt = "" }: Props = $props();
29
+
30
+ let box: HTMLDivElement | undefined = $state();
31
+ /** The row that takes Tab: where the keyboard was last, else the chosen one, else the first. */
32
+ let focusedValue = $state<string | null>(null);
33
+
34
+ const tabStop = $derived(
35
+ options.some((option) => option.value === focusedValue)
36
+ ? focusedValue
37
+ : options.some((option) => option.value === selected)
38
+ ? selected
39
+ : options.some((option) => option.value === openAt)
40
+ ? openAt
41
+ : (options[0]?.value ?? null),
42
+ );
43
+
44
+ /**
45
+ * The chosen row, else the `openAt` one, is put in the middle of the
46
+ * column as it opens, and again when the choice changes (a new hour can move the minute). Measured
47
+ * from the two boxes, not `offsetTop`, which counts from the sheet. Set
48
+ * straight on `scrollTop`: nothing here scrolls smoothly, so there is no
49
+ * motion for `prefers-reduced-motion` to remove.
50
+ */
51
+ function centreSelected() {
52
+ const column = box;
53
+ const row =
54
+ column?.querySelector<HTMLElement>('[aria-selected="true"]') ??
55
+ (openAt ? rows().find((element) => element.dataset.value === openAt) : undefined);
56
+ if (!column || !row || !column.clientHeight) return;
57
+ const offset = row.getBoundingClientRect().top - column.getBoundingClientRect().top;
58
+ column.scrollTop += offset - (column.clientHeight - row.offsetHeight) / 2;
59
+ }
60
+
61
+ onMount(centreSelected);
62
+
63
+ $effect(() => {
64
+ void selected;
65
+ void tick().then(centreSelected);
66
+ });
67
+
68
+ const rows = () => [...(box?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])];
69
+
70
+ function focusRow(value: string | undefined) {
71
+ if (value === undefined) return;
72
+ focusedValue = value;
73
+ const row = rows().find((element) => element.dataset.value === value);
74
+ row?.focus();
75
+ row?.scrollIntoView?.({ block: "nearest" });
76
+ }
77
+
78
+ let typed = "";
79
+ let typedTimer: ReturnType<typeof setTimeout> | undefined;
80
+
81
+ function handleKeydown(event: KeyboardEvent) {
82
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
83
+ const values = options.map((option) => option.value);
84
+ const current = values.indexOf((event.target as HTMLElement | null)?.closest<HTMLElement>("[data-value]")?.dataset.value ?? "");
85
+ if (event.key === "ArrowDown") {
86
+ event.preventDefault();
87
+ focusRow(values[Math.min(current + 1, values.length - 1)]);
88
+ } else if (event.key === "ArrowUp") {
89
+ event.preventDefault();
90
+ focusRow(values[Math.max(current - 1, 0)]);
91
+ } else if (event.key === "Home") {
92
+ event.preventDefault();
93
+ focusRow(values[0]);
94
+ } else if (event.key === "End") {
95
+ event.preventDefault();
96
+ focusRow(values[values.length - 1]);
97
+ } else if (event.key === "Enter" || event.key === " ") {
98
+ event.preventDefault();
99
+ const option = options[current];
100
+ if (option && !option.disabled) onselect(option.value);
101
+ } else if (/^[0-9]$/.test(event.key)) {
102
+ event.preventDefault();
103
+ typed += event.key;
104
+ clearTimeout(typedTimer);
105
+ typedTimer = setTimeout(() => (typed = ""), 800);
106
+ // "7" is 07, "30" is 30, "1" then "9" is 19. The value is the 24-hour one, whatever the label says.
107
+ const hit =
108
+ options.find((option) => option.value.startsWith(typed)) ??
109
+ options.find((option) => Number(option.value) === Number(typed)) ??
110
+ options.find((option) => option.value.startsWith(typed.slice(-1)));
111
+ if (hit) focusRow(hit.value);
112
+ }
113
+ }
114
+ </script>
115
+
116
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
117
+ <div
118
+ bind:this={box}
119
+ role="listbox"
120
+ aria-label={label}
121
+ tabindex="-1"
122
+ data-time-column
123
+ data-sheet-no-drag
124
+ class="h-44 min-w-0 flex-1 overflow-y-auto overscroll-contain rounded-control border border-input-border bg-input p-1"
125
+ onkeydown={handleKeydown}
126
+ >
127
+ {#each options as option (option.value)}
128
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
129
+ <div
130
+ role="option"
131
+ data-value={option.value}
132
+ aria-selected={option.value === selected}
133
+ aria-disabled={option.disabled ? "true" : undefined}
134
+ tabindex={option.value === tabStop ? 0 : -1}
135
+ class={cn(
136
+ "focus-ring flex h-11 shrink-0 cursor-pointer items-center justify-center rounded-control text-base transition-colors duration-(--duration-base) motion-reduce:transition-none focus:outline-none focus-visible:outline-none",
137
+ // Two class lists, never both: the hand-written colour classes
138
+ // sit outside the cascade layers, so a variant cannot beat them.
139
+ option.value === selected
140
+ ? "bg-action-primary text-action-primary font-semibold"
141
+ : "text-body hover:bg-input-hover",
142
+ "aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent",
143
+ )}
144
+ onfocus={() => (focusedValue = option.value)}
145
+ onclick={() => !option.disabled && onselect(option.value)}
146
+ >
147
+ {option.label}
148
+ </div>
149
+ {/each}
150
+ </div>
@@ -0,0 +1,25 @@
1
+ import type { TimeChoice } from "../util/temporal-field.js";
2
+ /**
3
+ * One scrollable column of the time picker in TimeField: the hours, or the
4
+ * minutes. Internal: it is not exported from the package.
5
+ *
6
+ * A listbox of 44px rows. One row is the Tab stop (the chosen one, else
7
+ * the first); Up and Down, Home and End move between rows, Enter and
8
+ * Space choose the focused one, and typing digits jumps to a row. A row
9
+ * that is out of range stays focusable and says `aria-disabled`. With
10
+ * nothing chosen, `openAt` is the row the column stands at and the keyboard
11
+ * starts from; it is not marked chosen.
12
+ */
13
+ interface Props {
14
+ /** Accessible name of the listbox. */
15
+ label: string;
16
+ options: TimeChoice[];
17
+ /** The `value` of the chosen option, or `""`. */
18
+ selected: string;
19
+ onselect: (value: string) => void;
20
+ /** The `value` of the row to stand at while nothing is chosen. It is not chosen. */
21
+ openAt?: string;
22
+ }
23
+ declare const TimeColumn: import("svelte").Component<Props, {}, "">;
24
+ type TimeColumn = ReturnType<typeof TimeColumn>;
25
+ export default TimeColumn;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLInputAttributes } from "svelte/elements";
3
3
  import type { SizeVariant } from "../types/variants.js";
4
+ import type { TimeFieldStrings } from "../util/temporal-field.js";
4
5
  import TemporalField from "./TemporalField.svelte";
5
6
 
6
7
  /**
@@ -11,15 +12,25 @@
11
12
  * `HH:mm` (`HH:mm:ss` when `step` asks for seconds), or `""` while empty,
12
13
  * whatever the field shows.
13
14
  *
14
- * What the field shows is the browser's decision: 24-hour or 12-hour
15
- * with AM and PM, by the settings of the browser or device, not of the
16
- * page. To show a time as text in the page's language, use `formatTime`:
15
+ * What the field shows is the browser's decision, unless it has a
16
+ * `locale`: without one, 24-hour or 12-hour with AM and PM, by the
17
+ * settings of the browser or device, not of the page. To show a time as
18
+ * text in the page's language, use `formatTime`:
17
19
  *
18
20
  * ```svelte
19
21
  * <TimeField label="Starts" bind:value={time} step={300} />
20
22
  * <p>{formatTime(time, "sv")}</p> <!-- 19:00 -->
21
23
  * ```
22
24
  *
25
+ * With `locale`, the field shows the value in that language ("18:30" in
26
+ * Swedish, never "06:30 PM") and its own picker opens: two columns in a
27
+ * BottomSheet, hours (written as the locale writes an hour) and minutes
28
+ * (every `step`, five minutes without one), and Done. A value that is not
29
+ * on the step is kept as its own minute. A `step` under 60 seconds, or
30
+ * `"any"`, uses the platform's picker instead, as does `picker="native"`.
31
+ * Until the page has run, and without scripts, it is the native input
32
+ * as above and submits as before.
33
+ *
23
34
  * Other attributes (`autocomplete`, `data-*`, `onchange`, ...) land on the
24
35
  * `<input>`.
25
36
  */
@@ -54,6 +65,26 @@
54
65
  size?: SizeVariant;
55
66
  /** Extra classes for the `<input>`. */
56
67
  class?: string;
68
+ /**
69
+ * Language of the field, as a BCP 47 tag (`sv`, `en-GB`) or a list of them. With it the
70
+ * field shows the value in that language, whatever the browser's is, and opens the
71
+ * library's own picker; see above. Left out, the field is the native input.
72
+ */
73
+ locale?: string | string[];
74
+ /** With `locale`: how the value is shown, as options of `Intl.DateTimeFormat`. Default: formatTime gives for the locale. */
75
+ format?: Intl.DateTimeFormatOptions;
76
+ /** With `locale`: shown in the placeholder colour while the field is empty. */
77
+ placeholder?: string;
78
+ /** With `locale`: `library` (default) opens the library's time columns in a sheet; `native` opens the platform's own picker. */
79
+ picker?: "library" | "native";
80
+ /** With `locale`: the words the field says; replace any of them to translate. */
81
+ strings?: Partial<TimeFieldStrings>;
82
+ /**
83
+ * With `locale`, while the field is empty: the time the library's columns stand at when
84
+ * the sheet opens, as `HH:mm` (`"18:30"`). It is only where to scroll to: no row is
85
+ * chosen and `value` stays empty until the person chooses. Left out: now, on the step.
86
+ */
87
+ openAt?: string;
57
88
  };
58
89
 
59
90
  let {
@@ -72,6 +103,12 @@
72
103
  readonly = false,
73
104
  size = "md",
74
105
  class: className = "",
106
+ locale,
107
+ format,
108
+ placeholder,
109
+ picker = "library",
110
+ strings,
111
+ openAt,
75
112
  ...restProps
76
113
  }: Props = $props();
77
114
 
@@ -104,5 +141,11 @@
104
141
  {readonly}
105
142
  {size}
106
143
  class={className}
144
+ {locale}
145
+ {format}
146
+ {placeholder}
147
+ {picker}
148
+ {strings}
149
+ {openAt}
107
150
  {...restProps}
108
151
  />
@@ -1,5 +1,6 @@
1
1
  import type { HTMLInputAttributes } from "svelte/elements";
2
2
  import type { SizeVariant } from "../types/variants.js";
3
+ import type { TimeFieldStrings } from "../util/temporal-field.js";
3
4
  /**
4
5
  * A time of day, picked with the device's own time picker and styled
5
6
  * like Input.
@@ -8,15 +9,25 @@ import type { SizeVariant } from "../types/variants.js";
8
9
  * `HH:mm` (`HH:mm:ss` when `step` asks for seconds), or `""` while empty,
9
10
  * whatever the field shows.
10
11
  *
11
- * What the field shows is the browser's decision: 24-hour or 12-hour
12
- * with AM and PM, by the settings of the browser or device, not of the
13
- * page. To show a time as text in the page's language, use `formatTime`:
12
+ * What the field shows is the browser's decision, unless it has a
13
+ * `locale`: without one, 24-hour or 12-hour with AM and PM, by the
14
+ * settings of the browser or device, not of the page. To show a time as
15
+ * text in the page's language, use `formatTime`:
14
16
  *
15
17
  * ```svelte
16
18
  * <TimeField label="Starts" bind:value={time} step={300} />
17
19
  * <p>{formatTime(time, "sv")}</p> <!-- 19:00 -->
18
20
  * ```
19
21
  *
22
+ * With `locale`, the field shows the value in that language ("18:30" in
23
+ * Swedish, never "06:30 PM") and its own picker opens: two columns in a
24
+ * BottomSheet, hours (written as the locale writes an hour) and minutes
25
+ * (every `step`, five minutes without one), and Done. A value that is not
26
+ * on the step is kept as its own minute. A `step` under 60 seconds, or
27
+ * `"any"`, uses the platform's picker instead, as does `picker="native"`.
28
+ * Until the page has run, and without scripts, it is the native input
29
+ * as above and submits as before.
30
+ *
20
31
  * Other attributes (`autocomplete`, `data-*`, `onchange`, ...) land on the
21
32
  * `<input>`.
22
33
  */
@@ -48,6 +59,26 @@ type Props = Omit<HTMLInputAttributes, "class" | "type" | "value" | "size" | "mi
48
59
  size?: SizeVariant;
49
60
  /** Extra classes for the `<input>`. */
50
61
  class?: string;
62
+ /**
63
+ * Language of the field, as a BCP 47 tag (`sv`, `en-GB`) or a list of them. With it the
64
+ * field shows the value in that language, whatever the browser's is, and opens the
65
+ * library's own picker; see above. Left out, the field is the native input.
66
+ */
67
+ locale?: string | string[];
68
+ /** With `locale`: how the value is shown, as options of `Intl.DateTimeFormat`. Default: formatTime gives for the locale. */
69
+ format?: Intl.DateTimeFormatOptions;
70
+ /** With `locale`: shown in the placeholder colour while the field is empty. */
71
+ placeholder?: string;
72
+ /** With `locale`: `library` (default) opens the library's time columns in a sheet; `native` opens the platform's own picker. */
73
+ picker?: "library" | "native";
74
+ /** With `locale`: the words the field says; replace any of them to translate. */
75
+ strings?: Partial<TimeFieldStrings>;
76
+ /**
77
+ * With `locale`, while the field is empty: the time the library's columns stand at when
78
+ * the sheet opens, as `HH:mm` (`"18:30"`). It is only where to scroll to: no row is
79
+ * chosen and `value` stays empty until the person chooses. Left out: now, on the step.
80
+ */
81
+ openAt?: string;
51
82
  };
52
83
  declare const TimeField: import("svelte").Component<Props, {}, "value">;
53
84
  type TimeField = ReturnType<typeof TimeField>;
@@ -33,15 +33,16 @@
33
33
  let isVisible = $state(true);
34
34
 
35
35
  const typeClasses: Record<NonNullable<Props['type']>, string> = {
36
- success: 'border-success bg-surface-overlay text-success',
37
- error: 'border-error bg-surface-overlay text-error',
38
- warning: 'border-warning bg-surface-overlay text-warning',
39
- info: 'border-border bg-surface-overlay text-body',
36
+ success: 'border-success text-success-text',
37
+ error: 'border-error text-error-text',
38
+ warning: 'border-warning text-warning-text',
39
+ // The material has its own rim: no second edge on the neutral toast.
40
+ info: 'border-transparent text-body',
40
41
  };
41
42
 
42
43
  // `min(18rem, 100%)`: 18rem is wider than a 320px screen once the text is enlarged.
43
44
  const cardClasses =
44
- 'box-border w-full min-w-[min(18rem,100%)] max-w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-control border p-4 shadow-lg';
45
+ 'box-border w-full min-w-[min(18rem,100%)] max-w-[min(24rem,calc(100vw-2rem))] overflow-hidden rounded-control border p-4 material-thick';
45
46
 
46
47
  function closeToast(event: Event) {
47
48
  isVisible = false;