@viniaraujo68/plinth 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +361 -2
- package/dist/anchoring.d.ts +61 -0
- package/dist/anchoring.js +151 -0
- package/dist/async-action.svelte.d.ts +37 -0
- package/dist/async-action.svelte.js +64 -0
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/tooltip.d.ts +37 -0
- package/dist/attachments/tooltip.js +166 -0
- package/dist/components/AsyncButton.svelte +49 -0
- package/dist/components/AsyncButton.svelte.d.ts +32 -0
- package/dist/components/Combobox.svelte +380 -0
- package/dist/components/Combobox.svelte.d.ts +74 -0
- package/dist/components/Copyable.svelte +116 -0
- package/dist/components/Copyable.svelte.d.ts +37 -0
- package/dist/components/DateRangePicker.svelte +266 -0
- package/dist/components/DateRangePicker.svelte.d.ts +66 -0
- package/dist/components/Dialog.svelte +102 -0
- package/dist/components/Dialog.svelte.d.ts +38 -0
- package/dist/components/Dropdown.svelte +204 -0
- package/dist/components/Dropdown.svelte.d.ts +49 -0
- package/dist/components/ErrorDisplay.svelte +59 -0
- package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
- package/dist/components/ErrorPage.svelte +119 -0
- package/dist/components/ErrorPage.svelte.d.ts +52 -0
- package/dist/components/JsonInput.svelte +106 -0
- package/dist/components/JsonInput.svelte.d.ts +40 -0
- package/dist/components/Listbox.svelte +257 -0
- package/dist/components/Listbox.svelte.d.ts +78 -0
- package/dist/components/LoadingButton.svelte +36 -0
- package/dist/components/LoadingButton.svelte.d.ts +22 -0
- package/dist/components/Logo.svelte +112 -0
- package/dist/components/Logo.svelte.d.ts +29 -0
- package/dist/components/Modal.svelte +187 -0
- package/dist/components/Modal.svelte.d.ts +62 -0
- package/dist/components/MultiSelect.svelte +256 -0
- package/dist/components/MultiSelect.svelte.d.ts +89 -0
- package/dist/components/OptionPanel.svelte +241 -0
- package/dist/components/OptionPanel.svelte.d.ts +51 -0
- package/dist/components/Paginator.svelte +205 -0
- package/dist/components/Paginator.svelte.d.ts +48 -0
- package/dist/components/RefreshButton.svelte +246 -0
- package/dist/components/RefreshButton.svelte.d.ts +46 -0
- package/dist/components/SegmentedControl.svelte +147 -0
- package/dist/components/SegmentedControl.svelte.d.ts +71 -0
- package/dist/components/Select.svelte +312 -0
- package/dist/components/Select.svelte.d.ts +68 -0
- package/dist/components/Skeleton.svelte +97 -0
- package/dist/components/Skeleton.svelte.d.ts +32 -0
- package/dist/components/Tooltip.svelte +60 -0
- package/dist/components/Tooltip.svelte.d.ts +58 -0
- package/dist/components/date-range.d.ts +101 -0
- package/dist/components/date-range.js +144 -0
- package/dist/components/index.d.ts +25 -0
- package/dist/components/index.js +37 -0
- package/dist/components/json-text.d.ts +22 -0
- package/dist/components/json-text.js +22 -0
- package/dist/components/pick.d.ts +18 -0
- package/dist/components/pick.js +18 -0
- package/dist/components/segmented.d.ts +7 -0
- package/dist/components/segmented.js +1 -0
- package/dist/components/select.d.ts +66 -0
- package/dist/components/select.js +90 -0
- package/dist/confirm/Confirmer.svelte +194 -0
- package/dist/confirm/Confirmer.svelte.d.ts +45 -0
- package/dist/confirm/challenge.d.ts +16 -0
- package/dist/confirm/challenge.js +16 -0
- package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
- package/dist/confirm/confirm-manager.svelte.js +149 -0
- package/dist/confirm/index.d.ts +3 -0
- package/dist/confirm/index.js +3 -0
- package/dist/error.d.ts +25 -0
- package/dist/error.js +39 -0
- package/dist/formatters.d.ts +95 -0
- package/dist/formatters.js +146 -0
- package/dist/http.d.ts +68 -0
- package/dist/http.js +138 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/routing.svelte.d.ts +169 -0
- package/dist/routing.svelte.js +208 -0
- package/dist/shell/AppShell.svelte +874 -0
- package/dist/shell/AppShell.svelte.d.ts +93 -0
- package/dist/shell/Breadcrumbs.svelte +78 -0
- package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
- package/dist/shell/crumbs.d.ts +23 -0
- package/dist/shell/crumbs.js +1 -0
- package/dist/shell/index.d.ts +3 -0
- package/dist/shell/index.js +6 -0
- package/dist/table/ColumnMenu.svelte +249 -0
- package/dist/table/ColumnMenu.svelte.d.ts +47 -0
- package/dist/table/DataTable.svelte +643 -0
- package/dist/table/DataTable.svelte.d.ts +153 -0
- package/dist/table/columns.d.ts +55 -0
- package/dist/table/columns.js +153 -0
- package/dist/table/index.d.ts +4 -0
- package/dist/table/index.js +8 -0
- package/dist/table/sort.d.ts +48 -0
- package/dist/table/sort.js +139 -0
- package/dist/table/types.d.ts +93 -0
- package/dist/table/types.js +1 -0
- package/dist/theme/ThemeController.svelte +54 -0
- package/dist/theme/ThemeController.svelte.d.ts +27 -0
- package/dist/theme/ThemeToggle.svelte +119 -0
- package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
- package/dist/theme/css-color.d.ts +23 -0
- package/dist/theme/css-color.js +31 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +5 -0
- package/dist/theme/theme.css +410 -0
- package/dist/theme/theme.svelte.d.ts +54 -0
- package/dist/theme/theme.svelte.js +58 -0
- package/dist/toast/Toaster.svelte +257 -0
- package/dist/toast/Toaster.svelte.d.ts +35 -0
- package/dist/toast/index.d.ts +2 -0
- package/dist/toast/index.js +2 -0
- package/dist/toast/toast-manager.svelte.d.ts +99 -0
- package/dist/toast/toast-manager.svelte.js +176 -0
- package/dist/user/ScopedComponent.svelte +47 -0
- package/dist/user/ScopedComponent.svelte.d.ts +29 -0
- package/dist/user/anonymous.d.ts +21 -0
- package/dist/user/anonymous.js +26 -0
- package/dist/user/context.d.ts +55 -0
- package/dist/user/context.js +13 -0
- package/dist/user/index.d.ts +3 -0
- package/dist/user/index.js +6 -0
- package/package.json +133 -4
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import {
|
|
5
|
+
anchorSelection,
|
|
6
|
+
buildRange,
|
|
7
|
+
createReAnchor,
|
|
8
|
+
CUSTOM_PRESET_ID,
|
|
9
|
+
DEFAULT_PRESETS,
|
|
10
|
+
isoToLocalInput,
|
|
11
|
+
isValidRange,
|
|
12
|
+
localInputToIso,
|
|
13
|
+
type DateRange,
|
|
14
|
+
type DateRangePreset,
|
|
15
|
+
} from "./date-range.js";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
class?: ClassValue;
|
|
19
|
+
/**
|
|
20
|
+
* The selected window, as UTC ISO instants. Bindable, and also reported through
|
|
21
|
+
* {@link Props.onchange} — a consumer needs one or the other, never both.
|
|
22
|
+
*
|
|
23
|
+
* It is an output, not an input: a relative preset overwrites it on every anchor. Passing one
|
|
24
|
+
* in only seeds the picker, and only alongside `preset="custom"`.
|
|
25
|
+
*/
|
|
26
|
+
value?: DateRange;
|
|
27
|
+
/** The buttons offered. `id` may not be `"custom"`, which the picker reserves. */
|
|
28
|
+
presets?: readonly DateRangePreset[];
|
|
29
|
+
/**
|
|
30
|
+
* Which button starts selected — a preset id, or `"custom"`. Defaults to the first preset, or
|
|
31
|
+
* to custom when `value` was seeded without naming a preset.
|
|
32
|
+
*/
|
|
33
|
+
preset?: string;
|
|
34
|
+
/**
|
|
35
|
+
* How often a relative range slides forward, in milliseconds. `0` turns re-anchoring off.
|
|
36
|
+
*
|
|
37
|
+
* A dashboard left open overnight should still be showing the last 24 hours in the morning,
|
|
38
|
+
* which is the whole reason this is on by default.
|
|
39
|
+
*/
|
|
40
|
+
reAnchorMs?: number;
|
|
41
|
+
/** Names the preset group for assistive technology. */
|
|
42
|
+
label?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Label of the button that switches to the typed pair. The id behind it stays
|
|
45
|
+
* {@link CUSTOM_PRESET_ID} whatever this says, so `preset="custom"` keeps working.
|
|
46
|
+
*/
|
|
47
|
+
customLabel?: string;
|
|
48
|
+
/** Label of the start field. */
|
|
49
|
+
fromLabel?: string;
|
|
50
|
+
/** Label of the end field. */
|
|
51
|
+
toLabel?: string;
|
|
52
|
+
/** Shown, as an alert, while the typed pair is out of order. */
|
|
53
|
+
invalidRangeLabel?: string;
|
|
54
|
+
onchange?: (value: DateRange) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let {
|
|
58
|
+
class: cls,
|
|
59
|
+
value = $bindable(),
|
|
60
|
+
presets = DEFAULT_PRESETS,
|
|
61
|
+
preset,
|
|
62
|
+
reAnchorMs = 60_000,
|
|
63
|
+
label = "Date range",
|
|
64
|
+
customLabel = "Custom",
|
|
65
|
+
fromLabel = "From",
|
|
66
|
+
toLabel = "To",
|
|
67
|
+
invalidRangeLabel = "The start must not be after the end.",
|
|
68
|
+
onchange,
|
|
69
|
+
}: Props = $props();
|
|
70
|
+
|
|
71
|
+
const uid = $props.id();
|
|
72
|
+
const fromId = `plinth-range-from-${uid}`;
|
|
73
|
+
const toId = `plinth-range-to-${uid}`;
|
|
74
|
+
const errorId = `plinth-range-error-${uid}`;
|
|
75
|
+
|
|
76
|
+
// The selection is the picker's own state rather than a bound prop: the pair is the entire
|
|
77
|
+
// output, and a second bound value would be a second thing for a consumer to keep in step.
|
|
78
|
+
let selection = $state(
|
|
79
|
+
untrack(() => preset ?? (value ? CUSTOM_PRESET_ID : (presets[0]?.id ?? CUSTOM_PRESET_ID))),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Wall-clock text for the two fields. Empty until custom mode is entered, because reading the
|
|
83
|
+
// local zone is a client-only measurement -- seeding these at init would make a prerendered
|
|
84
|
+
// page paint the build machine's offset and then correct itself on hydration.
|
|
85
|
+
let customFrom = $state("");
|
|
86
|
+
let customTo = $state("");
|
|
87
|
+
|
|
88
|
+
const isCustom = $derived(selection === CUSTOM_PRESET_ID);
|
|
89
|
+
|
|
90
|
+
const emit = (next: DateRange) => {
|
|
91
|
+
if (value?.from === next.from && value?.to === next.to) return;
|
|
92
|
+
|
|
93
|
+
value = next;
|
|
94
|
+
onchange?.(next);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const anchor = () => {
|
|
98
|
+
const next = anchorSelection(selection, presets, Date.now());
|
|
99
|
+
if (next) emit(next);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const seedCustomFields = (range: DateRange | undefined) => {
|
|
103
|
+
customFrom = isoToLocalInput(range?.from) ?? "";
|
|
104
|
+
customTo = isoToLocalInput(range?.to) ?? "";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const enterCustom = () => {
|
|
108
|
+
const current = value ?? (presets[0] ? buildRange(presets[0]) : undefined);
|
|
109
|
+
|
|
110
|
+
seedCustomFields(current);
|
|
111
|
+
selection = CUSTOM_PRESET_ID;
|
|
112
|
+
|
|
113
|
+
// The fields only hold whole minutes, so the range they now show is not quite the one that
|
|
114
|
+
// was emitted. Re-emitting the truncated pair is what keeps the value and the two fields the
|
|
115
|
+
// user is about to edit from disagreeing by up to a minute.
|
|
116
|
+
commitCustom();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const commitCustom = () => {
|
|
120
|
+
const from = localInputToIso(customFrom);
|
|
121
|
+
const to = localInputToIso(customTo);
|
|
122
|
+
|
|
123
|
+
if (from === null || to === null || !isValidRange(from, to)) return;
|
|
124
|
+
|
|
125
|
+
emit({ from, to });
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const rangeError = $derived.by(() => {
|
|
129
|
+
if (!isCustom) return null;
|
|
130
|
+
|
|
131
|
+
const from = localInputToIso(customFrom);
|
|
132
|
+
const to = localInputToIso(customTo);
|
|
133
|
+
|
|
134
|
+
// A half-typed field is not yet wrong, so it says nothing rather than flashing an error at
|
|
135
|
+
// someone who is still filling the form in.
|
|
136
|
+
if (from === null || to === null) return null;
|
|
137
|
+
|
|
138
|
+
return isValidRange(from, to) ? null : invalidRangeLabel;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
$effect(() => {
|
|
142
|
+
const intervalMs = reAnchorMs;
|
|
143
|
+
if (selection === CUSTOM_PRESET_ID) return;
|
|
144
|
+
|
|
145
|
+
// `presets` is read inside the anchor, untracked: a consumer who passes an array literal
|
|
146
|
+
// hands over a new identity on every render, and tracking it would turn each emit into
|
|
147
|
+
// another render into another anchor.
|
|
148
|
+
untrack(anchor);
|
|
149
|
+
if (intervalMs <= 0) return;
|
|
150
|
+
|
|
151
|
+
const clock = createReAnchor({ intervalMs, onAnchor: () => untrack(anchor) });
|
|
152
|
+
const onVisibilityChange = () => clock.setHidden(document.hidden);
|
|
153
|
+
|
|
154
|
+
clock.setHidden(document.hidden);
|
|
155
|
+
clock.start();
|
|
156
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
157
|
+
|
|
158
|
+
return () => {
|
|
159
|
+
clock.stop();
|
|
160
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
$effect(() => {
|
|
165
|
+
if (selection !== CUSTOM_PRESET_ID) return;
|
|
166
|
+
|
|
167
|
+
// Covers the one path into custom mode that is not a click: a picker mounted with
|
|
168
|
+
// `preset="custom"`. Seeding without emitting, because a value the consumer supplied is
|
|
169
|
+
// theirs to keep -- the click path is the one that re-emits.
|
|
170
|
+
untrack(() => {
|
|
171
|
+
if (!customFrom && !customTo) seedCustomFields(value);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
</script>
|
|
175
|
+
|
|
176
|
+
<!--
|
|
177
|
+
@component
|
|
178
|
+
A from/to picker built as a plain form control: relative presets, a custom pair of local
|
|
179
|
+
date-times, and `{ from, to }` as UTC ISO strings on the way out. It fetches nothing, knows no
|
|
180
|
+
query parameter, and reads no URL — what the pair means is the consumer's business.
|
|
181
|
+
|
|
182
|
+
A relative preset re-anchors on a timer (`reAnchorMs`, 60s by default, `0` to disable), so a
|
|
183
|
+
dashboard left open keeps sliding forward. The timer suspends while the tab is hidden and fires
|
|
184
|
+
once on return. A custom range is never touched by it.
|
|
185
|
+
|
|
186
|
+
The two custom fields are `<input type="datetime-local">`, which means they are wall-clock time in
|
|
187
|
+
the viewer's own zone with no offset attached, while the emitted pair stays in UTC. The conversion
|
|
188
|
+
runs to whole minutes in both directions; see `isoToLocalInput` / `localInputToIso`.
|
|
189
|
+
|
|
190
|
+
Every word it renders is a prop with an English default — `label`, `customLabel`, `fromLabel`,
|
|
191
|
+
`toLabel` and `invalidRangeLabel` — because the library ships no translations. Only the wording
|
|
192
|
+
moves: the custom button keeps the reserved `"custom"` id whatever `customLabel` says. The preset
|
|
193
|
+
buttons are labelled by the list itself, through `DateRangePreset.label`.
|
|
194
|
+
|
|
195
|
+
```svelte
|
|
196
|
+
<DateRangePicker bind:value={range} preset="24h" onchange={(r) => load(r)} />
|
|
197
|
+
```
|
|
198
|
+
-->
|
|
199
|
+
|
|
200
|
+
<div class={["flex flex-wrap items-end gap-x-4 gap-y-3", cls]}>
|
|
201
|
+
<div class="join" role="group" aria-label={label}>
|
|
202
|
+
{#each presets as option (option.id)}
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
class="btn join-item min-h-11"
|
|
206
|
+
class:btn-primary={selection === option.id}
|
|
207
|
+
aria-pressed={selection === option.id}
|
|
208
|
+
onclick={() => (selection = option.id)}
|
|
209
|
+
>
|
|
210
|
+
{option.label}
|
|
211
|
+
</button>
|
|
212
|
+
{/each}
|
|
213
|
+
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
class="btn join-item min-h-11"
|
|
217
|
+
class:btn-primary={isCustom}
|
|
218
|
+
aria-pressed={isCustom}
|
|
219
|
+
onclick={enterCustom}
|
|
220
|
+
>
|
|
221
|
+
{customLabel}
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{#if isCustom}
|
|
226
|
+
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
|
|
227
|
+
<div class="flex flex-col gap-1">
|
|
228
|
+
<label class="label text-xs" for={fromId}>{fromLabel}</label>
|
|
229
|
+
<input
|
|
230
|
+
id={fromId}
|
|
231
|
+
class="input min-h-11"
|
|
232
|
+
class:input-error={rangeError !== null}
|
|
233
|
+
type="datetime-local"
|
|
234
|
+
value={customFrom}
|
|
235
|
+
aria-invalid={rangeError !== null}
|
|
236
|
+
aria-describedby={rangeError === null ? undefined : errorId}
|
|
237
|
+
oninput={({ currentTarget }) => {
|
|
238
|
+
customFrom = currentTarget.value;
|
|
239
|
+
commitCustom();
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div class="flex flex-col gap-1">
|
|
245
|
+
<label class="label text-xs" for={toId}>{toLabel}</label>
|
|
246
|
+
<input
|
|
247
|
+
id={toId}
|
|
248
|
+
class="input min-h-11"
|
|
249
|
+
class:input-error={rangeError !== null}
|
|
250
|
+
type="datetime-local"
|
|
251
|
+
value={customTo}
|
|
252
|
+
aria-invalid={rangeError !== null}
|
|
253
|
+
aria-describedby={rangeError === null ? undefined : errorId}
|
|
254
|
+
oninput={({ currentTarget }) => {
|
|
255
|
+
customTo = currentTarget.value;
|
|
256
|
+
commitCustom();
|
|
257
|
+
}}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
{/if}
|
|
262
|
+
|
|
263
|
+
{#if rangeError !== null}
|
|
264
|
+
<p id={errorId} class="basis-full text-sm text-error" role="alert">{rangeError}</p>
|
|
265
|
+
{/if}
|
|
266
|
+
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ClassValue } from "svelte/elements";
|
|
2
|
+
import { type DateRange, type DateRangePreset } from "./date-range.js";
|
|
3
|
+
interface Props {
|
|
4
|
+
class?: ClassValue;
|
|
5
|
+
/**
|
|
6
|
+
* The selected window, as UTC ISO instants. Bindable, and also reported through
|
|
7
|
+
* {@link Props.onchange} — a consumer needs one or the other, never both.
|
|
8
|
+
*
|
|
9
|
+
* It is an output, not an input: a relative preset overwrites it on every anchor. Passing one
|
|
10
|
+
* in only seeds the picker, and only alongside `preset="custom"`.
|
|
11
|
+
*/
|
|
12
|
+
value?: DateRange;
|
|
13
|
+
/** The buttons offered. `id` may not be `"custom"`, which the picker reserves. */
|
|
14
|
+
presets?: readonly DateRangePreset[];
|
|
15
|
+
/**
|
|
16
|
+
* Which button starts selected — a preset id, or `"custom"`. Defaults to the first preset, or
|
|
17
|
+
* to custom when `value` was seeded without naming a preset.
|
|
18
|
+
*/
|
|
19
|
+
preset?: string;
|
|
20
|
+
/**
|
|
21
|
+
* How often a relative range slides forward, in milliseconds. `0` turns re-anchoring off.
|
|
22
|
+
*
|
|
23
|
+
* A dashboard left open overnight should still be showing the last 24 hours in the morning,
|
|
24
|
+
* which is the whole reason this is on by default.
|
|
25
|
+
*/
|
|
26
|
+
reAnchorMs?: number;
|
|
27
|
+
/** Names the preset group for assistive technology. */
|
|
28
|
+
label?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Label of the button that switches to the typed pair. The id behind it stays
|
|
31
|
+
* {@link CUSTOM_PRESET_ID} whatever this says, so `preset="custom"` keeps working.
|
|
32
|
+
*/
|
|
33
|
+
customLabel?: string;
|
|
34
|
+
/** Label of the start field. */
|
|
35
|
+
fromLabel?: string;
|
|
36
|
+
/** Label of the end field. */
|
|
37
|
+
toLabel?: string;
|
|
38
|
+
/** Shown, as an alert, while the typed pair is out of order. */
|
|
39
|
+
invalidRangeLabel?: string;
|
|
40
|
+
onchange?: (value: DateRange) => void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A from/to picker built as a plain form control: relative presets, a custom pair of local
|
|
44
|
+
* date-times, and `{ from, to }` as UTC ISO strings on the way out. It fetches nothing, knows no
|
|
45
|
+
* query parameter, and reads no URL — what the pair means is the consumer's business.
|
|
46
|
+
*
|
|
47
|
+
* A relative preset re-anchors on a timer (`reAnchorMs`, 60s by default, `0` to disable), so a
|
|
48
|
+
* dashboard left open keeps sliding forward. The timer suspends while the tab is hidden and fires
|
|
49
|
+
* once on return. A custom range is never touched by it.
|
|
50
|
+
*
|
|
51
|
+
* The two custom fields are `<input type="datetime-local">`, which means they are wall-clock time in
|
|
52
|
+
* the viewer's own zone with no offset attached, while the emitted pair stays in UTC. The conversion
|
|
53
|
+
* runs to whole minutes in both directions; see `isoToLocalInput` / `localInputToIso`.
|
|
54
|
+
*
|
|
55
|
+
* Every word it renders is a prop with an English default — `label`, `customLabel`, `fromLabel`,
|
|
56
|
+
* `toLabel` and `invalidRangeLabel` — because the library ships no translations. Only the wording
|
|
57
|
+
* moves: the custom button keeps the reserved `"custom"` id whatever `customLabel` says. The preset
|
|
58
|
+
* buttons are labelled by the list itself, through `DateRangePreset.label`.
|
|
59
|
+
*
|
|
60
|
+
* ```svelte
|
|
61
|
+
* <DateRangePicker bind:value={range} preset="24h" onchange={(r) => load(r)} />
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
declare const DateRangePicker: import("svelte").Component<Props, {}, "value">;
|
|
65
|
+
type DateRangePicker = ReturnType<typeof DateRangePicker>;
|
|
66
|
+
export default DateRangePicker;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { flushSync, type Snippet } from "svelte";
|
|
3
|
+
import type { Attachment } from "svelte/attachments";
|
|
4
|
+
import type { HTMLDialogAttributes } from "svelte/elements";
|
|
5
|
+
|
|
6
|
+
interface Props extends HTMLDialogAttributes {
|
|
7
|
+
/** Rendered only while the dialog is on screen. */
|
|
8
|
+
children: Snippet;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { children, ...rest }: Props = $props();
|
|
12
|
+
|
|
13
|
+
let mounted = $state(false);
|
|
14
|
+
let element = $state<HTMLDialogElement>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mount the content and raise the dialog into the top layer as a modal.
|
|
18
|
+
*
|
|
19
|
+
* Safe to call on an already open dialog, which `showModal()` itself is not.
|
|
20
|
+
*/
|
|
21
|
+
export function show() {
|
|
22
|
+
mounted = true;
|
|
23
|
+
// The element does not exist until the block below has been applied to the DOM. Flushing here
|
|
24
|
+
// is what keeps `show()` a plain synchronous call: without it the caller would have to await a
|
|
25
|
+
// tick before the dialog it just asked for exists.
|
|
26
|
+
flushSync();
|
|
27
|
+
if (element && !element.open) element.showModal();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Close the dialog. The content stays mounted until the element has finished disappearing. */
|
|
31
|
+
export function close(returnValue?: string) {
|
|
32
|
+
element?.close(returnValue);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const unmountWhenHidden: Attachment<HTMLDialogElement> = (dialog) => {
|
|
36
|
+
const listener = (event: Event) => {
|
|
37
|
+
// Opening transitions too, and the `toggle` that announces the open state arrives while the
|
|
38
|
+
// element is still being raised. Only an end state that is actually invisible may unmount.
|
|
39
|
+
if (event instanceof ToggleEvent && event.newState === "open") return;
|
|
40
|
+
|
|
41
|
+
// Being closed and being gone are not the same thing. A stylesheet that keeps the element
|
|
42
|
+
// displayed while fading it out -- daisyUI's `.modal` is `display: grid` at all times and
|
|
43
|
+
// animates `visibility` -- would otherwise have the content ripped out mid-animation, so the
|
|
44
|
+
// question asked here is "has it finished disappearing", not "is the open attribute gone".
|
|
45
|
+
if (mounted && !dialog.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
|
|
46
|
+
mounted = false;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// `toggle` covers an instant hide (no transition, or reduced motion); the two transition events
|
|
50
|
+
// cover a fade, including one cut short by a reopen.
|
|
51
|
+
dialog.addEventListener("toggle", listener);
|
|
52
|
+
dialog.addEventListener("transitionend", listener);
|
|
53
|
+
dialog.addEventListener("transitioncancel", listener);
|
|
54
|
+
|
|
55
|
+
return () => {
|
|
56
|
+
dialog.removeEventListener("toggle", listener);
|
|
57
|
+
dialog.removeEventListener("transitionend", listener);
|
|
58
|
+
dialog.removeEventListener("transitioncancel", listener);
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The element's own `open` attribute is the other way in, and it shows the dialog NON-modally:
|
|
63
|
+
// no top layer, no inert background, no Escape. Seeding the mount from it keeps that path from
|
|
64
|
+
// rendering an empty shell.
|
|
65
|
+
$effect(() => {
|
|
66
|
+
if (rest.open) mounted = true;
|
|
67
|
+
});
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<!--
|
|
71
|
+
@component
|
|
72
|
+
A native `<dialog>` that only exists while it is on screen.
|
|
73
|
+
|
|
74
|
+
The point of the wrapper is what it does NOT do. Modality, the focus trap, Escape, returning focus
|
|
75
|
+
to whatever opened it and stacking above the rest of the page without a z-index fight are all the
|
|
76
|
+
browser's, delivered by `showModal()` and correct in a way a hand-rolled overlay rarely is. This
|
|
77
|
+
component adds one thing on top: the content is mounted on `show()` and unmounted once the element
|
|
78
|
+
has finished disappearing, so a page can declare a dozen dialogs -- each with its own form, fetch
|
|
79
|
+
or chart inside -- and pay for none of them until one is opened.
|
|
80
|
+
|
|
81
|
+
There is no chrome here: no title, no close button, no padding. Use `Modal` for that, or style the
|
|
82
|
+
element yourself through `class` and any other `<dialog>` attribute, which are forwarded.
|
|
83
|
+
|
|
84
|
+
```svelte
|
|
85
|
+
<script lang="ts">
|
|
86
|
+
let confirmation = $state<Dialog>();
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<button onclick={() => confirmation?.show()}>Delete</button>
|
|
90
|
+
|
|
91
|
+
<Dialog bind:this={confirmation} class="rounded-box bg-base-100 p-6">
|
|
92
|
+
<p>This cannot be undone.</p>
|
|
93
|
+
<button onclick={() => confirmation?.close()}>Cancel</button>
|
|
94
|
+
</Dialog>
|
|
95
|
+
```
|
|
96
|
+
-->
|
|
97
|
+
|
|
98
|
+
{#if mounted}
|
|
99
|
+
<dialog bind:this={element} {@attach unmountWhenHidden} {...rest}>
|
|
100
|
+
{@render children()}
|
|
101
|
+
</dialog>
|
|
102
|
+
{/if}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Snippet } from "svelte";
|
|
2
|
+
import type { HTMLDialogAttributes } from "svelte/elements";
|
|
3
|
+
interface Props extends HTMLDialogAttributes {
|
|
4
|
+
/** Rendered only while the dialog is on screen. */
|
|
5
|
+
children: Snippet;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A native `<dialog>` that only exists while it is on screen.
|
|
9
|
+
*
|
|
10
|
+
* The point of the wrapper is what it does NOT do. Modality, the focus trap, Escape, returning focus
|
|
11
|
+
* to whatever opened it and stacking above the rest of the page without a z-index fight are all the
|
|
12
|
+
* browser's, delivered by `showModal()` and correct in a way a hand-rolled overlay rarely is. This
|
|
13
|
+
* component adds one thing on top: the content is mounted on `show()` and unmounted once the element
|
|
14
|
+
* has finished disappearing, so a page can declare a dozen dialogs -- each with its own form, fetch
|
|
15
|
+
* or chart inside -- and pay for none of them until one is opened.
|
|
16
|
+
*
|
|
17
|
+
* There is no chrome here: no title, no close button, no padding. Use `Modal` for that, or style the
|
|
18
|
+
* element yourself through `class` and any other `<dialog>` attribute, which are forwarded.
|
|
19
|
+
*
|
|
20
|
+
* ```svelte
|
|
21
|
+
* <script lang="ts">
|
|
22
|
+
* let confirmation = $state<Dialog>();
|
|
23
|
+
* </script>
|
|
24
|
+
*
|
|
25
|
+
* <button onclick={() => confirmation?.show()}>Delete</button>
|
|
26
|
+
*
|
|
27
|
+
* <Dialog bind:this={confirmation} class="rounded-box bg-base-100 p-6">
|
|
28
|
+
* <p>This cannot be undone.</p>
|
|
29
|
+
* <button onclick={() => confirmation?.close()}>Cancel</button>
|
|
30
|
+
* </Dialog>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
declare const Dialog: import("svelte").Component<Props, {
|
|
34
|
+
show: () => void;
|
|
35
|
+
close: (returnValue?: string) => void;
|
|
36
|
+
}, "">;
|
|
37
|
+
type Dialog = ReturnType<typeof Dialog>;
|
|
38
|
+
export default Dialog;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { flushSync, onDestroy, type Snippet } from "svelte";
|
|
3
|
+
import type { ClassValue, HTMLButtonAttributes } from "svelte/elements";
|
|
4
|
+
import { positionUnder, supportsAnchorPositioning } from "../anchoring.js";
|
|
5
|
+
|
|
6
|
+
type Props = HTMLButtonAttributes & {
|
|
7
|
+
children: Snippet;
|
|
8
|
+
content: Snippet;
|
|
9
|
+
/** Classes for the panel — the only part of this component the trigger's `class` cannot
|
|
10
|
+
reach, since the trigger is the root. */
|
|
11
|
+
panelClass?: ClassValue | null;
|
|
12
|
+
/** Whether the panel is open, however it got there. Bind to read it; the popover decides it. */
|
|
13
|
+
expanded?: boolean;
|
|
14
|
+
/** Hangs the panel just under the trigger at the trigger's width, as a field's list does. */
|
|
15
|
+
matchWidth?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
children,
|
|
20
|
+
content,
|
|
21
|
+
panelClass,
|
|
22
|
+
expanded = $bindable(false),
|
|
23
|
+
matchWidth = false,
|
|
24
|
+
type,
|
|
25
|
+
...rest
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
// `popovertarget` needs a document-unique id, and `$props.id()` is the only source of one that
|
|
29
|
+
// is stable across server render and hydration.
|
|
30
|
+
const uid = $props.id();
|
|
31
|
+
const panelId = `plinth-dropdown-${uid}`;
|
|
32
|
+
const anchorName = `--${panelId}`;
|
|
33
|
+
|
|
34
|
+
const FIELD_GAP = 4;
|
|
35
|
+
|
|
36
|
+
let isMounted = $state(false);
|
|
37
|
+
let trigger = $state<HTMLButtonElement>();
|
|
38
|
+
let panel = $state<HTMLElement>();
|
|
39
|
+
|
|
40
|
+
let stopPositioning: (() => void) | undefined;
|
|
41
|
+
|
|
42
|
+
/* Without anchor positioning the placement rules at the bottom of this file are dropped and
|
|
43
|
+
the UA popover ones -- `position: fixed; inset: 0; margin: auto` -- are all that is left,
|
|
44
|
+
which floats the panel in the middle of the screen with no relation to the trigger. Script
|
|
45
|
+
places it instead, to the geometry `position-area: bottom` gives: centred on the trigger,
|
|
46
|
+
right under it, and above it when there is no room below. */
|
|
47
|
+
const reposition = () => {
|
|
48
|
+
stopPositioning?.();
|
|
49
|
+
stopPositioning = undefined;
|
|
50
|
+
if (!panel || !trigger || supportsAnchorPositioning()) return;
|
|
51
|
+
|
|
52
|
+
// The edge margin the stylesheet gives the panel, read before the script takes the margins
|
|
53
|
+
// over, so both paths keep the same distance from the side of the screen.
|
|
54
|
+
const edge = parseFloat(getComputedStyle(panel).marginLeft) || 0;
|
|
55
|
+
stopPositioning = positionUnder(
|
|
56
|
+
panel,
|
|
57
|
+
trigger,
|
|
58
|
+
matchWidth ? { gap: FIELD_GAP, matchWidth } : { inset: edge },
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const releasePositioning = () => {
|
|
63
|
+
stopPositioning?.();
|
|
64
|
+
stopPositioning = undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// A panel left open when the dropdown is destroyed would otherwise leave the reposition
|
|
68
|
+
// listeners on the window, holding a detached element.
|
|
69
|
+
onDestroy(releasePositioning);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Opens the panel, or, when it is already open, moves focus back to its `autofocus` element, so a
|
|
73
|
+
* caller can send the keyboard back into the panel after Shift+Tab took it out.
|
|
74
|
+
*/
|
|
75
|
+
export function open() {
|
|
76
|
+
if (!panel) return;
|
|
77
|
+
if (panel.matches(":popover-open")) {
|
|
78
|
+
panel.querySelector<HTMLElement>("[autofocus]")?.focus();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
panel.showPopover();
|
|
82
|
+
reposition();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Closes the panel — for dismissing it once an action inside `content` has finished. */
|
|
86
|
+
export function close() {
|
|
87
|
+
panel?.hidePopover();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* A hidden popover stops rendering entirely, which freezes any exit transition mid-flight and
|
|
91
|
+
means its `transitionend` never arrives. Asking the element whether it is actually visible is
|
|
92
|
+
the only reading that holds for both an animated and an instant close. */
|
|
93
|
+
const unmountWhenHidden = () => {
|
|
94
|
+
if (!panel || panel.matches(":popover-open")) return;
|
|
95
|
+
if (!panel.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
|
|
96
|
+
isMounted = false;
|
|
97
|
+
};
|
|
98
|
+
/* See OptionPanel: a descendant's transition must not be mistaken for the panel's exit. */
|
|
99
|
+
const onTransitionSettled = (event: TransitionEvent) => {
|
|
100
|
+
if (event.target === event.currentTarget) unmountWhenHidden();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const onBeforeToggle = (event: ToggleEvent) => {
|
|
104
|
+
if (event.newState === "open") flushSync(() => (isMounted = true));
|
|
105
|
+
else if (panel?.contains(document.activeElement)) trigger?.focus({ preventScroll: true });
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onToggle = (event: ToggleEvent) => {
|
|
109
|
+
expanded = event.newState === "open";
|
|
110
|
+
if (expanded) {
|
|
111
|
+
reposition();
|
|
112
|
+
} else {
|
|
113
|
+
releasePositioning();
|
|
114
|
+
unmountWhenHidden();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<!--
|
|
120
|
+
@component
|
|
121
|
+
A button that toggles a panel through the native popover API, so clicking the trigger again,
|
|
122
|
+
clicking outside, and pressing Escape all dismiss it without a single listener of our own. The
|
|
123
|
+
button is the root: class, handlers and aria go straight onto it, and `panelClass` styles the
|
|
124
|
+
panel.
|
|
125
|
+
|
|
126
|
+
The popover itself — not a local flag — is the source of truth for open/closed; `aria-expanded`
|
|
127
|
+
and the bindable `expanded` are read back from its `toggle` event. Content mounts just before the
|
|
128
|
+
panel shows, however it was opened, and unmounts once the panel is really hidden, so an expensive
|
|
129
|
+
menu costs nothing until it is used. An element inside it marked `autofocus` takes the focus as the
|
|
130
|
+
panel shows, and closing the panel with the focus inside it hands the focus back to the trigger.
|
|
131
|
+
|
|
132
|
+
`open()` and `close()` are exposed for `bind:this`, which is what a form inside the panel needs to
|
|
133
|
+
dismiss itself after submitting. `open()` on a panel that is already open moves the focus back to
|
|
134
|
+
its `autofocus` element.
|
|
135
|
+
|
|
136
|
+
A panel that would run off the side of the screen is pulled back onto it and keeps half a rem from
|
|
137
|
+
the edge, in both the anchored and the scripted placement; `--dropdown-edge` changes the distance.
|
|
138
|
+
`matchWidth` hangs it instead just under the trigger and exactly as wide, for a trigger that is a
|
|
139
|
+
field, the way the `Select` panel hangs under its control.
|
|
140
|
+
|
|
141
|
+
```svelte
|
|
142
|
+
<Dropdown bind:this={menu} class="btn" panelClass="dropdown menu w-56 rounded-box bg-base-200 p-2">
|
|
143
|
+
Actions
|
|
144
|
+
{#snippet content()}
|
|
145
|
+
<li><button onclick={() => menu.close()}>Archive</button></li>
|
|
146
|
+
{/snippet}
|
|
147
|
+
</Dropdown>
|
|
148
|
+
```
|
|
149
|
+
-->
|
|
150
|
+
|
|
151
|
+
<button
|
|
152
|
+
{...rest}
|
|
153
|
+
bind:this={trigger}
|
|
154
|
+
type={type ?? "button"}
|
|
155
|
+
popovertarget={panelId}
|
|
156
|
+
aria-expanded={expanded}
|
|
157
|
+
style:anchor-name={anchorName}
|
|
158
|
+
>
|
|
159
|
+
{@render children()}
|
|
160
|
+
</button>
|
|
161
|
+
<div
|
|
162
|
+
bind:this={panel}
|
|
163
|
+
class={[panelClass, matchWidth && "match-width"]}
|
|
164
|
+
popover
|
|
165
|
+
id={panelId}
|
|
166
|
+
style:position-anchor={anchorName}
|
|
167
|
+
onbeforetoggle={onBeforeToggle}
|
|
168
|
+
ontoggle={onToggle}
|
|
169
|
+
ontransitionend={onTransitionSettled}
|
|
170
|
+
ontransitioncancel={onTransitionSettled}
|
|
171
|
+
>
|
|
172
|
+
{#if isMounted}
|
|
173
|
+
{@render content()}
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<style>
|
|
178
|
+
[popover] {
|
|
179
|
+
position-area: bottom;
|
|
180
|
+
position-try: top, left, right;
|
|
181
|
+
/* A panel wider than the room left beside its trigger is shifted back onto the screen, and the
|
|
182
|
+
shift stops at the margin box: with no inline margin a menu opened from a trigger at the edge
|
|
183
|
+
of a phone lands flush against the glass. The custom property is the way to change it, since
|
|
184
|
+
this rule outranks a utility class passed through `panelClass`. */
|
|
185
|
+
margin-inline: var(--dropdown-edge, 0.5rem);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
[popover].match-width {
|
|
189
|
+
position-try: top;
|
|
190
|
+
box-sizing: border-box;
|
|
191
|
+
width: anchor-size(width);
|
|
192
|
+
margin-inline: 0;
|
|
193
|
+
margin-block: 0.25rem;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* A closed popover is hidden by a rule in the UA stylesheet, which every author rule outranks --
|
|
197
|
+
including the `display: flex` that daisyUI's `menu` and friends carry. Passing one of those
|
|
198
|
+
through `panelClass` would otherwise pin the panel open with no error anywhere. Restating the
|
|
199
|
+
rule here puts it at a specificity a utility class cannot reach, while leaving an exit
|
|
200
|
+
transition possible for a caller who asks for one with `transition-behavior: allow-discrete`. */
|
|
201
|
+
[popover]:not(:popover-open) {
|
|
202
|
+
display: none;
|
|
203
|
+
}
|
|
204
|
+
</style>
|