@lyeve-labs/ui-kit 0.12.1 → 0.13.1

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 (87) hide show
  1. package/dist/components/Autocomplete.svelte +191 -125
  2. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  3. package/dist/components/Card.svelte +43 -2
  4. package/dist/components/Card.svelte.d.ts +24 -2
  5. package/dist/components/Checkbox.svelte +174 -63
  6. package/dist/components/Checkbox.svelte.d.ts +20 -3
  7. package/dist/components/CheckboxGroup.svelte +162 -0
  8. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  9. package/dist/components/Collapsible.svelte +142 -0
  10. package/dist/components/Collapsible.svelte.d.ts +32 -0
  11. package/dist/components/CopyButton.svelte +126 -0
  12. package/dist/components/CopyButton.svelte.d.ts +14 -0
  13. package/dist/components/DatePicker.svelte +48 -6
  14. package/dist/components/DateTimePicker.svelte +337 -0
  15. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  16. package/dist/components/DescriptionList.svelte +78 -0
  17. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  18. package/dist/components/Field.svelte +104 -0
  19. package/dist/components/Field.svelte.d.ts +46 -0
  20. package/dist/components/FileInput.svelte +5 -2
  21. package/dist/components/FormMessage.svelte +85 -0
  22. package/dist/components/FormMessage.svelte.d.ts +11 -0
  23. package/dist/components/Input.svelte +1 -1
  24. package/dist/components/Label.svelte +7 -1
  25. package/dist/components/Label.svelte.d.ts +6 -0
  26. package/dist/components/MultiSelect.svelte +199 -109
  27. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  28. package/dist/components/NumberInput.svelte +8 -4
  29. package/dist/components/PageHeader.svelte +37 -4
  30. package/dist/components/PageHeader.svelte.d.ts +15 -0
  31. package/dist/components/PageShell.svelte +85 -0
  32. package/dist/components/PageShell.svelte.d.ts +38 -0
  33. package/dist/components/Pagination.svelte +43 -7
  34. package/dist/components/Panel.svelte +101 -0
  35. package/dist/components/Panel.svelte.d.ts +39 -0
  36. package/dist/components/PasswordInput.svelte +139 -0
  37. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  38. package/dist/components/Radio.svelte +152 -35
  39. package/dist/components/Radio.svelte.d.ts +16 -1
  40. package/dist/components/RadioGroup.svelte +118 -71
  41. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  42. package/dist/components/SectionHeading.svelte +39 -0
  43. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  44. package/dist/components/SegmentedControl.svelte +194 -0
  45. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  46. package/dist/components/Select.svelte +471 -46
  47. package/dist/components/Select.svelte.d.ts +95 -6
  48. package/dist/components/SidebarNav.svelte +264 -0
  49. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  50. package/dist/components/Stat.svelte +53 -2
  51. package/dist/components/Stat.svelte.d.ts +31 -0
  52. package/dist/components/Textarea.svelte +1 -1
  53. package/dist/components/TimePicker.svelte +480 -0
  54. package/dist/components/TimePicker.svelte.d.ts +23 -0
  55. package/dist/components/Toolbar.svelte +39 -0
  56. package/dist/components/Toolbar.svelte.d.ts +26 -0
  57. package/dist/components/TreeView.svelte +339 -0
  58. package/dist/components/TreeView.svelte.d.ts +37 -0
  59. package/dist/index.d.ts +25 -1
  60. package/dist/index.js +20 -1
  61. package/dist/internal/calendar.d.ts +119 -0
  62. package/dist/internal/calendar.js +225 -0
  63. package/dist/internal/choice.d.ts +136 -0
  64. package/dist/internal/choice.js +179 -0
  65. package/dist/internal/field.d.ts +39 -0
  66. package/dist/internal/field.js +48 -0
  67. package/dist/internal/filter.d.ts +80 -0
  68. package/dist/internal/filter.js +80 -0
  69. package/dist/internal/layout.d.ts +119 -0
  70. package/dist/internal/layout.js +132 -0
  71. package/dist/internal/listbox.svelte.d.ts +77 -0
  72. package/dist/internal/listbox.svelte.js +438 -0
  73. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  74. package/dist/internal/nav-expansion.svelte.js +144 -0
  75. package/dist/internal/nav-tree.d.ts +68 -0
  76. package/dist/internal/nav-tree.js +102 -0
  77. package/dist/internal/panel.d.ts +100 -0
  78. package/dist/internal/panel.js +109 -0
  79. package/dist/internal/rollup.d.ts +52 -0
  80. package/dist/internal/rollup.js +67 -0
  81. package/dist/internal/time.d.ts +103 -0
  82. package/dist/internal/time.js +166 -0
  83. package/dist/internal/tree.d.ts +86 -0
  84. package/dist/internal/tree.js +111 -0
  85. package/dist/styles/theme.css +18 -10
  86. package/package.json +4 -2
  87. package/src/lib/styles/theme.css +18 -10
