@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,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,2 @@
1
+ export { createToastManager, getDefaultToastManager, toast, ToastManager, type Toast, type ToastApi, type ToastManagerOptions, type ToastOptions, type ToastPosition, type ToastVariant, } from "./toast-manager.svelte.js";
2
+ export { default as Toaster } from "./Toaster.svelte";
@@ -0,0 +1,2 @@
1
+ export { createToastManager, getDefaultToastManager, toast, ToastManager, } from "./toast-manager.svelte.js";
2
+ export { default as Toaster } from "./Toaster.svelte";
@@ -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;