@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,126 @@
1
+ <script lang="ts">
2
+ import { Check, Copy } from '@lucide/svelte';
3
+
4
+ interface Props {
5
+ /** The text written to the clipboard. */
6
+ value: string;
7
+ /** The button's accessible name. */
8
+ label?: string;
9
+ /** Announced and shown after a successful copy. */
10
+ copiedLabel?: string;
11
+ /** Icon size in px. */
12
+ size?: number;
13
+ class?: string;
14
+ }
15
+
16
+ let {
17
+ value,
18
+ label = 'Copy',
19
+ copiedLabel = 'Copied',
20
+ size = 14,
21
+ class: klass = '',
22
+ }: Props = $props();
23
+
24
+ type Status = 'idle' | 'copied' | 'failed';
25
+
26
+ /**
27
+ * How long the check stays up before the button returns to its resting icon.
28
+ *
29
+ * Stated once. The copy affordance this replaces was hand-rolled per page, and
30
+ * the pages that reverted at all reverted at three different speeds.
31
+ */
32
+ const REVERT_MS = 1500;
33
+
34
+ /**
35
+ * What the page says when the write did not happen.
36
+ *
37
+ * Every one of those hand-rolled copies reported nothing on failure: the user
38
+ * pressed the button, the icon did not move, and the value was still only on
39
+ * screen with no way to tell whether it had been taken.
40
+ */
41
+ const FAILED_MESSAGE = 'Copy failed';
42
+
43
+ let status = $state<Status>('idle');
44
+ let timer: ReturnType<typeof setTimeout> | undefined;
45
+
46
+ const message = $derived.by(() => {
47
+ if (status === 'copied') return copiedLabel;
48
+ if (status === 'failed') return FAILED_MESSAGE;
49
+ return '';
50
+ });
51
+
52
+ /** Writes the value, and reports whether it landed. */
53
+ async function write(): Promise<boolean> {
54
+ /*
55
+ * The clipboard has to be read into a binding and tested. It is undefined
56
+ * on an insecure origin, and `navigator.clipboard?.writeText(value)`
57
+ * resolves to undefined there rather than throwing, so awaiting it succeeds
58
+ * and the button reports a copy that never happened.
59
+ */
60
+ const clipboard = navigator.clipboard;
61
+ if (!clipboard) return false;
62
+ try {
63
+ // writeText rejects while the document is not focused, which is what a
64
+ // press from a background window or an inspector pane produces.
65
+ await clipboard.writeText(value);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ async function copy() {
73
+ // A second press restarts the window. Without this the first press's timer
74
+ // reverts the second copy part way through its own.
75
+ clearTimeout(timer);
76
+ status = (await write()) ? 'copied' : 'failed';
77
+ timer = setTimeout(() => (status = 'idle'), REVERT_MS);
78
+ }
79
+
80
+ /*
81
+ * The revert timer outlives the component without this. A table that swaps
82
+ * its rows while a check is up leaves the callback assigning to a destroyed
83
+ * instance.
84
+ */
85
+ $effect(() => () => clearTimeout(timer));
86
+ </script>
87
+
88
+ <!--
89
+ The live region is mounted for the life of the button and empty at rest.
90
+ Rendering it only after a copy announces nothing: assistive technology watches
91
+ an existing region for a change, and a region that arrives already holding its
92
+ text is not a change. Toaster carries the same rule for the same reason.
93
+
94
+ The gap exists only while there is a message, so an idle button is the width
95
+ of its icon and a row of them lines up.
96
+ -->
97
+ <span class="inline-flex items-center {message ? 'gap-1.5' : ''} {klass}">
98
+ <!--
99
+ The accessible name stays `label` through the copied state. The live region
100
+ below is what reports the result, and renaming the button as well would
101
+ announce the same word twice and then leave a control called "Copied" that
102
+ copies.
103
+ -->
104
+ <button
105
+ type="button"
106
+ aria-label={label}
107
+ onclick={copy}
108
+ class="inline-flex items-center justify-center rounded-md p-1 outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand {status ===
109
+ 'copied'
110
+ ? 'text-success'
111
+ : 'text-faint hover:text-fg'}"
112
+ >
113
+ {#if status === 'copied'}
114
+ <Check {size} aria-hidden="true" />
115
+ {:else}
116
+ <Copy {size} aria-hidden="true" />
117
+ {/if}
118
+ </button>
119
+
120
+ <span
121
+ role="status"
122
+ aria-live="polite"
123
+ aria-atomic="true"
124
+ class="text-xs {status === 'failed' ? 'text-danger' : 'text-success'}">{message}</span
125
+ >
126
+ </span>
@@ -0,0 +1,14 @@
1
+ interface Props {
2
+ /** The text written to the clipboard. */
3
+ value: string;
4
+ /** The button's accessible name. */
5
+ label?: string;
6
+ /** Announced and shown after a successful copy. */
7
+ copiedLabel?: string;
8
+ /** Icon size in px. */
9
+ size?: number;
10
+ class?: string;
11
+ }
12
+ declare const CopyButton: import("svelte").Component<Props, {}, "">;
13
+ type CopyButton = ReturnType<typeof CopyButton>;
14
+ export default CopyButton;
@@ -54,6 +54,19 @@
54
54
  'December',
55
55
  ];
56
56
 
57
+ /*
58
+ * $props.id() and not a random string: a random id differs between the server
59
+ * and the client, so every id derived from it changes under the first paint.
60
+ *
61
+ * The fallback is the fix for a standalone <DatePicker label="Starts" />,
62
+ * which rendered a <label> with no `for` at all: the trigger was named by its
63
+ * placeholder, so the field announced as "Select a date" and the visible
64
+ * label pointed at nothing.
65
+ */
66
+ const uid = $props.id();
67
+ const fieldId = $derived(id ?? uid);
68
+ const calendarId = $derived(`${fieldId}-calendar`);
69
+
57
70
  let open = $state(false);
58
71
  let containerEl: HTMLDivElement | undefined = $state();
59
72
 
@@ -162,18 +175,37 @@
162
175
 
163
176
  <div class="{FIELD_WRAP} {cls}" bind:this={containerEl}>
164
177
  {#if label}
165
- <label for={id} class={FIELD_LABEL}>
166
- {label}{#if required}<span class="text-danger ml-0.5" aria-label="required">*</span>{/if}
178
+ <label for={fieldId} class={FIELD_LABEL}>
179
+ {label}{#if required}<span class="text-danger ml-0.5" aria-hidden="true">*</span>{/if}
167
180
  </label>
168
181
  {/if}
169
182
 
170
183
  <div class="relative">
184
+ <!--
185
+ role="combobox" over the button's own role, and aria-haspopup="dialog"
186
+ beside it. ARIA 1.2 does not list aria-required for button, so the
187
+ attribute was stripped from the accessibility tree and the requirement
188
+ reached a reader nowhere: the asterisk in the label is decorative and this
189
+ picker posts no native input that could take `required`. A combobox is an
190
+ input whose popup helps set its value, which is what the trigger and the
191
+ calendar below are, and it supports aria-required and aria-invalid.
192
+
193
+ aria-controls is set only while the calendar is on screen. The popup is
194
+ rendered on open, so naming it while it is closed points a reader at an
195
+ element that does not exist.
196
+ -->
171
197
  <button
172
198
  type="button"
173
- {id}
199
+ id={fieldId}
200
+ role="combobox"
201
+ aria-haspopup="dialog"
202
+ aria-expanded={open}
203
+ aria-controls={open ? calendarId : undefined}
174
204
  {disabled}
175
205
  onclick={() => (open ? (open = false) : openCal())}
176
- aria-describedby={describedBy(id, error, hint)}
206
+ aria-invalid={error ? 'true' : undefined}
207
+ aria-required={required ? 'true' : undefined}
208
+ aria-describedby={describedBy(fieldId, error, hint)}
177
209
  class="{CONTROL_BASE} {controlBorder(!!error)} flex items-center justify-between text-left"
178
210
  >
179
211
  <span class={display ? 'text-fg' : 'text-faint'}>{display || placeholder}</span>
@@ -199,7 +231,17 @@
199
231
  </button>
200
232
 
201
233
  {#if open}
234
+ <!--
235
+ role="dialog" with a name of its own, because the trigger advertises
236
+ aria-haspopup="dialog". A bare div leaves that claim unbacked, and a
237
+ dialog with no name is announced as "dialog" and nothing else. The name
238
+ is fixed rather than built from the field label, so a reader hears what
239
+ the popup does instead of the label they just heard.
240
+ -->
202
241
  <div
242
+ id={calendarId}
243
+ role="dialog"
244
+ aria-label="Choose date"
203
245
  class="absolute z-50 mt-1 w-[17rem] rounded-xl border border-line bg-surface shadow-2xl p-3"
204
246
  >
205
247
  <!-- Header -->
@@ -275,8 +317,8 @@
275
317
  </div>
276
318
 
277
319
  {#if error}
278
- <p id={id ? `${id}-error` : undefined} class={FIELD_ERROR}>{error}</p>
320
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
279
321
  {:else if hint}
280
- <p id={id ? `${id}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
322
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
281
323
  {/if}
282
324
  </div>
@@ -0,0 +1,337 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A date and a time as one field, composed from DatePicker and TimePicker.
4
+ *
5
+ * The value is a local wall clock with no zone:
6
+ *
7
+ * unset ''
8
+ * seconds false '2026-03-04T15:30'
9
+ * seconds true '2026-03-04T15:30:45'
10
+ *
11
+ * Four digit year, two digit zero padded everything else, `T` between the two
12
+ * halves. No offset, no trailing Z, no fraction. A string that is not one of
13
+ * those three leaves both halves empty rather than being guessed at, and a
14
+ * bare `YYYY-MM-DD` is read as that day with no time, because that is the
15
+ * spelling a date field's own value carries.
16
+ *
17
+ * Why the value carries no offset. A datetime column is zone aware on
18
+ * PostgreSQL, which stores TIMESTAMPTZ, and zone blind on MySQL and SQL
19
+ * Server, which store DATETIME and DATETIME2. An offset written into the
20
+ * value is kept by one of those three and dropped by the other two, so one
21
+ * string would name a different instant depending on where it landed. The
22
+ * offset a browser can supply is the viewer's own and not the one the content
23
+ * is scheduled against, so an editor in Jakarta and an editor in New York
24
+ * would write two different strings for the same intent. Converting a wall
25
+ * clock to an absolute instant belongs at the boundary that knows which zone
26
+ * to apply. This control knows only the browser's, so it does not convert.
27
+ *
28
+ * The rule this component exists to keep: no string is ever handed to the
29
+ * Date parser. `new Date('2026-01-02')` is UTC midnight by specification and
30
+ * `new Date('2026-01-02T00:00')` is local midnight, so one helper applied to
31
+ * the date half and to the whole value lands in two different zones. Read the
32
+ * first form back through the local getters west of UTC and the day is 1
33
+ * January. That is the off by one day, it shows only for the part of the
34
+ * world that is not on UTC, and it has already been fixed once in an
35
+ * application built on this kit. Every piece of arithmetic below comes from
36
+ * internal/calendar.ts and internal/time.ts, neither of which constructs a
37
+ * Date from text.
38
+ *
39
+ * The two halves are one field, so the label, the hint and the error are
40
+ * stated once here and the children are given none of their own. The label
41
+ * points at the date trigger, which is a button and so is labelable, which
42
+ * means clicking the label opens the calendar. The pair sits in a group that
43
+ * carries the description, because a message about the whole instant belongs
44
+ * to neither half on its own.
45
+ */
46
+
47
+ import { compareDates, parseISODate, toISODate } from '../internal/calendar.js';
48
+ import type { CalendarDate } from '../internal/calendar.js';
49
+ import {
50
+ FIELD_ERROR,
51
+ FIELD_HINT,
52
+ FIELD_LABEL,
53
+ FIELD_WRAP,
54
+ describedBy,
55
+ } from '../internal/field.js';
56
+ import { parseISOTime, toISOTime } from '../internal/time.js';
57
+ import type { TimeParts } from '../internal/time.js';
58
+ import DatePicker from './DatePicker.svelte';
59
+ import TimePicker from './TimePicker.svelte';
60
+
61
+ interface Props {
62
+ /** `YYYY-MM-DDTHH:mm`, or `YYYY-MM-DDTHH:mm:ss` with `seconds`. Empty for unset. */
63
+ value?: string;
64
+ id?: string;
65
+ name?: string;
66
+ label?: string;
67
+ hint?: string;
68
+ error?: string;
69
+ /** Show a seconds segment on the time half, and carry seconds in the value. */
70
+ seconds?: boolean;
71
+ /** Draw the time half 12-hour with an AM/PM segment. The value stays 24-hour. */
72
+ hour12?: boolean;
73
+ /** Minute and second step on the time half. */
74
+ step?: number;
75
+ /** Earliest allowed instant, in the value format. A bare date means its midnight. */
76
+ min?: string;
77
+ /** Latest allowed instant, in the value format. A bare date means its last second. */
78
+ max?: string;
79
+ required?: boolean;
80
+ disabled?: boolean;
81
+ class?: string;
82
+ onchange?: (value: string) => void;
83
+ }
84
+
85
+ let {
86
+ value = $bindable(''),
87
+ id = undefined,
88
+ name = undefined,
89
+ label = undefined,
90
+ hint = undefined,
91
+ error = undefined,
92
+ seconds = false,
93
+ hour12 = false,
94
+ step = 1,
95
+ min = undefined,
96
+ max = undefined,
97
+ required = false,
98
+ disabled = false,
99
+ class: klass = '',
100
+ onchange = undefined,
101
+ }: Props = $props();
102
+
103
+ // $props.id() and not a random string: a random id differs between the server
104
+ // render and hydration, so the label `for`, the group's aria-labelledby and
105
+ // the message ids would each point at an element the client never rendered.
106
+ const uid = $props.id();
107
+ const fieldId = $derived(id ?? uid);
108
+
109
+ /** A day and a clock reading with nothing between them that names a zone. */
110
+ interface Moment {
111
+ date: CalendarDate;
112
+ time: TimeParts;
113
+ }
114
+
115
+ /** Named once because both the split and the join depend on the same character. */
116
+ const DATE_TIME_SEPARATOR = 'T';
117
+
118
+ const MIDNIGHT: TimeParts = { h: 0, mi: 0, s: 0 };
119
+ const LAST_SECOND: TimeParts = { h: 23, mi: 59, s: 59 };
120
+
121
+ const SECONDS_PER_MINUTE = 60;
122
+ const SECONDS_PER_HOUR = 3600;
123
+
124
+ /** The whole clock reading as one number, so a comparison cannot compare the minute first. */
125
+ function secondsOfDay(time: TimeParts): number {
126
+ return time.h * SECONDS_PER_HOUR + time.mi * SECONDS_PER_MINUTE + time.s;
127
+ }
128
+
129
+ function compareMoments(a: Moment, b: Moment): number {
130
+ const byDay = compareDates(a.date, b.date);
131
+ if (byDay !== 0) return byDay;
132
+ const at = secondsOfDay(a.time);
133
+ const bt = secondsOfDay(b.time);
134
+ if (at === bt) return 0;
135
+ return at < bt ? -1 : 1;
136
+ }
137
+
138
+ /**
139
+ * Splits the value into the two strings the children read.
140
+ *
141
+ * Each half is parsed by the module that owns its format and written back
142
+ * from the parsed parts, so a half the parser rejects reaches the child as an
143
+ * empty string rather than as text it would show and then refuse to edit. A
144
+ * value with no day has no time either: a clock reading with no date names no
145
+ * instant, and showing one would claim the field holds something it cannot
146
+ * emit.
147
+ */
148
+ function splitValue(raw: string | undefined): { date: string; time: string } {
149
+ const text = raw ?? '';
150
+ const cut = text.indexOf(DATE_TIME_SEPARATOR);
151
+ const day = parseISODate(cut < 0 ? text : text.slice(0, cut));
152
+ if (day === null) return { date: '', time: '' };
153
+ const clock = cut < 0 ? null : parseISOTime(text.slice(cut + 1));
154
+ return {
155
+ date: toISODate(day),
156
+ // Rewritten to the shape `seconds` asks for, so a value carrying seconds
157
+ // into a field with no second segment does not sit in the time half as a
158
+ // string that control would never write.
159
+ time: clock === null ? '' : toISOTime(clock, seconds),
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Reads a bound as a whole instant.
165
+ *
166
+ * A bare `YYYY-MM-DD` is filled with the end of the range it stands for: a
167
+ * min of a bare day starts at its midnight and a max of a bare day runs to
168
+ * its last second. Filling both with midnight would make a max of the last
169
+ * allowed day reject every time on that day except midnight, which reads to
170
+ * the user as a day they are offered and cannot use.
171
+ */
172
+ function parseBound(raw: string | undefined, fill: TimeParts): Moment | null {
173
+ if (!raw) return null;
174
+ const cut = raw.indexOf(DATE_TIME_SEPARATOR);
175
+ const day = parseISODate(cut < 0 ? raw : raw.slice(0, cut));
176
+ if (day === null) return null;
177
+ if (cut < 0) return { date: day, time: fill };
178
+ const clock = parseISOTime(raw.slice(cut + 1));
179
+ return clock === null ? null : { date: day, time: clock };
180
+ }
181
+
182
+ /** Bounds the whole instant. Clamping a half on its own is the defect this field avoids. */
183
+ function clampMoment(moment: Moment, lower: Moment | null, upper: Moment | null): Moment {
184
+ if (lower !== null && compareMoments(moment, lower) < 0) return lower;
185
+ if (upper !== null && compareMoments(moment, upper) > 0) return upper;
186
+ return moment;
187
+ }
188
+
189
+ // Each half holds its own string, so one can be filled while the other is
190
+ // empty. Deriving both from `value` instead would mean a date the user picks
191
+ // before touching the clock has nowhere to live until the clock is set.
192
+ const seed = splitValue(value);
193
+ let datePart = $state(seed.date);
194
+ let timePart = $state(seed.time);
195
+
196
+ // What was last handed to or taken from the consumer. Without it the effect
197
+ // below would adopt the field's own writes and reset both halves on every
198
+ // keystroke.
199
+ let mirrored = value ?? '';
200
+
201
+ // The seed above runs once, so a value the consumer changes later has to be
202
+ // adopted here. Seeding in this effect instead would leave the server render
203
+ // showing an empty field for a value it was given.
204
+ $effect(() => {
205
+ const incoming = value ?? '';
206
+ if (incoming === mirrored) return;
207
+ mirrored = incoming;
208
+ const next = splitValue(incoming);
209
+ datePart = next.date;
210
+ timePart = next.time;
211
+ });
212
+
213
+ const lowerBound = $derived(parseBound(min, MIDNIGHT));
214
+ const upperBound = $derived(parseBound(max, LAST_SECOND));
215
+ const chosenDay = $derived(parseISODate(datePart));
216
+
217
+ const dateMin = $derived(lowerBound === null ? undefined : toISODate(lowerBound.date));
218
+ const dateMax = $derived(upperBound === null ? undefined : toISODate(upperBound.date));
219
+
220
+ /**
221
+ * A time bound reaches the time half only on the day that bound falls on.
222
+ *
223
+ * With min 2026-03-04T09:00 and max 2026-03-06T17:00, eight in the morning on
224
+ * the fifth is inside the range. Handing 09:00 to the time half on every day
225
+ * would rewrite it to nine, which is the per-half clamp this component exists
226
+ * to keep out.
227
+ */
228
+ const timeMin = $derived(
229
+ lowerBound !== null && chosenDay !== null && compareDates(chosenDay, lowerBound.date) === 0
230
+ ? toISOTime(lowerBound.time, seconds)
231
+ : undefined,
232
+ );
233
+ const timeMax = $derived(
234
+ upperBound !== null && chosenDay !== null && compareDates(chosenDay, upperBound.date) === 0
235
+ ? toISOTime(upperBound.time, seconds)
236
+ : undefined,
237
+ );
238
+
239
+ function publish(next: string): void {
240
+ if (next === value) return;
241
+ mirrored = next;
242
+ value = next;
243
+ onchange?.(next);
244
+ }
245
+
246
+ /**
247
+ * Writes the two halves out as one instant.
248
+ *
249
+ * A day with no clock reading is midnight. Refusing to write anything would
250
+ * leave the calendar marking a day the consumer does not hold, and a day is
251
+ * the half a user reaches for first. A clock reading with no day stays on
252
+ * screen and publishes nothing, because there is no instant to name yet.
253
+ */
254
+ function commit(): void {
255
+ const day = parseISODate(datePart);
256
+ if (day === null) {
257
+ publish('');
258
+ return;
259
+ }
260
+ const clock = parseISOTime(timePart) ?? MIDNIGHT;
261
+ const bounded = clampMoment({ date: day, time: clock }, lowerBound, upperBound);
262
+ datePart = toISODate(bounded.date);
263
+ timePart = toISOTime(bounded.time, seconds);
264
+ publish(`${datePart}${DATE_TIME_SEPARATOR}${timePart}`);
265
+ }
266
+
267
+ function onDateChange(next: string): void {
268
+ datePart = next;
269
+ commit();
270
+ }
271
+
272
+ function onTimeChange(next: string): void {
273
+ timePart = next;
274
+ commit();
275
+ }
276
+ </script>
277
+
278
+ <div class="{FIELD_WRAP} {klass}">
279
+ {#if label}
280
+ <!-- `for` the date trigger. A button is labelable, so the field's own label
281
+ names it and clicking that label opens the calendar. The group below
282
+ takes its name from this same element. -->
283
+ <label id="{fieldId}-label" for="{fieldId}-date" class={FIELD_LABEL}>
284
+ {label}{#if required}<span class="ml-0.5 text-danger" aria-hidden="true">*</span>{/if}
285
+ </label>
286
+ {/if}
287
+
288
+ <!--
289
+ role="group" and no aria-invalid. ARIA does not let a group carry that
290
+ property, and the compiler's a11y gate rejects it. The error reaches a
291
+ reader through aria-describedby instead, which is the route a control with
292
+ no value of its own already uses.
293
+ -->
294
+ <div
295
+ role="group"
296
+ aria-labelledby={label ? `${fieldId}-label` : undefined}
297
+ aria-label={label ? undefined : 'Date and time'}
298
+ aria-describedby={describedBy(fieldId, error, hint)}
299
+ class="flex flex-wrap items-start gap-2"
300
+ >
301
+ <!-- Neither child is given a label, a hint or an error. Each renders its own
302
+ when it has one, and three message rows under one field is what this
303
+ component was written to replace. -->
304
+ <DatePicker
305
+ id="{fieldId}-date"
306
+ value={datePart}
307
+ min={dateMin}
308
+ max={dateMax}
309
+ {required}
310
+ {disabled}
311
+ class="min-w-44 flex-1"
312
+ onchange={onDateChange}
313
+ />
314
+ <TimePicker
315
+ id="{fieldId}-time"
316
+ value={timePart}
317
+ min={timeMin}
318
+ max={timeMax}
319
+ {seconds}
320
+ {hour12}
321
+ {step}
322
+ {required}
323
+ {disabled}
324
+ onchange={onTimeChange}
325
+ />
326
+ </div>
327
+
328
+ <!-- The one control a form serializes. The halves carry no name of their own,
329
+ so a submit posts the instant and not the two strings it was built from. -->
330
+ <input type="hidden" {name} {value} />
331
+
332
+ {#if error}
333
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
334
+ {:else if hint}
335
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
336
+ {/if}
337
+ </div>
@@ -0,0 +1,26 @@
1
+ interface Props {
2
+ /** `YYYY-MM-DDTHH:mm`, or `YYYY-MM-DDTHH:mm:ss` with `seconds`. Empty for unset. */
3
+ value?: string;
4
+ id?: string;
5
+ name?: string;
6
+ label?: string;
7
+ hint?: string;
8
+ error?: string;
9
+ /** Show a seconds segment on the time half, and carry seconds in the value. */
10
+ seconds?: boolean;
11
+ /** Draw the time half 12-hour with an AM/PM segment. The value stays 24-hour. */
12
+ hour12?: boolean;
13
+ /** Minute and second step on the time half. */
14
+ step?: number;
15
+ /** Earliest allowed instant, in the value format. A bare date means its midnight. */
16
+ min?: string;
17
+ /** Latest allowed instant, in the value format. A bare date means its last second. */
18
+ max?: string;
19
+ required?: boolean;
20
+ disabled?: boolean;
21
+ class?: string;
22
+ onchange?: (value: string) => void;
23
+ }
24
+ declare const DateTimePicker: import("svelte").Component<Props, {}, "value">;
25
+ type DateTimePicker = ReturnType<typeof DateTimePicker>;
26
+ export default DateTimePicker;
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Key and value pairs, as a real description list.
4
+ *
5
+ * Detail pages laid these out with ad hoc grids of divs, which broke
6
+ * alignment between two pages showing the same record and, more importantly,
7
+ * conveyed nothing: a grid of divs has no relationship between a label and
8
+ * the value under it, so a screen reader reads a run of unattached strings
9
+ * and the listener has to infer which value belongs to which term. `dl`,
10
+ * `dt` and `dd` state that relationship in the markup, and it is the whole
11
+ * reason this component exists.
12
+ *
13
+ * A long value wraps. It is never truncated, because a clipped identifier
14
+ * cannot be read out, cannot be selected and cannot be copied, and an id or
15
+ * a key is exactly the kind of value a detail page is opened for. `break-words`
16
+ * covers the unbroken ones, so a token or a hash wraps instead of pushing the
17
+ * page sideways.
18
+ */
19
+ import type { Snippet } from 'svelte';
20
+
21
+ interface Item {
22
+ term: string;
23
+ value: string;
24
+ /** Rendered instead of value when present. */
25
+ detail?: Snippet;
26
+ }
27
+
28
+ type Layout = 'inline' | 'stacked';
29
+
30
+ interface Props {
31
+ items: Item[];
32
+ /** 'inline' puts the term and value on one row, 'stacked' puts the value under the term. */
33
+ layout?: Layout;
34
+ class?: string;
35
+ }
36
+
37
+ let { items, layout = 'inline', class: klass = '' }: Props = $props();
38
+
39
+ /**
40
+ * One pair.
41
+ *
42
+ * The `div` between `dl` and its `dt`/`dd` is what HTML allows for grouping a
43
+ * pair, and it is what makes the columns line up across rows: every row runs
44
+ * the same grid, so two pairs stay aligned however long either value is.
45
+ */
46
+ const ROW: Record<Layout, string> = {
47
+ inline: 'grid grid-cols-3 gap-x-4',
48
+ stacked: 'flex flex-col gap-0.5',
49
+ };
50
+
51
+ /**
52
+ * `min-w-0` is load bearing on the value. A grid item refuses to shrink below
53
+ * its content by default, so a long unbroken value would widen its column and
54
+ * push the term out of alignment instead of wrapping inside its own.
55
+ */
56
+ const VALUE: Record<Layout, string> = {
57
+ inline: 'col-span-2 min-w-0',
58
+ stacked: '',
59
+ };
60
+ </script>
61
+
62
+ <dl class="flex flex-col gap-2 {klass}">
63
+ <!-- Keyed by term: a description list names each term once, so the term is
64
+ the pair's identity and nothing else in the item is stable. -->
65
+ {#each items as item (item.term)}
66
+ <div class={ROW[layout]}>
67
+ <dt class="text-sm text-muted">{item.term}</dt>
68
+ <dd class="text-sm break-words text-fg {VALUE[layout]}">
69
+ {#if item.detail}
70
+ {@const detail = item.detail}
71
+ {@render detail()}
72
+ {:else}
73
+ {item.value}
74
+ {/if}
75
+ </dd>
76
+ </div>
77
+ {/each}
78
+ </dl>