@viniaraujo68/plinth 0.0.0-stage → 0.3.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 +2 -0
- package/dist/attachments/index.js +2 -0
- package/dist/attachments/sortable.d.ts +6 -0
- package/dist/attachments/sortable.js +227 -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/DragHandle.svelte +26 -0
- package/dist/components/DragHandle.svelte.d.ts +7 -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 +26 -0
- package/dist/components/index.js +38 -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,257 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fly, scale } from "svelte/transition";
|
|
3
|
+
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import {
|
|
5
|
+
getDefaultToastManager,
|
|
6
|
+
type ToastManager,
|
|
7
|
+
type ToastPosition,
|
|
8
|
+
type ToastVariant,
|
|
9
|
+
} from "./toast-manager.svelte.js";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
class?: ClassValue;
|
|
13
|
+
/** Queue to render. Defaults to the one behind the module-level `toast` helper. */
|
|
14
|
+
manager?: ToastManager;
|
|
15
|
+
position?: ToastPosition;
|
|
16
|
+
/** Stack newest first. The default puts the newest at the bottom of the stack. */
|
|
17
|
+
reverseOrder?: boolean;
|
|
18
|
+
/** Accessible name of the live region, for apps that are not in English. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Accessible name of every toast's dismiss button. */
|
|
21
|
+
dismissLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
class: cls,
|
|
26
|
+
manager,
|
|
27
|
+
position = "bottom-end",
|
|
28
|
+
reverseOrder = false,
|
|
29
|
+
label = "Notifications",
|
|
30
|
+
dismissLabel = "Dismiss notification",
|
|
31
|
+
}: Props = $props();
|
|
32
|
+
|
|
33
|
+
const resolvedManager = $derived(manager ?? getDefaultToastManager());
|
|
34
|
+
const toasts = $derived(
|
|
35
|
+
reverseOrder ? resolvedManager.toasts.toReversed() : resolvedManager.toasts,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const POSITION_CLASSES: Record<ToastPosition, string> = {
|
|
39
|
+
"top-start": "toast-top toast-start",
|
|
40
|
+
"top-center": "toast-top toast-center",
|
|
41
|
+
"top-end": "toast-top toast-end",
|
|
42
|
+
"middle-start": "toast-middle toast-start",
|
|
43
|
+
"middle-center": "toast-middle toast-center",
|
|
44
|
+
"middle-end": "toast-middle toast-end",
|
|
45
|
+
"bottom-start": "toast-bottom toast-start",
|
|
46
|
+
"bottom-center": "toast-bottom toast-center",
|
|
47
|
+
"bottom-end": "toast-bottom toast-end",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
|
51
|
+
info: "alert-info",
|
|
52
|
+
success: "alert-success",
|
|
53
|
+
warning: "alert-warning",
|
|
54
|
+
error: "alert-error",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Inline paths rather than an icon font or an iconify plugin: the library must render correctly
|
|
58
|
+
// in a consumer that installed nothing but the package. Variant is never signalled by color
|
|
59
|
+
// alone, which is what makes the four alerts distinguishable to a colorblind reader.
|
|
60
|
+
const VARIANT_ICONS: Record<ToastVariant, string[]> = {
|
|
61
|
+
info: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18", "M12 16v-5", "M12 8h.01"],
|
|
62
|
+
success: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18", "m8.5 12.4 2.4 2.4 4.6-5.2"],
|
|
63
|
+
warning: [
|
|
64
|
+
"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0",
|
|
65
|
+
"M12 9v4",
|
|
66
|
+
"M12 17h.01",
|
|
67
|
+
],
|
|
68
|
+
error: ["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18", "m14.5 9.5-5 5", "m9.5 9.5 5 5"],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const edgeOf = (value: ToastPosition) => {
|
|
72
|
+
if (value.startsWith("top")) return "top";
|
|
73
|
+
if (value.startsWith("middle")) return "middle";
|
|
74
|
+
return "bottom";
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const edge = $derived(edgeOf(position));
|
|
78
|
+
|
|
79
|
+
// A toast that enters from off-stage would fly past the viewport edge it is anchored to, so the
|
|
80
|
+
// offset points back towards the middle of the screen.
|
|
81
|
+
const flyOffset = $derived(edge === "top" ? -8 : 8);
|
|
82
|
+
|
|
83
|
+
const containsNode = (element: HTMLElement, node: EventTarget | null) =>
|
|
84
|
+
node instanceof Node && element.contains(node);
|
|
85
|
+
|
|
86
|
+
const resumeUnlessStillHeld = (id: string, event: MouseEvent | FocusEvent) => {
|
|
87
|
+
const toastElement = event.currentTarget;
|
|
88
|
+
if (!(toastElement instanceof HTMLElement)) return;
|
|
89
|
+
|
|
90
|
+
// A pointer leaving for a child, or focus moving between the toast's own controls, is not a
|
|
91
|
+
// reader letting go of the toast — the countdown has to stay frozen.
|
|
92
|
+
if (containsNode(toastElement, event.relatedTarget)) return;
|
|
93
|
+
if (containsNode(toastElement, document.activeElement)) return;
|
|
94
|
+
if (toastElement.matches(":hover")) return;
|
|
95
|
+
|
|
96
|
+
resolvedManager.resume(id);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Toasts have to outrank a modal `<dialog>`, and z-index cannot do it: a modal dialog lives in
|
|
100
|
+
// the browser's top layer, above every positioned element in the document. A popover reaches
|
|
101
|
+
// the top layer too, but a modal dialog still paints over a popover anchored in <body> — the
|
|
102
|
+
// only popover that wins is one nested inside that dialog. So the stack is hosted inside the
|
|
103
|
+
// topmost open modal when there is one, and in <body> otherwise.
|
|
104
|
+
let host = $state<HTMLElement>();
|
|
105
|
+
|
|
106
|
+
$effect(() => {
|
|
107
|
+
const pickHost = () => {
|
|
108
|
+
const modals = document.querySelectorAll<HTMLDialogElement>("dialog:modal");
|
|
109
|
+
host = modals[modals.length - 1] ?? document.body;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
pickHost();
|
|
113
|
+
// `:modal` flips with the `open` attribute that showModal()/close() write, so watching that
|
|
114
|
+
// one attribute re-parents live toasts as dialogs come and go.
|
|
115
|
+
const observer = new MutationObserver(pickHost);
|
|
116
|
+
observer.observe(document.documentElement, {
|
|
117
|
+
subtree: true,
|
|
118
|
+
attributes: true,
|
|
119
|
+
attributeFilter: ["open"],
|
|
120
|
+
});
|
|
121
|
+
return () => {
|
|
122
|
+
observer.disconnect();
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const hostInTopLayer = (target: HTMLElement | undefined) => (element: HTMLElement) => {
|
|
127
|
+
const parent = target ?? document.body;
|
|
128
|
+
if (element.parentElement !== parent) {
|
|
129
|
+
if (element.matches(":popover-open")) element.hidePopover();
|
|
130
|
+
parent.appendChild(element);
|
|
131
|
+
}
|
|
132
|
+
if (!element.matches(":popover-open")) element.showPopover();
|
|
133
|
+
};
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<!--
|
|
137
|
+
@component
|
|
138
|
+
Renders a `ToastManager`'s queue. Mount exactly one, unconditionally, in the app's root layout —
|
|
139
|
+
it has to outlive navigation so a toast fired just before a `goto()` survives to the next page.
|
|
140
|
+
|
|
141
|
+
With no `manager` prop it renders the queue behind the module-level `toast` helper, which is what
|
|
142
|
+
lets any module in the app call `toast.error(...)` with nothing plumbed through.
|
|
143
|
+
|
|
144
|
+
```svelte
|
|
145
|
+
<Toaster position="top-end" />
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The stack is a top-layer popover, re-parented into the topmost open modal `<dialog>` while one is
|
|
149
|
+
open, so toasts stay visible and clickable above a modal.
|
|
150
|
+
|
|
151
|
+
Its live region stays mounted even with nothing to show: a screen reader announces additions
|
|
152
|
+
reliably only into a region that was already in the accessibility tree when the content arrived.
|
|
153
|
+
It is `pointer-events: none` for that reason — an empty region must never swallow a tap.
|
|
154
|
+
-->
|
|
155
|
+
|
|
156
|
+
<div
|
|
157
|
+
class={["pointer-events-none toast gap-2", POSITION_CLASSES[position], cls]}
|
|
158
|
+
data-edge={edge}
|
|
159
|
+
popover="manual"
|
|
160
|
+
role="region"
|
|
161
|
+
aria-label={label}
|
|
162
|
+
aria-live="polite"
|
|
163
|
+
aria-relevant="additions text"
|
|
164
|
+
{@attach hostInTopLayer(host)}
|
|
165
|
+
>
|
|
166
|
+
{#each toasts as toast (toast.id)}
|
|
167
|
+
<div
|
|
168
|
+
class={[
|
|
169
|
+
"pointer-events-auto alert w-80 max-w-full shadow-lg",
|
|
170
|
+
VARIANT_CLASSES[toast.variant],
|
|
171
|
+
]}
|
|
172
|
+
data-testid="toast"
|
|
173
|
+
data-variant={toast.variant}
|
|
174
|
+
role={toast.variant === "info" || toast.variant === "success" ? "status" : "alert"}
|
|
175
|
+
aria-atomic="true"
|
|
176
|
+
onmouseenter={() => resolvedManager.pause(toast.id)}
|
|
177
|
+
onmouseleave={(event) => resumeUnlessStillHeld(toast.id, event)}
|
|
178
|
+
onfocusin={() => resolvedManager.pause(toast.id)}
|
|
179
|
+
onfocusout={(event) => resumeUnlessStillHeld(toast.id, event)}
|
|
180
|
+
in:fly|global={{ y: flyOffset, duration: 140 }}
|
|
181
|
+
out:scale|global={{ start: 0.96, duration: 160 }}
|
|
182
|
+
>
|
|
183
|
+
<svg
|
|
184
|
+
class="size-5 shrink-0"
|
|
185
|
+
viewBox="0 0 24 24"
|
|
186
|
+
fill="none"
|
|
187
|
+
stroke="currentColor"
|
|
188
|
+
stroke-width="2"
|
|
189
|
+
stroke-linecap="round"
|
|
190
|
+
stroke-linejoin="round"
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
>
|
|
193
|
+
{#each VARIANT_ICONS[toast.variant] as d (d)}
|
|
194
|
+
<path {d} />
|
|
195
|
+
{/each}
|
|
196
|
+
</svg>
|
|
197
|
+
|
|
198
|
+
<div class="flex min-w-0 flex-col">
|
|
199
|
+
<span class="break-words whitespace-pre-line">{toast.message}</span>
|
|
200
|
+
{#if toast.description}
|
|
201
|
+
<span class="text-xs opacity-80">{toast.description}</span>
|
|
202
|
+
{/if}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{#if toast.dismissible}
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
class="btn btn-square btn-ghost btn-sm"
|
|
209
|
+
aria-label={dismissLabel}
|
|
210
|
+
onclick={() => resolvedManager.dismiss(toast.id)}
|
|
211
|
+
>
|
|
212
|
+
<svg
|
|
213
|
+
class="size-4"
|
|
214
|
+
viewBox="0 0 24 24"
|
|
215
|
+
fill="none"
|
|
216
|
+
stroke="currentColor"
|
|
217
|
+
stroke-width="2"
|
|
218
|
+
stroke-linecap="round"
|
|
219
|
+
aria-hidden="true"
|
|
220
|
+
>
|
|
221
|
+
<path d="M18 6 6 18" />
|
|
222
|
+
<path d="m6 6 12 12" />
|
|
223
|
+
</svg>
|
|
224
|
+
</button>
|
|
225
|
+
{/if}
|
|
226
|
+
</div>
|
|
227
|
+
{/each}
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<style>
|
|
231
|
+
/* The stack is a popover, so the UA popover styles apply on top of daisyUI's `.toast`: they add
|
|
232
|
+
a border, padding, a Canvas background and `overflow: auto`. daisyUI resets none of those
|
|
233
|
+
because it never expected the element to be in the top layer. */
|
|
234
|
+
.toast {
|
|
235
|
+
margin: 0;
|
|
236
|
+
border: 0;
|
|
237
|
+
padding: 0;
|
|
238
|
+
overflow: visible;
|
|
239
|
+
background: transparent;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Phone chrome: the home indicator and the notch overlap a viewport-edge stack. daisyUI's flat
|
|
243
|
+
1rem does not know about either. */
|
|
244
|
+
.toast[data-edge="bottom"] {
|
|
245
|
+
bottom: calc(1rem + env(safe-area-inset-bottom));
|
|
246
|
+
}
|
|
247
|
+
.toast[data-edge="top"] {
|
|
248
|
+
top: calc(1rem + env(safe-area-inset-top));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* Svelte's in:/out: transitions own the toast lifecycle. daisyUI's own entry keyframe would
|
|
252
|
+
replay on every re-parent between hosts (each modal open and close), flashing the whole
|
|
253
|
+
stack back in. */
|
|
254
|
+
.toast > :global(*) {
|
|
255
|
+
animation: none;
|
|
256
|
+
}
|
|
257
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ClassValue } from "svelte/elements";
|
|
2
|
+
import { type ToastManager, type ToastPosition } from "./toast-manager.svelte.js";
|
|
3
|
+
interface Props {
|
|
4
|
+
class?: ClassValue;
|
|
5
|
+
/** Queue to render. Defaults to the one behind the module-level `toast` helper. */
|
|
6
|
+
manager?: ToastManager;
|
|
7
|
+
position?: ToastPosition;
|
|
8
|
+
/** Stack newest first. The default puts the newest at the bottom of the stack. */
|
|
9
|
+
reverseOrder?: boolean;
|
|
10
|
+
/** Accessible name of the live region, for apps that are not in English. */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** Accessible name of every toast's dismiss button. */
|
|
13
|
+
dismissLabel?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Renders a `ToastManager`'s queue. Mount exactly one, unconditionally, in the app's root layout —
|
|
17
|
+
* it has to outlive navigation so a toast fired just before a `goto()` survives to the next page.
|
|
18
|
+
*
|
|
19
|
+
* With no `manager` prop it renders the queue behind the module-level `toast` helper, which is what
|
|
20
|
+
* lets any module in the app call `toast.error(...)` with nothing plumbed through.
|
|
21
|
+
*
|
|
22
|
+
* ```svelte
|
|
23
|
+
* <Toaster position="top-end" />
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* The stack is a top-layer popover, re-parented into the topmost open modal `<dialog>` while one is
|
|
27
|
+
* open, so toasts stay visible and clickable above a modal.
|
|
28
|
+
*
|
|
29
|
+
* Its live region stays mounted even with nothing to show: a screen reader announces additions
|
|
30
|
+
* reliably only into a region that was already in the accessibility tree when the content arrived.
|
|
31
|
+
* It is `pointer-events: none` for that reason — an empty region must never swallow a tap.
|
|
32
|
+
*/
|
|
33
|
+
declare const Toaster: import("svelte").Component<Props, {}, "">;
|
|
34
|
+
type Toaster = ReturnType<typeof Toaster>;
|
|
35
|
+
export default Toaster;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Severity a toast carries. Maps to a daisyUI alert modifier and an icon in `Toaster`. */
|
|
2
|
+
export type ToastVariant = "info" | "success" | "warning" | "error";
|
|
3
|
+
/**
|
|
4
|
+
* Where a `Toaster` anchors its stack. Mirrors daisyUI's two-axis toast placement, so the value
|
|
5
|
+
* is a rendering concern only — the manager never sees it.
|
|
6
|
+
*/
|
|
7
|
+
export type ToastPosition = "top-start" | "top-center" | "top-end" | "middle-start" | "middle-center" | "middle-end" | "bottom-start" | "bottom-center" | "bottom-end";
|
|
8
|
+
/** A queued notification. Produced by the manager; consumed by `Toaster`. */
|
|
9
|
+
export interface Toast {
|
|
10
|
+
id: string;
|
|
11
|
+
variant: ToastVariant;
|
|
12
|
+
message: string;
|
|
13
|
+
/** Secondary line, rendered smaller under the message. */
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Milliseconds on screen. Anything non-finite or non-positive makes the toast sticky. */
|
|
16
|
+
duration: number;
|
|
17
|
+
dismissible: boolean;
|
|
18
|
+
/** Set once per id, so an in-place update keeps the moment the toast first appeared. */
|
|
19
|
+
createdAt: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ToastOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Reuse an id to replace a toast in place instead of stacking a second one. Everything except
|
|
24
|
+
* `createdAt` is replaced, so an option left out falls back to the default rather than to the
|
|
25
|
+
* previous toast's value.
|
|
26
|
+
*/
|
|
27
|
+
id?: string;
|
|
28
|
+
duration?: number;
|
|
29
|
+
description?: string;
|
|
30
|
+
dismissible?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface ToastManagerOptions {
|
|
33
|
+
/**
|
|
34
|
+
* How many toasts may be on screen at once; the oldest is dropped to make room. A cap exists
|
|
35
|
+
* because a failing loop can fire dozens of identical errors, and a stack taller than the
|
|
36
|
+
* viewport hides the newest one — the only one still worth reading.
|
|
37
|
+
*/
|
|
38
|
+
limit?: number;
|
|
39
|
+
/** Per-variant auto-dismiss delays, merged over the defaults. */
|
|
40
|
+
durations?: Partial<Record<ToastVariant, number>>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The queue behind `Toaster`. Owns ids, ordering, the stack cap and every auto-dismiss timer.
|
|
44
|
+
*
|
|
45
|
+
* Most apps never touch this class: `toast.success(...)` goes through the process-wide default
|
|
46
|
+
* manager, and a single `<Toaster />` near the app root renders it. Construct one explicitly to
|
|
47
|
+
* scope a queue to a subtree, or to give a test a manager it can throw away afterwards.
|
|
48
|
+
*/
|
|
49
|
+
export declare class ToastManager {
|
|
50
|
+
#private;
|
|
51
|
+
constructor({ limit, durations }?: ToastManagerOptions);
|
|
52
|
+
/** Oldest first. Read-only by contract — every mutation goes through a method. */
|
|
53
|
+
get toasts(): readonly Toast[];
|
|
54
|
+
/** Push a toast (or replace the one already holding `options.id`) and return its id. */
|
|
55
|
+
show: (message: string, options?: ToastOptions, variant?: ToastVariant) => string;
|
|
56
|
+
info: (message: string, options?: ToastOptions) => string;
|
|
57
|
+
success: (message: string, options?: ToastOptions) => string;
|
|
58
|
+
warning: (message: string, options?: ToastOptions) => string;
|
|
59
|
+
error: (message: string, options?: ToastOptions) => string;
|
|
60
|
+
/** Remove one toast. Unknown ids are ignored, so a caller may dismiss twice. */
|
|
61
|
+
dismiss: (id: string) => void;
|
|
62
|
+
/** Empty the queue. Useful on sign-out or on a navigation that invalidates every message. */
|
|
63
|
+
clear: () => void;
|
|
64
|
+
/**
|
|
65
|
+
* Freeze a toast's countdown. `Toaster` calls this on hover and on focus so a toast cannot
|
|
66
|
+
* vanish while it is being read or while its dismiss button is the focused element.
|
|
67
|
+
*/
|
|
68
|
+
pause: (id: string) => void;
|
|
69
|
+
/** Resume a frozen countdown from where `pause` left it. */
|
|
70
|
+
resume: (id: string) => void;
|
|
71
|
+
}
|
|
72
|
+
export declare const createToastManager: (options?: ToastManagerOptions) => ToastManager;
|
|
73
|
+
/**
|
|
74
|
+
* The manager backing the module-level `toast` helper.
|
|
75
|
+
*
|
|
76
|
+
* A singleton only in the browser. On the server every call hands back a fresh manager, because
|
|
77
|
+
* a module-level queue there is shared by every concurrent request — one user's toast would be
|
|
78
|
+
* rendered into another user's HTML.
|
|
79
|
+
*/
|
|
80
|
+
export declare const getDefaultToastManager: () => ToastManager;
|
|
81
|
+
export interface ToastApi {
|
|
82
|
+
show: (message: string, options?: ToastOptions, variant?: ToastVariant) => string;
|
|
83
|
+
info: (message: string, options?: ToastOptions) => string;
|
|
84
|
+
success: (message: string, options?: ToastOptions) => string;
|
|
85
|
+
warning: (message: string, options?: ToastOptions) => string;
|
|
86
|
+
error: (message: string, options?: ToastOptions) => string;
|
|
87
|
+
dismiss: (id: string) => void;
|
|
88
|
+
clear: () => void;
|
|
89
|
+
pause: (id: string) => void;
|
|
90
|
+
resume: (id: string) => void;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The ergonomic entry point: `toast.error("Could not save")` from anywhere, with no context to
|
|
94
|
+
* plumb and no import of the manager.
|
|
95
|
+
*
|
|
96
|
+
* Each call resolves the default manager rather than capturing it, so importing this module on
|
|
97
|
+
* the server never creates the browser singleton.
|
|
98
|
+
*/
|
|
99
|
+
export declare const toast: ToastApi;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Errors and warnings linger: they carry something the reader has to act on. */
|
|
2
|
+
const DEFAULT_DURATIONS = {
|
|
3
|
+
info: 4000,
|
|
4
|
+
success: 3000,
|
|
5
|
+
warning: 5000,
|
|
6
|
+
error: 5000,
|
|
7
|
+
};
|
|
8
|
+
const DEFAULT_LIMIT = 5;
|
|
9
|
+
let nextSequence = 0;
|
|
10
|
+
const createToastId = () => `toast-${++nextSequence}`;
|
|
11
|
+
/**
|
|
12
|
+
* The queue behind `Toaster`. Owns ids, ordering, the stack cap and every auto-dismiss timer.
|
|
13
|
+
*
|
|
14
|
+
* Most apps never touch this class: `toast.success(...)` goes through the process-wide default
|
|
15
|
+
* manager, and a single `<Toaster />` near the app root renders it. Construct one explicitly to
|
|
16
|
+
* scope a queue to a subtree, or to give a test a manager it can throw away afterwards.
|
|
17
|
+
*/
|
|
18
|
+
export class ToastManager {
|
|
19
|
+
#toasts = $state([]);
|
|
20
|
+
#limit;
|
|
21
|
+
#durations;
|
|
22
|
+
// A plain Map, not `SvelteMap`: nothing renders from the clocks, so reactivity here would be
|
|
23
|
+
// pure overhead. The rendered surface is `#toasts` and nothing else.
|
|
24
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
25
|
+
#clocks = new Map();
|
|
26
|
+
constructor({ limit = DEFAULT_LIMIT, durations } = {}) {
|
|
27
|
+
this.#limit = Math.max(1, limit);
|
|
28
|
+
this.#durations = { ...DEFAULT_DURATIONS, ...durations };
|
|
29
|
+
}
|
|
30
|
+
/** Oldest first. Read-only by contract — every mutation goes through a method. */
|
|
31
|
+
get toasts() {
|
|
32
|
+
return this.#toasts;
|
|
33
|
+
}
|
|
34
|
+
/** Push a toast (or replace the one already holding `options.id`) and return its id. */
|
|
35
|
+
show = (message, options = {}, variant = "info") => {
|
|
36
|
+
const id = options.id ?? createToastId();
|
|
37
|
+
const existing = this.#toasts.find((toast) => toast.id === id);
|
|
38
|
+
const next = {
|
|
39
|
+
id,
|
|
40
|
+
variant,
|
|
41
|
+
message,
|
|
42
|
+
description: options.description,
|
|
43
|
+
duration: options.duration ?? this.#durations[variant],
|
|
44
|
+
dismissible: options.dismissible ?? true,
|
|
45
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
46
|
+
};
|
|
47
|
+
this.#upsert(next);
|
|
48
|
+
this.#enforceLimit();
|
|
49
|
+
this.#restartClock(next);
|
|
50
|
+
return id;
|
|
51
|
+
};
|
|
52
|
+
info = (message, options) => this.show(message, options, "info");
|
|
53
|
+
success = (message, options) => this.show(message, options, "success");
|
|
54
|
+
warning = (message, options) => this.show(message, options, "warning");
|
|
55
|
+
error = (message, options) => this.show(message, options, "error");
|
|
56
|
+
/** Remove one toast. Unknown ids are ignored, so a caller may dismiss twice. */
|
|
57
|
+
dismiss = (id) => {
|
|
58
|
+
this.#remove(id);
|
|
59
|
+
};
|
|
60
|
+
/** Empty the queue. Useful on sign-out or on a navigation that invalidates every message. */
|
|
61
|
+
clear = () => {
|
|
62
|
+
for (const clock of this.#clocks.values())
|
|
63
|
+
if (clock.handle)
|
|
64
|
+
clearTimeout(clock.handle);
|
|
65
|
+
this.#clocks.clear();
|
|
66
|
+
this.#toasts = [];
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Freeze a toast's countdown. `Toaster` calls this on hover and on focus so a toast cannot
|
|
70
|
+
* vanish while it is being read or while its dismiss button is the focused element.
|
|
71
|
+
*/
|
|
72
|
+
pause = (id) => {
|
|
73
|
+
const clock = this.#clocks.get(id);
|
|
74
|
+
if (!clock || clock.paused)
|
|
75
|
+
return;
|
|
76
|
+
clock.paused = true;
|
|
77
|
+
if (clock.handle === null)
|
|
78
|
+
return;
|
|
79
|
+
clearTimeout(clock.handle);
|
|
80
|
+
clock.handle = null;
|
|
81
|
+
clock.remainingMs = Math.max(0, (clock.deadline ?? Date.now()) - Date.now());
|
|
82
|
+
clock.deadline = null;
|
|
83
|
+
};
|
|
84
|
+
/** Resume a frozen countdown from where `pause` left it. */
|
|
85
|
+
resume = (id) => {
|
|
86
|
+
const clock = this.#clocks.get(id);
|
|
87
|
+
if (!clock)
|
|
88
|
+
return;
|
|
89
|
+
clock.paused = false;
|
|
90
|
+
this.#start(id);
|
|
91
|
+
};
|
|
92
|
+
#upsert(toast) {
|
|
93
|
+
const index = this.#toasts.findIndex((current) => current.id === toast.id);
|
|
94
|
+
if (index === -1)
|
|
95
|
+
this.#toasts.push(toast);
|
|
96
|
+
else
|
|
97
|
+
this.#toasts[index] = toast;
|
|
98
|
+
}
|
|
99
|
+
#enforceLimit() {
|
|
100
|
+
// Drop from the front: the newest toast was just appended, and it is the one the caller
|
|
101
|
+
// actually wants seen.
|
|
102
|
+
while (this.#toasts.length > this.#limit)
|
|
103
|
+
this.#remove(this.#toasts[0].id);
|
|
104
|
+
}
|
|
105
|
+
#remove(id) {
|
|
106
|
+
const clock = this.#clocks.get(id);
|
|
107
|
+
if (clock?.handle)
|
|
108
|
+
clearTimeout(clock.handle);
|
|
109
|
+
this.#clocks.delete(id);
|
|
110
|
+
this.#toasts = this.#toasts.filter((toast) => toast.id !== id);
|
|
111
|
+
}
|
|
112
|
+
#restartClock(toast) {
|
|
113
|
+
const previous = this.#clocks.get(toast.id);
|
|
114
|
+
if (previous?.handle)
|
|
115
|
+
clearTimeout(previous.handle);
|
|
116
|
+
this.#clocks.set(toast.id, {
|
|
117
|
+
handle: null,
|
|
118
|
+
sticky: !Number.isFinite(toast.duration) || toast.duration <= 0,
|
|
119
|
+
remainingMs: toast.duration,
|
|
120
|
+
deadline: null,
|
|
121
|
+
// An update that lands while the pointer rests on the toast (a retry turning an error into
|
|
122
|
+
// a success) must stay frozen; starting a fresh countdown under the cursor would take the
|
|
123
|
+
// message away without the reader ever leaving it.
|
|
124
|
+
paused: previous?.paused ?? false,
|
|
125
|
+
});
|
|
126
|
+
this.#start(toast.id);
|
|
127
|
+
}
|
|
128
|
+
#start(id) {
|
|
129
|
+
const clock = this.#clocks.get(id);
|
|
130
|
+
if (!clock || clock.paused || clock.sticky || clock.handle !== null)
|
|
131
|
+
return;
|
|
132
|
+
// Only reachable when a pause landed exactly on the deadline: the countdown is spent, so the
|
|
133
|
+
// toast has already earned its exit.
|
|
134
|
+
if (clock.remainingMs <= 0) {
|
|
135
|
+
this.#remove(id);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
clock.deadline = Date.now() + clock.remainingMs;
|
|
139
|
+
clock.handle = setTimeout(() => {
|
|
140
|
+
this.#remove(id);
|
|
141
|
+
}, clock.remainingMs);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export const createToastManager = (options) => new ToastManager(options);
|
|
145
|
+
let browserManager = null;
|
|
146
|
+
/**
|
|
147
|
+
* The manager backing the module-level `toast` helper.
|
|
148
|
+
*
|
|
149
|
+
* A singleton only in the browser. On the server every call hands back a fresh manager, because
|
|
150
|
+
* a module-level queue there is shared by every concurrent request — one user's toast would be
|
|
151
|
+
* rendered into another user's HTML.
|
|
152
|
+
*/
|
|
153
|
+
export const getDefaultToastManager = () => {
|
|
154
|
+
if (typeof document === "undefined")
|
|
155
|
+
return createToastManager();
|
|
156
|
+
browserManager ??= createToastManager();
|
|
157
|
+
return browserManager;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* The ergonomic entry point: `toast.error("Could not save")` from anywhere, with no context to
|
|
161
|
+
* plumb and no import of the manager.
|
|
162
|
+
*
|
|
163
|
+
* Each call resolves the default manager rather than capturing it, so importing this module on
|
|
164
|
+
* the server never creates the browser singleton.
|
|
165
|
+
*/
|
|
166
|
+
export const toast = {
|
|
167
|
+
show: (message, options, variant) => getDefaultToastManager().show(message, options, variant),
|
|
168
|
+
info: (message, options) => getDefaultToastManager().info(message, options),
|
|
169
|
+
success: (message, options) => getDefaultToastManager().success(message, options),
|
|
170
|
+
warning: (message, options) => getDefaultToastManager().warning(message, options),
|
|
171
|
+
error: (message, options) => getDefaultToastManager().error(message, options),
|
|
172
|
+
dismiss: (id) => getDefaultToastManager().dismiss(id),
|
|
173
|
+
clear: () => getDefaultToastManager().clear(),
|
|
174
|
+
pause: (id) => getDefaultToastManager().pause(id),
|
|
175
|
+
resume: (id) => getDefaultToastManager().resume(id),
|
|
176
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { getUserContext } from "./context.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Roles the user must hold. An empty list is an open gate. */
|
|
7
|
+
roles: readonly string[];
|
|
8
|
+
/** Require every role instead of at least one. */
|
|
9
|
+
requiresAll?: boolean;
|
|
10
|
+
children: Snippet;
|
|
11
|
+
/** Rendered instead of `children` when the check fails. Nothing renders without it. */
|
|
12
|
+
fallback?: Snippet;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { roles, requiresAll = false, children, fallback }: Props = $props();
|
|
16
|
+
|
|
17
|
+
const user = getUserContext();
|
|
18
|
+
|
|
19
|
+
// Reading the check inside `$derived` rather than at init is what makes the gate follow a
|
|
20
|
+
// sign-in: the user context is expected to be reactive, so this re-runs when its status or
|
|
21
|
+
// roles change and the subtree appears without a remount.
|
|
22
|
+
const allowed = $derived(requiresAll ? user.hasAllRoles(roles) : user.hasAnyRole(roles));
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<!--
|
|
26
|
+
@component
|
|
27
|
+
Renders its children only for a user holding the required roles.
|
|
28
|
+
|
|
29
|
+
The check delegates entirely to the `UserContext` in scope; with no context set, the anonymous
|
|
30
|
+
default lets everything through, so a gated subtree still renders in an app that has no
|
|
31
|
+
authentication yet.
|
|
32
|
+
|
|
33
|
+
```svelte
|
|
34
|
+
<ScopedComponent roles={["orders:write"]}>
|
|
35
|
+
<button>Approve</button>
|
|
36
|
+
{#snippet fallback()}
|
|
37
|
+
<span class="opacity-50">Read-only</span>
|
|
38
|
+
{/snippet}
|
|
39
|
+
</ScopedComponent>
|
|
40
|
+
```
|
|
41
|
+
-->
|
|
42
|
+
|
|
43
|
+
{#if allowed}
|
|
44
|
+
{@render children()}
|
|
45
|
+
{:else if fallback}
|
|
46
|
+
{@render fallback()}
|
|
47
|
+
{/if}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Roles the user must hold. An empty list is an open gate. */
|
|
4
|
+
roles: readonly string[];
|
|
5
|
+
/** Require every role instead of at least one. */
|
|
6
|
+
requiresAll?: boolean;
|
|
7
|
+
children: Snippet;
|
|
8
|
+
/** Rendered instead of `children` when the check fails. Nothing renders without it. */
|
|
9
|
+
fallback?: Snippet;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Renders its children only for a user holding the required roles.
|
|
13
|
+
*
|
|
14
|
+
* The check delegates entirely to the `UserContext` in scope; with no context set, the anonymous
|
|
15
|
+
* default lets everything through, so a gated subtree still renders in an app that has no
|
|
16
|
+
* authentication yet.
|
|
17
|
+
*
|
|
18
|
+
* ```svelte
|
|
19
|
+
* <ScopedComponent roles={["orders:write"]}>
|
|
20
|
+
* <button>Approve</button>
|
|
21
|
+
* {#snippet fallback()}
|
|
22
|
+
* <span class="opacity-50">Read-only</span>
|
|
23
|
+
* {/snippet}
|
|
24
|
+
* </ScopedComponent>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
declare const ScopedComponent: import("svelte").Component<Props, {}, "">;
|
|
28
|
+
type ScopedComponent = ReturnType<typeof ScopedComponent>;
|
|
29
|
+
export default ScopedComponent;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { UserContext } from "./context.js";
|
|
2
|
+
/**
|
|
3
|
+
* The user context in force until an app installs its own.
|
|
4
|
+
*
|
|
5
|
+
* Nobody is signed in, and every role check answers `true`. That reads backwards for something
|
|
6
|
+
* whose job is to deny, so it is worth being explicit about why it is the right default.
|
|
7
|
+
*
|
|
8
|
+
* A role gate is a *statement about the app's intent*, not a security control — the server is
|
|
9
|
+
* what actually refuses to serve the data. So the question is only what an unauthenticated
|
|
10
|
+
* library should do with an intent it has no way to evaluate. Denying would mean a route tree
|
|
11
|
+
* annotated with `requiredRoles` renders as an empty shell the moment you clone it, and the
|
|
12
|
+
* annotation would have to be commented out to develop against it. Allowing makes the gates
|
|
13
|
+
* advisory: they sit in the config, describing the eventual policy, doing nothing, until the
|
|
14
|
+
* day the app calls `setUserContext` with something that can actually answer. From that call
|
|
15
|
+
* on, every gate is live at once, with no other edit.
|
|
16
|
+
*
|
|
17
|
+
* The failure mode of the opposite choice — an app that ships without ever setting a context
|
|
18
|
+
* and hides its own UI — is silent and confusing. This one is loud: everything is visible,
|
|
19
|
+
* `status` says `"anonymous"`, and nothing pretends a user exists.
|
|
20
|
+
*/
|
|
21
|
+
export declare const anonymousUser: UserContext;
|