@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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +361 -2
  3. package/dist/anchoring.d.ts +61 -0
  4. package/dist/anchoring.js +151 -0
  5. package/dist/async-action.svelte.d.ts +37 -0
  6. package/dist/async-action.svelte.js +64 -0
  7. package/dist/attachments/index.d.ts +1 -0
  8. package/dist/attachments/index.js +1 -0
  9. package/dist/attachments/tooltip.d.ts +37 -0
  10. package/dist/attachments/tooltip.js +166 -0
  11. package/dist/components/AsyncButton.svelte +49 -0
  12. package/dist/components/AsyncButton.svelte.d.ts +32 -0
  13. package/dist/components/Combobox.svelte +380 -0
  14. package/dist/components/Combobox.svelte.d.ts +74 -0
  15. package/dist/components/Copyable.svelte +116 -0
  16. package/dist/components/Copyable.svelte.d.ts +37 -0
  17. package/dist/components/DateRangePicker.svelte +266 -0
  18. package/dist/components/DateRangePicker.svelte.d.ts +66 -0
  19. package/dist/components/Dialog.svelte +102 -0
  20. package/dist/components/Dialog.svelte.d.ts +38 -0
  21. package/dist/components/Dropdown.svelte +204 -0
  22. package/dist/components/Dropdown.svelte.d.ts +49 -0
  23. package/dist/components/ErrorDisplay.svelte +59 -0
  24. package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
  25. package/dist/components/ErrorPage.svelte +119 -0
  26. package/dist/components/ErrorPage.svelte.d.ts +52 -0
  27. package/dist/components/JsonInput.svelte +106 -0
  28. package/dist/components/JsonInput.svelte.d.ts +40 -0
  29. package/dist/components/Listbox.svelte +257 -0
  30. package/dist/components/Listbox.svelte.d.ts +78 -0
  31. package/dist/components/LoadingButton.svelte +36 -0
  32. package/dist/components/LoadingButton.svelte.d.ts +22 -0
  33. package/dist/components/Logo.svelte +112 -0
  34. package/dist/components/Logo.svelte.d.ts +29 -0
  35. package/dist/components/Modal.svelte +187 -0
  36. package/dist/components/Modal.svelte.d.ts +62 -0
  37. package/dist/components/MultiSelect.svelte +256 -0
  38. package/dist/components/MultiSelect.svelte.d.ts +89 -0
  39. package/dist/components/OptionPanel.svelte +241 -0
  40. package/dist/components/OptionPanel.svelte.d.ts +51 -0
  41. package/dist/components/Paginator.svelte +205 -0
  42. package/dist/components/Paginator.svelte.d.ts +48 -0
  43. package/dist/components/RefreshButton.svelte +246 -0
  44. package/dist/components/RefreshButton.svelte.d.ts +46 -0
  45. package/dist/components/SegmentedControl.svelte +147 -0
  46. package/dist/components/SegmentedControl.svelte.d.ts +71 -0
  47. package/dist/components/Select.svelte +312 -0
  48. package/dist/components/Select.svelte.d.ts +68 -0
  49. package/dist/components/Skeleton.svelte +97 -0
  50. package/dist/components/Skeleton.svelte.d.ts +32 -0
  51. package/dist/components/Tooltip.svelte +60 -0
  52. package/dist/components/Tooltip.svelte.d.ts +58 -0
  53. package/dist/components/date-range.d.ts +101 -0
  54. package/dist/components/date-range.js +144 -0
  55. package/dist/components/index.d.ts +25 -0
  56. package/dist/components/index.js +37 -0
  57. package/dist/components/json-text.d.ts +22 -0
  58. package/dist/components/json-text.js +22 -0
  59. package/dist/components/pick.d.ts +18 -0
  60. package/dist/components/pick.js +18 -0
  61. package/dist/components/segmented.d.ts +7 -0
  62. package/dist/components/segmented.js +1 -0
  63. package/dist/components/select.d.ts +66 -0
  64. package/dist/components/select.js +90 -0
  65. package/dist/confirm/Confirmer.svelte +194 -0
  66. package/dist/confirm/Confirmer.svelte.d.ts +45 -0
  67. package/dist/confirm/challenge.d.ts +16 -0
  68. package/dist/confirm/challenge.js +16 -0
  69. package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
  70. package/dist/confirm/confirm-manager.svelte.js +149 -0
  71. package/dist/confirm/index.d.ts +3 -0
  72. package/dist/confirm/index.js +3 -0
  73. package/dist/error.d.ts +25 -0
  74. package/dist/error.js +39 -0
  75. package/dist/formatters.d.ts +95 -0
  76. package/dist/formatters.js +146 -0
  77. package/dist/http.d.ts +68 -0
  78. package/dist/http.js +138 -0
  79. package/dist/index.d.ts +10 -0
  80. package/dist/index.js +14 -0
  81. package/dist/routing.svelte.d.ts +169 -0
  82. package/dist/routing.svelte.js +208 -0
  83. package/dist/shell/AppShell.svelte +874 -0
  84. package/dist/shell/AppShell.svelte.d.ts +93 -0
  85. package/dist/shell/Breadcrumbs.svelte +78 -0
  86. package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
  87. package/dist/shell/crumbs.d.ts +23 -0
  88. package/dist/shell/crumbs.js +1 -0
  89. package/dist/shell/index.d.ts +3 -0
  90. package/dist/shell/index.js +6 -0
  91. package/dist/table/ColumnMenu.svelte +249 -0
  92. package/dist/table/ColumnMenu.svelte.d.ts +47 -0
  93. package/dist/table/DataTable.svelte +643 -0
  94. package/dist/table/DataTable.svelte.d.ts +153 -0
  95. package/dist/table/columns.d.ts +55 -0
  96. package/dist/table/columns.js +153 -0
  97. package/dist/table/index.d.ts +4 -0
  98. package/dist/table/index.js +8 -0
  99. package/dist/table/sort.d.ts +48 -0
  100. package/dist/table/sort.js +139 -0
  101. package/dist/table/types.d.ts +93 -0
  102. package/dist/table/types.js +1 -0
  103. package/dist/theme/ThemeController.svelte +54 -0
  104. package/dist/theme/ThemeController.svelte.d.ts +27 -0
  105. package/dist/theme/ThemeToggle.svelte +119 -0
  106. package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
  107. package/dist/theme/css-color.d.ts +23 -0
  108. package/dist/theme/css-color.js +31 -0
  109. package/dist/theme/index.d.ts +4 -0
  110. package/dist/theme/index.js +5 -0
  111. package/dist/theme/theme.css +410 -0
  112. package/dist/theme/theme.svelte.d.ts +54 -0
  113. package/dist/theme/theme.svelte.js +58 -0
  114. package/dist/toast/Toaster.svelte +257 -0
  115. package/dist/toast/Toaster.svelte.d.ts +35 -0
  116. package/dist/toast/index.d.ts +2 -0
  117. package/dist/toast/index.js +2 -0
  118. package/dist/toast/toast-manager.svelte.d.ts +99 -0
  119. package/dist/toast/toast-manager.svelte.js +176 -0
  120. package/dist/user/ScopedComponent.svelte +47 -0
  121. package/dist/user/ScopedComponent.svelte.d.ts +29 -0
  122. package/dist/user/anonymous.d.ts +21 -0
  123. package/dist/user/anonymous.js +26 -0
  124. package/dist/user/context.d.ts +55 -0
  125. package/dist/user/context.js +13 -0
  126. package/dist/user/index.d.ts +3 -0
  127. package/dist/user/index.js +6 -0
  128. 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>