@lyeve-labs/ui-kit 0.11.2 → 0.13.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.
- package/README.md +1 -1
- package/dist/components/AccordionItem.svelte +1 -1
- package/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Button.svelte +26 -4
- package/dist/components/Card.svelte +61 -3
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -59
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Drawer.svelte +15 -4
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/Modal.svelte +25 -8
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +58 -17
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -32
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +259 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toaster.svelte +9 -2
- package/dist/components/Toggle.svelte +5 -1
- package/dist/components/Toggle.svelte.d.ts +2 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/Tooltip.svelte +48 -12
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/components/dialog/Dialog.svelte +15 -58
- package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
- package/dist/components/dialog/dialog-manager.svelte.js +21 -21
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +31 -0
- package/dist/internal/field.js +42 -1
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/overlay.d.ts +25 -0
- package/dist/internal/overlay.js +92 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +66 -25
- package/package.json +4 -2
- package/src/lib/styles/theme.css +66 -25
|
@@ -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={
|
|
166
|
-
{label}{#if required}<span class="text-danger ml-0.5" aria-
|
|
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
|
-
{
|
|
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-
|
|
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={
|
|
320
|
+
<p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
|
|
279
321
|
{:else if hint}
|
|
280
|
-
<p id={
|
|
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>
|