@@ -0,0 +1,480 @@
1
+ <script lang="ts">
2
+ import {
3
+ CONTROL_SEGMENT,
4
+ CONTROL_SEGMENTED,
5
+ FIELD_ERROR,
6
+ FIELD_HINT,
7
+ FIELD_LABEL,
8
+ FIELD_WRAP,
9
+ describedBy,
10
+ segmentedBorder,
11
+ } from '../internal/field.js';
12
+ import {
13
+ clampTime,
14
+ from12Hour,
15
+ pad2,
16
+ parseISOTime,
17
+ stepSegment,
18
+ to12Hour,
19
+ toISOTime,
20
+ } from '../internal/time.js';
21
+ import type { TimeParts, TimeSegment } from '../internal/time.js';
22
+
23
+ /**
24
+ * A time field built from one input per segment, and deliberately not from a
25
+ * single masked input.
26
+ *
27
+ * A masked input has exactly one accessible value, so every keystroke
28
+ * re-announces the whole string: a screen reader user typing an hour hears
29
+ * the minutes and the seconds read back at them, and there is no way to say
30
+ * which part of that string the caret is in. Separate inputs give each
31
+ * segment its own name, its own value and its own announcement, and let each
32
+ * one be a spinbutton with real bounds rather than three ranges hidden inside
33
+ * one text field.
34
+ *
35
+ * The value on the wire is 24-hour whatever the display shows:
36
+ *
37
+ * unset ''
38
+ * seconds false '09:30'
39
+ * seconds true '09:30:15'
40
+ *
41
+ * `hour12` changes only what is drawn. Every step, wrap and bound comes from
42
+ * internal/time.ts, which is where the arithmetic that a picker gets wrong on
43
+ * the first attempt already lives.
44
+ */
45
+
46
+ /** A segment the user types digits into. The meridiem is a select, not a spinbutton. */
47
+ type DigitSegment = Exclude<TimeSegment, 'meridiem'>;
48
+
49
+ interface Props {
50
+ value?: string;
51
+ id?: string;
52
+ name?: string;
53
+ label?: string;
54
+ hint?: string;
55
+ error?: string;
56
+ /** Show a seconds segment. */
57
+ seconds?: boolean;
58
+ /** Draw 12-hour with an AM/PM segment. The wire format stays 24-hour. */
59
+ hour12?: boolean;
60
+ /** Minute and second step. Arrow keys move by this. */
61
+ step?: number;
62
+ min?: string;
63
+ max?: string;
64
+ required?: boolean;
65
+ disabled?: boolean;
66
+ class?: string;
67
+ onchange?: (value: string) => void;
68
+ }
69
+
70
+ let {
71
+ value = $bindable(''),
72
+ id = undefined,
73
+ name = undefined,
74
+ label = undefined,
75
+ hint = undefined,
76
+ error = undefined,
77
+ seconds = false,
78
+ hour12 = false,
79
+ step = 1,
80
+ min = undefined,
81
+ max = undefined,
82
+ required = false,
83
+ disabled = false,
84
+ class: klass = '',
85
+ onchange = undefined,
86
+ }: Props = $props();
87
+
88
+ // $props.id() and not a random string: a random id differs between the server
89
+ // render and hydration, so every aria-labelledby and every label `for` built
90
+ // from it points at an element that does not exist on the client.
91
+ const uid = $props.id();
92
+ const fieldId = $derived(id ?? uid);
93
+
94
+ const SEGMENT_LABEL: Record<DigitSegment, string> = {
95
+ hour: 'Hour',
96
+ minute: 'Minute',
97
+ second: 'Second',
98
+ };
99
+
100
+ /** PageUp and PageDown move by ten whatever `step` is, so a coarse step still has a coarse key. */
101
+ const PAGE_STEP = 10;
102
+
103
+ // Each segment holds its own number, so one of them can be empty while the
104
+ // others are filled. Holding the whole time as a string instead means
105
+ // Backspace on the minute has to clear the hour too, since '09:--' is not a
106
+ // value this field is allowed to emit.
107
+ const seed = parseISOTime(value);
108
+ let hour = $state<number | null>(seed ? seed.h : null);
109
+ let minute = $state<number | null>(seed ? seed.mi : null);
110
+ let second = $state<number | null>(seed ? seed.s : null);
111
+
112
+ // The meridiem follows the hour once there is one. It is remembered on its
113
+ // own only while the hour is empty, so picking PM and then typing 3 gives 15
114
+ // rather than 3.
115
+ let meridiemPref = $state<'AM' | 'PM'>(seed ? to12Hour(seed.h).meridiem : 'AM');
116
+ const meridiem = $derived<'AM' | 'PM'>(hour === null ? meridiemPref : to12Hour(hour).meridiem);
117
+
118
+ // The first of two digits, before it is worth anything on its own. Only one
119
+ // segment can be mid-entry, because only one of them holds the caret.
120
+ let draft = $state<{ segment: DigitSegment; text: string } | null>(null);
121
+
122
+ let group = $state<HTMLDivElement | null>(null);
123
+
124
+ // What was last handed to or taken from the consumer. Without it the effect
125
+ // below would adopt the field's own writes and wipe the draft on every
126
+ // keystroke.
127
+ let mirrored = value;
128
+
129
+ const minParts = $derived(parseISOTime(min));
130
+ const maxParts = $derived(parseISOTime(max));
131
+
132
+ // A step of 0 makes ArrowUp a no-op that looks like a broken key, and a
133
+ // negative one inverts the arrows.
134
+ const stepUnit = $derived(Math.max(1, Math.trunc(step)));
135
+
136
+ const digitSegments = $derived<DigitSegment[]>(
137
+ seconds ? ['hour', 'minute', 'second'] : ['hour', 'minute'],
138
+ );
139
+
140
+ // The seed above runs once, so a value the consumer changes later has to be
141
+ // adopted here. Seeding in this effect instead would leave the server render
142
+ // showing empty segments for a value it was given.
143
+ $effect(() => {
144
+ const incoming = value ?? '';
145
+ if (incoming === mirrored) return;
146
+ mirrored = incoming;
147
+ draft = null;
148
+ const parts = parseISOTime(incoming);
149
+ hour = parts ? parts.h : null;
150
+ minute = parts ? parts.mi : null;
151
+ second = parts ? parts.s : null;
152
+ if (parts) meridiemPref = to12Hour(parts.h).meridiem;
153
+ });
154
+
155
+ function rawOf(segment: DigitSegment): number | null {
156
+ if (segment === 'hour') return hour;
157
+ if (segment === 'minute') return minute;
158
+ return second;
159
+ }
160
+
161
+ /** The number the segment shows, which is not the number it stores in 12-hour mode. */
162
+ function displayOf(segment: DigitSegment): number | null {
163
+ const n = rawOf(segment);
164
+ if (n === null) return null;
165
+ return segment === 'hour' && hour12 ? to12Hour(n).hour : n;
166
+ }
167
+
168
+ function shown(segment: DigitSegment): string {
169
+ if (draft?.segment === segment) return draft.text;
170
+ const n = displayOf(segment);
171
+ return n === null ? '' : pad2(n);
172
+ }
173
+
174
+ function shownNow(segment: DigitSegment): number | undefined {
175
+ const text = shown(segment);
176
+ return text === '' ? undefined : Number(text);
177
+ }
178
+
179
+ /**
180
+ * An empty spinbutton has no aria-valuenow to give, so it says so in words.
181
+ * Omitting both leaves the segment announced as an unlabelled number.
182
+ */
183
+ function valueText(segment: DigitSegment): string {
184
+ const text = shown(segment);
185
+ return text === '' ? 'Empty' : text;
186
+ }
187
+
188
+ function segmentMin(segment: DigitSegment): number {
189
+ // A 12-hour clock has no hour zero. Every other segment starts at zero.
190
+ return segment === 'hour' && hour12 ? 1 : 0;
191
+ }
192
+
193
+ function segmentMax(segment: DigitSegment): number {
194
+ if (segment !== 'hour') return 59;
195
+ return hour12 ? 12 : 23;
196
+ }
197
+
198
+ function setSegment(segment: DigitSegment, n: number): void {
199
+ const bounded = Math.min(segmentMax(segment), Math.max(segmentMin(segment), n));
200
+ if (segment === 'hour') {
201
+ hour = hour12 ? from12Hour(bounded, meridiem) : bounded;
202
+ } else if (segment === 'minute') {
203
+ minute = bounded;
204
+ } else {
205
+ second = bounded;
206
+ }
207
+ }
208
+
209
+ function clearSegment(segment: DigitSegment): void {
210
+ if (segment === 'hour') hour = null;
211
+ else if (segment === 'minute') minute = null;
212
+ else second = null;
213
+ }
214
+
215
+ function publish(next: string): void {
216
+ if (next === value) return;
217
+ mirrored = next;
218
+ value = next;
219
+ onchange?.(next);
220
+ }
221
+
222
+ /**
223
+ * Writes the segments out, once they add up to a whole time.
224
+ *
225
+ * The bounds are applied to that whole time and never to a segment on its
226
+ * own: with min 09:30 the time 10:15 is legal, and clamping the minute by
227
+ * itself would rewrite it to 10:30.
228
+ */
229
+ function commit(): void {
230
+ if (hour === null || minute === null || (seconds && second === null)) {
231
+ publish('');
232
+ return;
233
+ }
234
+ const clamped = clampTime(
235
+ { h: hour, mi: minute, s: seconds ? (second ?? 0) : 0 },
236
+ minParts,
237
+ maxParts,
238
+ );
239
+ hour = clamped.h;
240
+ minute = clamped.mi;
241
+ if (seconds) second = clamped.s;
242
+ publish(toISOTime(clamped, seconds));
243
+ }
244
+
245
+ function focusSegment(from: TimeSegment, delta: number): void {
246
+ if (!group) return;
247
+ const all = [...group.querySelectorAll<HTMLElement>('[data-segment]')];
248
+ const index = all.findIndex((el) => el.dataset.segment === from);
249
+ const next = all[index + delta];
250
+ if (!next) return;
251
+ next.focus();
252
+ // Selecting means the next digit replaces what is there rather than
253
+ // appending to it, which is what a segment the caret has just arrived in
254
+ // has to do.
255
+ if (next instanceof HTMLInputElement) next.select();
256
+ }
257
+
258
+ function stepBy(segment: DigitSegment, delta: number, unit: number): void {
259
+ draft = null;
260
+ // Empty segments count as zero for the arithmetic and stay empty unless
261
+ // they are the one being stepped: stepSegment does not carry, and neither
262
+ // does this.
263
+ const keep = meridiem;
264
+ const base: TimeParts = { h: hour ?? 0, mi: minute ?? 0, s: second ?? 0 };
265
+ const stepped = stepSegment(base, segment, delta, unit);
266
+ if (segment === 'hour') {
267
+ // In 12-hour display the hour cycles inside its own meridiem, 12 to 11
268
+ // and back. Letting it flip AM to PM would be the hour segment writing
269
+ // into the segment beside it.
270
+ hour = hour12 ? from12Hour(to12Hour(stepped.h).hour, keep) : stepped.h;
271
+ } else if (segment === 'minute') {
272
+ minute = stepped.mi;
273
+ } else {
274
+ second = stepped.s;
275
+ }
276
+ commit();
277
+ }
278
+
279
+ function typeDigit(segment: DigitSegment, digit: string): void {
280
+ const high = segmentMax(segment);
281
+ const pending = draft?.segment === segment ? draft.text : '';
282
+ const combined = Number(pending + digit);
283
+
284
+ if (pending !== '' && combined <= high) {
285
+ draft = null;
286
+ setSegment(segment, combined);
287
+ commit();
288
+ focusSegment(segment, 1);
289
+ return;
290
+ }
291
+
292
+ // Either the first digit, or a second one that cannot follow the first. A
293
+ // digit no legal value can be built on top of decides the segment on its
294
+ // own: 5 in a 24-hour hour is 05, because 50 is not an hour.
295
+ const single = Number(digit);
296
+ if (single * 10 > high) {
297
+ draft = null;
298
+ setSegment(segment, single);
299
+ commit();
300
+ focusSegment(segment, 1);
301
+ return;
302
+ }
303
+ draft = { segment, text: digit };
304
+ }
305
+
306
+ function onSegmentKeydown(segment: DigitSegment, e: KeyboardEvent): void {
307
+ if (disabled) return;
308
+
309
+ switch (e.key) {
310
+ case 'ArrowUp':
311
+ case 'ArrowDown':
312
+ e.preventDefault();
313
+ // The hour moves by one whatever the step is. A step of 15 is a
314
+ // statement about minutes, and quarter-day hours are not a thing.
315
+ stepBy(segment, e.key === 'ArrowUp' ? 1 : -1, segment === 'hour' ? 1 : stepUnit);
316
+ return;
317
+ case 'PageUp':
318
+ case 'PageDown':
319
+ e.preventDefault();
320
+ stepBy(segment, e.key === 'PageUp' ? 1 : -1, PAGE_STEP);
321
+ return;
322
+ case 'Home':
323
+ case 'End':
324
+ e.preventDefault();
325
+ draft = null;
326
+ setSegment(segment, e.key === 'Home' ? segmentMin(segment) : segmentMax(segment));
327
+ commit();
328
+ return;
329
+ case 'ArrowLeft':
330
+ e.preventDefault();
331
+ focusSegment(segment, -1);
332
+ return;
333
+ case 'ArrowRight':
334
+ e.preventDefault();
335
+ focusSegment(segment, 1);
336
+ return;
337
+ case 'Backspace':
338
+ e.preventDefault();
339
+ draft = null;
340
+ clearSegment(segment);
341
+ commit();
342
+ focusSegment(segment, -1);
343
+ return;
344
+ case 'Delete':
345
+ e.preventDefault();
346
+ draft = null;
347
+ clearSegment(segment);
348
+ commit();
349
+ return;
350
+ default:
351
+ break;
352
+ }
353
+
354
+ if (/^\d$/.test(e.key)) {
355
+ e.preventDefault();
356
+ typeDigit(segment, e.key);
357
+ return;
358
+ }
359
+
360
+ // Anything else printable would land in the input as free text. Tab,
361
+ // Escape and every shortcut keep their native behaviour.
362
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) e.preventDefault();
363
+ }
364
+
365
+ function onSegmentInput(segment: DigitSegment, e: Event & { currentTarget: HTMLInputElement }) {
366
+ // Paste, drop and an IME never reach keydown. The segment is drawn from
367
+ // state, so nothing that arrives this way is parsed: the field simply puts
368
+ // back what it was showing.
369
+ e.currentTarget.value = shown(segment);
370
+ }
371
+
372
+ function onSegmentBlur(segment: DigitSegment): void {
373
+ if (draft?.segment !== segment) return;
374
+ const single = Number(draft.text);
375
+ draft = null;
376
+ setSegment(segment, single);
377
+ commit();
378
+ }
379
+
380
+ function onMeridiemChange(e: Event & { currentTarget: HTMLSelectElement }): void {
381
+ const next = e.currentTarget.value === 'PM' ? 'PM' : 'AM';
382
+ meridiemPref = next;
383
+ if (hour !== null && to12Hour(hour).meridiem !== next) {
384
+ hour = stepSegment({ h: hour, mi: 0, s: 0 }, 'meridiem', 1, 1).h;
385
+ }
386
+ commit();
387
+ }
388
+
389
+ function onMeridiemKeydown(e: KeyboardEvent): void {
390
+ // Up and down still pick an option, which is what a select does. Left and
391
+ // right belong to the field: they walk the segments.
392
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
393
+ e.preventDefault();
394
+ focusSegment('meridiem', e.key === 'ArrowLeft' ? -1 : 1);
395
+ }
396
+ </script>
397
+
398
+ <div class="{FIELD_WRAP} {klass}">
399
+ {#if label}
400
+ <!-- `for` the hour so a click lands somewhere useful, while the group below
401
+ takes its name from this same element. The hour keeps its own aria-label,
402
+ which is what a screen reader reads for it. -->
403
+ <label id="{fieldId}-label" for="{fieldId}-hour" class={FIELD_LABEL}>
404
+ {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
405
+ </label>
406
+ {/if}
407
+
408
+ <div
409
+ bind:this={group}
410
+ role="group"
411
+ aria-labelledby={label ? `${fieldId}-label` : undefined}
412
+ aria-label={label ? undefined : 'Time'}
413
+ aria-describedby={describedBy(fieldId, error, hint)}
414
+ class="{CONTROL_SEGMENTED} {segmentedBorder(!!error)} {disabled
415
+ ? 'cursor-not-allowed opacity-50'
416
+ : ''}"
417
+ >
418
+ {#each digitSegments as segment, i (segment)}
419
+ {#if i > 0}
420
+ <span aria-hidden="true" class="text-faint">:</span>
421
+ {/if}
422
+ <input
423
+ data-segment={segment}
424
+ id="{fieldId}-{segment}"
425
+ type="text"
426
+ inputmode="numeric"
427
+ role="spinbutton"
428
+ autocomplete="off"
429
+ spellcheck="false"
430
+ maxlength="2"
431
+ {disabled}
432
+ value={shown(segment)}
433
+ aria-label={SEGMENT_LABEL[segment]}
434
+ aria-valuenow={shownNow(segment)}
435
+ aria-valuemin={segmentMin(segment)}
436
+ aria-valuemax={segmentMax(segment)}
437
+ aria-valuetext={valueText(segment)}
438
+ aria-invalid={error ? 'true' : undefined}
439
+ aria-required={required ? 'true' : undefined}
440
+ onkeydown={(e) => onSegmentKeydown(segment, e)}
441
+ oninput={(e) => onSegmentInput(segment, e)}
442
+ onblur={() => onSegmentBlur(segment)}
443
+ onfocus={(e) => e.currentTarget.select()}
444
+ class="{CONTROL_SEGMENT} w-7"
445
+ />
446
+ {/each}
447
+
448
+ {#if hour12}
449
+ <!-- A select, because two values is exactly what a select is for: it keeps
450
+ the native option list, the native keyboard and the native
451
+ announcement instead of a button pretending to be one. -->
452
+ <select
453
+ data-segment="meridiem"
454
+ id="{fieldId}-meridiem"
455
+ {disabled}
456
+ value={meridiem}
457
+ aria-label="AM or PM"
458
+ aria-invalid={error ? 'true' : undefined}
459
+ aria-required={required ? 'true' : undefined}
460
+ onchange={onMeridiemChange}
461
+ onkeydown={onMeridiemKeydown}
462
+ class="{CONTROL_SEGMENT} ml-1"
463
+ >
464
+ <option value="AM">AM</option>
465
+ <option value="PM">PM</option>
466
+ </select>
467
+ {/if}
468
+ </div>
469
+
470
+ <!-- The segments carry no name, so a form serializes this and only this. Three
471
+ separately named parts would post three fields the server never asked for,
472
+ and in 12-hour display two of them would be the wrong numbers. -->
473
+ <input type="hidden" {name} {value} />
474
+
475
+ {#if error}
476
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
477
+ {:else if hint}
478
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
479
+ {/if}
480
+ </div>
@@ -0,0 +1,23 @@
1
+ interface Props {
2
+ value?: string;
3
+ id?: string;
4
+ name?: string;
5
+ label?: string;
6
+ hint?: string;
7
+ error?: string;
8
+ /** Show a seconds segment. */
9
+ seconds?: boolean;
10
+ /** Draw 12-hour with an AM/PM segment. The wire format stays 24-hour. */
11
+ hour12?: boolean;
12
+ /** Minute and second step. Arrow keys move by this. */
13
+ step?: number;
14
+ min?: string;
15
+ max?: string;
16
+ required?: boolean;
17
+ disabled?: boolean;
18
+ class?: string;
19
+ onchange?: (value: string) => void;
20
+ }
21
+ declare const TimePicker: import("svelte").Component<Props, {}, "value">;
22
+ type TimePicker = ReturnType<typeof TimePicker>;
23
+ export default TimePicker;
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The row of filters and actions above a table or a list.
4
+ *
5
+ * Every filter row was hand-rolled, and they disagreed with each other about
6
+ * how their controls sit: some stretched their children to the tallest one,
7
+ * some aligned on the top edge, so a search box, a select and a button in one
8
+ * row sat visibly off by a couple of pixels. The alignment is stated once
9
+ * here, on their vertical centres, which is the only arrangement that holds
10
+ * when one control is taller than the rest.
11
+ *
12
+ * The row wraps. A filter row that overflows takes a horizontal scrollbar the
13
+ * user has to find before the last filter can be reached, and on a phone that
14
+ * is every filter row.
15
+ */
16
+ import type { Snippet } from 'svelte';
17
+
18
+ interface Props {
19
+ /** Accessible name, so a screen reader can tell two toolbars apart. */
20
+ label?: string;
21
+ class?: string;
22
+ /** Pushed to the trailing edge. */
23
+ actions?: Snippet;
24
+ children: Snippet;
25
+ }
26
+
27
+ let { label = undefined, class: klass = '', actions, children }: Props = $props();
28
+ </script>
29
+
30
+ <!-- The children are flex items of the toolbar itself rather than of a wrapper.
31
+ A wrapper would align its own contents and leave the toolbar aligning one
32
+ box, which is how a filter row loses the alignment it is here to keep. -->
33
+ <div role="toolbar" aria-label={label} class="flex flex-wrap items-center gap-3 {klass}">
34
+ {@render children()}
35
+
36
+ {#if actions}
37
+ <div class="ms-auto flex flex-wrap items-center gap-2">{@render actions()}</div>
38
+ {/if}
39
+ </div>
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The row of filters and actions above a table or a list.
3
+ *
4
+ * Every filter row was hand-rolled, and they disagreed with each other about
5
+ * how their controls sit: some stretched their children to the tallest one,
6
+ * some aligned on the top edge, so a search box, a select and a button in one
7
+ * row sat visibly off by a couple of pixels. The alignment is stated once
8
+ * here, on their vertical centres, which is the only arrangement that holds
9
+ * when one control is taller than the rest.
10
+ *
11
+ * The row wraps. A filter row that overflows takes a horizontal scrollbar the
12
+ * user has to find before the last filter can be reached, and on a phone that
13
+ * is every filter row.
14
+ */
15
+ import type { Snippet } from 'svelte';
16
+ interface Props {
17
+ /** Accessible name, so a screen reader can tell two toolbars apart. */
18
+ label?: string;
19
+ class?: string;
20
+ /** Pushed to the trailing edge. */
21
+ actions?: Snippet;
22
+ children: Snippet;
23
+ }
24
+ declare const Toolbar: import("svelte").Component<Props, {}, "">;
25
+ type Toolbar = ReturnType<typeof Toolbar>;
26
+ export default Toolbar;