@viniaraujo68/plinth 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +361 -2
- package/dist/anchoring.d.ts +61 -0
- package/dist/anchoring.js +151 -0
- package/dist/async-action.svelte.d.ts +37 -0
- package/dist/async-action.svelte.js +64 -0
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/tooltip.d.ts +37 -0
- package/dist/attachments/tooltip.js +166 -0
- package/dist/components/AsyncButton.svelte +49 -0
- package/dist/components/AsyncButton.svelte.d.ts +32 -0
- package/dist/components/Combobox.svelte +380 -0
- package/dist/components/Combobox.svelte.d.ts +74 -0
- package/dist/components/Copyable.svelte +116 -0
- package/dist/components/Copyable.svelte.d.ts +37 -0
- package/dist/components/DateRangePicker.svelte +266 -0
- package/dist/components/DateRangePicker.svelte.d.ts +66 -0
- package/dist/components/Dialog.svelte +102 -0
- package/dist/components/Dialog.svelte.d.ts +38 -0
- package/dist/components/Dropdown.svelte +204 -0
- package/dist/components/Dropdown.svelte.d.ts +49 -0
- package/dist/components/ErrorDisplay.svelte +59 -0
- package/dist/components/ErrorDisplay.svelte.d.ts +24 -0
- package/dist/components/ErrorPage.svelte +119 -0
- package/dist/components/ErrorPage.svelte.d.ts +52 -0
- package/dist/components/JsonInput.svelte +106 -0
- package/dist/components/JsonInput.svelte.d.ts +40 -0
- package/dist/components/Listbox.svelte +257 -0
- package/dist/components/Listbox.svelte.d.ts +78 -0
- package/dist/components/LoadingButton.svelte +36 -0
- package/dist/components/LoadingButton.svelte.d.ts +22 -0
- package/dist/components/Logo.svelte +112 -0
- package/dist/components/Logo.svelte.d.ts +29 -0
- package/dist/components/Modal.svelte +187 -0
- package/dist/components/Modal.svelte.d.ts +62 -0
- package/dist/components/MultiSelect.svelte +256 -0
- package/dist/components/MultiSelect.svelte.d.ts +89 -0
- package/dist/components/OptionPanel.svelte +241 -0
- package/dist/components/OptionPanel.svelte.d.ts +51 -0
- package/dist/components/Paginator.svelte +205 -0
- package/dist/components/Paginator.svelte.d.ts +48 -0
- package/dist/components/RefreshButton.svelte +246 -0
- package/dist/components/RefreshButton.svelte.d.ts +46 -0
- package/dist/components/SegmentedControl.svelte +147 -0
- package/dist/components/SegmentedControl.svelte.d.ts +71 -0
- package/dist/components/Select.svelte +312 -0
- package/dist/components/Select.svelte.d.ts +68 -0
- package/dist/components/Skeleton.svelte +97 -0
- package/dist/components/Skeleton.svelte.d.ts +32 -0
- package/dist/components/Tooltip.svelte +60 -0
- package/dist/components/Tooltip.svelte.d.ts +58 -0
- package/dist/components/date-range.d.ts +101 -0
- package/dist/components/date-range.js +144 -0
- package/dist/components/index.d.ts +25 -0
- package/dist/components/index.js +37 -0
- package/dist/components/json-text.d.ts +22 -0
- package/dist/components/json-text.js +22 -0
- package/dist/components/pick.d.ts +18 -0
- package/dist/components/pick.js +18 -0
- package/dist/components/segmented.d.ts +7 -0
- package/dist/components/segmented.js +1 -0
- package/dist/components/select.d.ts +66 -0
- package/dist/components/select.js +90 -0
- package/dist/confirm/Confirmer.svelte +194 -0
- package/dist/confirm/Confirmer.svelte.d.ts +45 -0
- package/dist/confirm/challenge.d.ts +16 -0
- package/dist/confirm/challenge.js +16 -0
- package/dist/confirm/confirm-manager.svelte.d.ts +91 -0
- package/dist/confirm/confirm-manager.svelte.js +149 -0
- package/dist/confirm/index.d.ts +3 -0
- package/dist/confirm/index.js +3 -0
- package/dist/error.d.ts +25 -0
- package/dist/error.js +39 -0
- package/dist/formatters.d.ts +95 -0
- package/dist/formatters.js +146 -0
- package/dist/http.d.ts +68 -0
- package/dist/http.js +138 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/dist/routing.svelte.d.ts +169 -0
- package/dist/routing.svelte.js +208 -0
- package/dist/shell/AppShell.svelte +874 -0
- package/dist/shell/AppShell.svelte.d.ts +93 -0
- package/dist/shell/Breadcrumbs.svelte +78 -0
- package/dist/shell/Breadcrumbs.svelte.d.ts +25 -0
- package/dist/shell/crumbs.d.ts +23 -0
- package/dist/shell/crumbs.js +1 -0
- package/dist/shell/index.d.ts +3 -0
- package/dist/shell/index.js +6 -0
- package/dist/table/ColumnMenu.svelte +249 -0
- package/dist/table/ColumnMenu.svelte.d.ts +47 -0
- package/dist/table/DataTable.svelte +643 -0
- package/dist/table/DataTable.svelte.d.ts +153 -0
- package/dist/table/columns.d.ts +55 -0
- package/dist/table/columns.js +153 -0
- package/dist/table/index.d.ts +4 -0
- package/dist/table/index.js +8 -0
- package/dist/table/sort.d.ts +48 -0
- package/dist/table/sort.js +139 -0
- package/dist/table/types.d.ts +93 -0
- package/dist/table/types.js +1 -0
- package/dist/theme/ThemeController.svelte +54 -0
- package/dist/theme/ThemeController.svelte.d.ts +27 -0
- package/dist/theme/ThemeToggle.svelte +119 -0
- package/dist/theme/ThemeToggle.svelte.d.ts +52 -0
- package/dist/theme/css-color.d.ts +23 -0
- package/dist/theme/css-color.js +31 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +5 -0
- package/dist/theme/theme.css +410 -0
- package/dist/theme/theme.svelte.d.ts +54 -0
- package/dist/theme/theme.svelte.js +58 -0
- package/dist/toast/Toaster.svelte +257 -0
- package/dist/toast/Toaster.svelte.d.ts +35 -0
- package/dist/toast/index.d.ts +2 -0
- package/dist/toast/index.js +2 -0
- package/dist/toast/toast-manager.svelte.d.ts +99 -0
- package/dist/toast/toast-manager.svelte.js +176 -0
- package/dist/user/ScopedComponent.svelte +47 -0
- package/dist/user/ScopedComponent.svelte.d.ts +29 -0
- package/dist/user/anonymous.d.ts +21 -0
- package/dist/user/anonymous.js +26 -0
- package/dist/user/context.d.ts +55 -0
- package/dist/user/context.js +13 -0
- package/dist/user/index.d.ts +3 -0
- package/dist/user/index.js +6 -0
- package/package.json +133 -4
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ClassValue } from "svelte/elements";
|
|
3
|
+
import Modal from "../components/Modal.svelte";
|
|
4
|
+
import { challengeSatisfied } from "./challenge.js";
|
|
5
|
+
import {
|
|
6
|
+
getDefaultConfirmManager,
|
|
7
|
+
type ConfirmManager,
|
|
8
|
+
type ConfirmRequest,
|
|
9
|
+
} from "./confirm-manager.svelte.js";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Queue to render. Defaults to the one behind the module-level `confirm` helper. */
|
|
13
|
+
manager?: ConfirmManager;
|
|
14
|
+
/** Extra classes for the box -- a wider cap for a long name. Not for the backdrop. */
|
|
15
|
+
class?: ClassValue;
|
|
16
|
+
/**
|
|
17
|
+
* Default label of the confirm action, for apps that are not in English. A call overrides it
|
|
18
|
+
* with `confirmLabel`; this is what every call that does not gets.
|
|
19
|
+
*/
|
|
20
|
+
confirmLabel?: string;
|
|
21
|
+
/** Default label of the cancel action. Overridden per call by `cancelLabel`. */
|
|
22
|
+
cancelLabel?: string;
|
|
23
|
+
/** Accessible name of the close button, which never varies per call. */
|
|
24
|
+
closeLabel?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Labels the challenge input. A function rather than a string because the challenge itself has
|
|
27
|
+
* to appear inside the sentence, and only the app knows where its language puts it.
|
|
28
|
+
*/
|
|
29
|
+
challengeLabel?: (challenge: string) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
manager,
|
|
34
|
+
class: className,
|
|
35
|
+
confirmLabel = "Confirm",
|
|
36
|
+
cancelLabel = "Cancel",
|
|
37
|
+
closeLabel = "Close",
|
|
38
|
+
challengeLabel = (challenge) => `Type ${challenge} to confirm`,
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
const resolvedManager = $derived(manager ?? getDefaultConfirmManager());
|
|
42
|
+
|
|
43
|
+
let modal = $state<Modal>();
|
|
44
|
+
// What is on screen, which is NOT the same as `manager.current`: the head of the queue advances
|
|
45
|
+
// the moment a request is settled, and the dialog is still fading out at that point. Holding the
|
|
46
|
+
// snapshot until the next one is actually presented is what keeps the closing box from flashing
|
|
47
|
+
// the next question's title.
|
|
48
|
+
let shown = $state<ConfirmRequest | null>(null);
|
|
49
|
+
let typed = $state("");
|
|
50
|
+
|
|
51
|
+
// Not `$state`: nothing renders from it, and it is written and read within one close cycle.
|
|
52
|
+
let outcome = false;
|
|
53
|
+
|
|
54
|
+
const inputId = $props.id();
|
|
55
|
+
const enabled = $derived(challengeSatisfied(shown?.challenge, typed));
|
|
56
|
+
|
|
57
|
+
// A question that fits entirely in its title -- "Delete this night?" -- has nothing to put in a
|
|
58
|
+
// body, and `Modal` renders no body element at all when it is handed no snippet. Deciding here
|
|
59
|
+
// rather than rendering an empty wrapper is what keeps that case from showing a padded strip
|
|
60
|
+
// between the header and the actions.
|
|
61
|
+
const hasBody = $derived(Boolean(shown?.description ?? shown?.challenge));
|
|
62
|
+
|
|
63
|
+
$effect(() =>
|
|
64
|
+
resolvedManager.attach(() => {
|
|
65
|
+
shown = resolvedManager.current;
|
|
66
|
+
typed = "";
|
|
67
|
+
outcome = false;
|
|
68
|
+
modal?.show();
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Every path out of the dialog goes through `close`, so the decision is recorded first and read
|
|
73
|
+
// back once the element reports itself closed. That also means the awaiting caller resumes with
|
|
74
|
+
// the backdrop already gone, rather than racing a navigation against a closing dialog.
|
|
75
|
+
const decide = (confirmed: boolean) => {
|
|
76
|
+
outcome = confirmed;
|
|
77
|
+
modal?.close();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleClose = () => {
|
|
81
|
+
if (!shown) return;
|
|
82
|
+
|
|
83
|
+
const confirmed = outcome;
|
|
84
|
+
outcome = false;
|
|
85
|
+
// Escape, the backdrop and the close button never touch `outcome`, so they all arrive here as
|
|
86
|
+
// false -- cancel is the default of every path that is not the confirm action itself.
|
|
87
|
+
resolvedManager.settle(shown.id, confirmed);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleChallengeKeydown = (event: KeyboardEvent) => {
|
|
91
|
+
if (event.key !== "Enter") return;
|
|
92
|
+
|
|
93
|
+
// There is no form here, so nothing submits on its own and Enter has to be wired to the action
|
|
94
|
+
// it stands in for. Prevented in both branches: an Enter that lands before the gate opens must
|
|
95
|
+
// do nothing at all rather than fall through to anything else.
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
if (enabled) decide(true);
|
|
98
|
+
};
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<!--
|
|
102
|
+
@component
|
|
103
|
+
Renders a `ConfirmManager`'s queue as a modal question. Mount exactly one, unconditionally, in the
|
|
104
|
+
app's root layout, next to `Toaster` and for the same reason: the call site that asks the question
|
|
105
|
+
is somewhere else entirely.
|
|
106
|
+
|
|
107
|
+
With no `manager` prop it renders the queue behind the module-level `confirm` helper, which is what
|
|
108
|
+
lets any module in the app write the one line this exists for:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
if (!(await confirm({ title: `Delete ${night.name}?`, danger: true }))) return;
|
|
112
|
+
await api.delete(`/nights/${night.id}`);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The library ships no translations, so the four labels here are how an app localizes every confirm
|
|
116
|
+
at once; a single call overrides any of them through its own options.
|
|
117
|
+
|
|
118
|
+
Focus never lands on the confirm action. It opens on cancel, or on the challenge input when there
|
|
119
|
+
is one, so a stray Enter can only ever cancel -- and in the challenge input Enter confirms solely
|
|
120
|
+
when what has been typed already matches.
|
|
121
|
+
-->
|
|
122
|
+
|
|
123
|
+
{#snippet body()}
|
|
124
|
+
<div class="flex flex-col gap-4" data-testid="confirm-body">
|
|
125
|
+
{#if shown?.description}
|
|
126
|
+
<p class="text-sm whitespace-pre-line text-base-content/70" data-testid="confirm-description">
|
|
127
|
+
{shown.description}
|
|
128
|
+
</p>
|
|
129
|
+
{/if}
|
|
130
|
+
|
|
131
|
+
{#if shown?.challenge}
|
|
132
|
+
<div class="flex flex-col gap-2">
|
|
133
|
+
<label class="text-sm font-medium" for={inputId}>
|
|
134
|
+
{shown.challengeLabel ?? challengeLabel(shown.challenge)}
|
|
135
|
+
</label>
|
|
136
|
+
<!-- The browser opens a dialog on its first focusable descendant, which is the ✕.
|
|
137
|
+
Autofocus is how content overrides that, and it is the seam `Modal` already documents;
|
|
138
|
+
the a11y rule suppressed here is about a page stealing focus from a reader, which is
|
|
139
|
+
not what a dialog that has just taken focus by definition is doing. Autocomplete,
|
|
140
|
+
correction and capitalisation are off because the comparison is exact, and a phone
|
|
141
|
+
capitalising the first letter would make the gate unopenable. -->
|
|
142
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
143
|
+
<input
|
|
144
|
+
id={inputId}
|
|
145
|
+
data-testid="confirm-challenge"
|
|
146
|
+
class="input w-full"
|
|
147
|
+
type="text"
|
|
148
|
+
autocomplete="off"
|
|
149
|
+
autocapitalize="none"
|
|
150
|
+
autocorrect="off"
|
|
151
|
+
spellcheck="false"
|
|
152
|
+
autofocus
|
|
153
|
+
bind:value={typed}
|
|
154
|
+
onkeydown={handleChallengeKeydown}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
159
|
+
{/snippet}
|
|
160
|
+
|
|
161
|
+
{#snippet footer()}
|
|
162
|
+
<!-- The other half of the focus rule: with no challenge to type, cancel is where the dialog
|
|
163
|
+
opens, one Enter away from the safe outcome rather than from the destructive one. -->
|
|
164
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
class="btn btn-ghost"
|
|
168
|
+
data-testid="confirm-cancel"
|
|
169
|
+
autofocus={!shown?.challenge}
|
|
170
|
+
onclick={() => decide(false)}
|
|
171
|
+
>
|
|
172
|
+
{shown?.cancelLabel ?? cancelLabel}
|
|
173
|
+
</button>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class={["btn", shown?.danger ? "btn-error" : "btn-primary"]}
|
|
177
|
+
data-testid="confirm-accept"
|
|
178
|
+
data-danger={shown?.danger ? "true" : undefined}
|
|
179
|
+
disabled={!enabled}
|
|
180
|
+
onclick={() => decide(true)}
|
|
181
|
+
>
|
|
182
|
+
{shown?.confirmLabel ?? confirmLabel}
|
|
183
|
+
</button>
|
|
184
|
+
{/snippet}
|
|
185
|
+
|
|
186
|
+
<Modal
|
|
187
|
+
bind:this={modal}
|
|
188
|
+
title={shown?.title ?? ""}
|
|
189
|
+
class={["max-w-md", className]}
|
|
190
|
+
{closeLabel}
|
|
191
|
+
onclose={handleClose}
|
|
192
|
+
children={hasBody ? body : undefined}
|
|
193
|
+
{footer}
|
|
194
|
+
/>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ClassValue } from "svelte/elements";
|
|
2
|
+
import { type ConfirmManager } from "./confirm-manager.svelte.js";
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Queue to render. Defaults to the one behind the module-level `confirm` helper. */
|
|
5
|
+
manager?: ConfirmManager;
|
|
6
|
+
/** Extra classes for the box -- a wider cap for a long name. Not for the backdrop. */
|
|
7
|
+
class?: ClassValue;
|
|
8
|
+
/**
|
|
9
|
+
* Default label of the confirm action, for apps that are not in English. A call overrides it
|
|
10
|
+
* with `confirmLabel`; this is what every call that does not gets.
|
|
11
|
+
*/
|
|
12
|
+
confirmLabel?: string;
|
|
13
|
+
/** Default label of the cancel action. Overridden per call by `cancelLabel`. */
|
|
14
|
+
cancelLabel?: string;
|
|
15
|
+
/** Accessible name of the close button, which never varies per call. */
|
|
16
|
+
closeLabel?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Labels the challenge input. A function rather than a string because the challenge itself has
|
|
19
|
+
* to appear inside the sentence, and only the app knows where its language puts it.
|
|
20
|
+
*/
|
|
21
|
+
challengeLabel?: (challenge: string) => string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Renders a `ConfirmManager`'s queue as a modal question. Mount exactly one, unconditionally, in the
|
|
25
|
+
* app's root layout, next to `Toaster` and for the same reason: the call site that asks the question
|
|
26
|
+
* is somewhere else entirely.
|
|
27
|
+
*
|
|
28
|
+
* With no `manager` prop it renders the queue behind the module-level `confirm` helper, which is what
|
|
29
|
+
* lets any module in the app write the one line this exists for:
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* if (!(await confirm({ title: `Delete ${night.name}?`, danger: true }))) return;
|
|
33
|
+
* await api.delete(`/nights/${night.id}`);
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* The library ships no translations, so the four labels here are how an app localizes every confirm
|
|
37
|
+
* at once; a single call overrides any of them through its own options.
|
|
38
|
+
*
|
|
39
|
+
* Focus never lands on the confirm action. It opens on cancel, or on the challenge input when there
|
|
40
|
+
* is one, so a stray Enter can only ever cancel -- and in the challenge input Enter confirms solely
|
|
41
|
+
* when what has been typed already matches.
|
|
42
|
+
*/
|
|
43
|
+
declare const Confirmer: import("svelte").Component<Props, {}, "">;
|
|
44
|
+
type Confirmer = ReturnType<typeof Confirmer>;
|
|
45
|
+
export default Confirmer;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a typed value satisfies a confirm challenge.
|
|
3
|
+
*
|
|
4
|
+
* Both sides are trimmed at the ends and then compared exactly: case, accents, inner spacing and
|
|
5
|
+
* punctuation all have to match. The trim is there because the string being typed is almost always
|
|
6
|
+
* a name read or copied off the row above it, and a copy picks up a trailing space far more often
|
|
7
|
+
* than anyone means to type one. Folding case or accents on top of that would give away the one
|
|
8
|
+
* thing the gate exists for — making the hand slow down over the exact name of the thing that is
|
|
9
|
+
* about to be destroyed.
|
|
10
|
+
*/
|
|
11
|
+
export declare const matchesChallenge: (typed: string, challenge: string) => boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the confirm action may be enabled for a request. A request with no challenge is always
|
|
14
|
+
* satisfied, so no caller has to branch on the option itself.
|
|
15
|
+
*/
|
|
16
|
+
export declare const challengeSatisfied: (challenge: string | undefined, typed: string) => boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a typed value satisfies a confirm challenge.
|
|
3
|
+
*
|
|
4
|
+
* Both sides are trimmed at the ends and then compared exactly: case, accents, inner spacing and
|
|
5
|
+
* punctuation all have to match. The trim is there because the string being typed is almost always
|
|
6
|
+
* a name read or copied off the row above it, and a copy picks up a trailing space far more often
|
|
7
|
+
* than anyone means to type one. Folding case or accents on top of that would give away the one
|
|
8
|
+
* thing the gate exists for — making the hand slow down over the exact name of the thing that is
|
|
9
|
+
* about to be destroyed.
|
|
10
|
+
*/
|
|
11
|
+
export const matchesChallenge = (typed, challenge) => typed.trim() === challenge.trim();
|
|
12
|
+
/**
|
|
13
|
+
* Whether the confirm action may be enabled for a request. A request with no challenge is always
|
|
14
|
+
* satisfied, so no caller has to branch on the option itself.
|
|
15
|
+
*/
|
|
16
|
+
export const challengeSatisfied = (challenge, typed) => challenge === undefined || matchesChallenge(typed, challenge);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** What a call to `confirm` asks. Everything but the title has a default. */
|
|
2
|
+
export interface ConfirmOptions {
|
|
3
|
+
/** Names the dialog for assistive technology, and heads it visually. The question being asked. */
|
|
4
|
+
title: string;
|
|
5
|
+
/**
|
|
6
|
+
* The consequence, in prose, under the title. A string rather than a snippet on purpose: the
|
|
7
|
+
* whole point of the imperative API is that the options object is plain data, so it can be built
|
|
8
|
+
* in a store, an action or a `.ts` module that has no component scope to author a snippet in.
|
|
9
|
+
* A confirm that needs markup is a `Modal` composed by hand, and that path already exists.
|
|
10
|
+
*/
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Overrides the host's `confirmLabel` for this one call. */
|
|
13
|
+
confirmLabel?: string;
|
|
14
|
+
/** Overrides the host's `cancelLabel` for this one call. */
|
|
15
|
+
cancelLabel?: string;
|
|
16
|
+
/** Styles the confirm action as destructive. Does not change any behaviour. */
|
|
17
|
+
danger?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* A stronger gate: the confirm action stays disabled until this exact string has been typed.
|
|
20
|
+
* Reach for it when the action destroys something that took a while to build and the name is on
|
|
21
|
+
* screen to be read off — a project, a group, a production database. A blank or whitespace-only
|
|
22
|
+
* value is treated as no challenge at all, so `challenge={maybeName}` is safe.
|
|
23
|
+
*/
|
|
24
|
+
challenge?: string;
|
|
25
|
+
/** Overrides the host's `challengeLabel` for this one call. Labels the challenge input. */
|
|
26
|
+
challengeLabel?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A queued question. Produced by the manager; consumed by `Confirmer`. */
|
|
29
|
+
export interface ConfirmRequest extends ConfirmOptions {
|
|
30
|
+
id: string;
|
|
31
|
+
}
|
|
32
|
+
/** What the host registers with `attach`: a nudge to put `current` on screen. */
|
|
33
|
+
export type ConfirmPresenter = () => void;
|
|
34
|
+
/**
|
|
35
|
+
* The queue behind `Confirmer`. Owns ids, ordering and the promise each call is waiting on.
|
|
36
|
+
*
|
|
37
|
+
* Most apps never touch this class: `confirm(...)` goes through the process-wide default manager
|
|
38
|
+
* and a single `<Confirmer />` near the app root renders it. Construct one explicitly to scope a
|
|
39
|
+
* queue to a subtree, or to give a test a manager it can throw away afterwards.
|
|
40
|
+
*/
|
|
41
|
+
export declare class ConfirmManager {
|
|
42
|
+
#private;
|
|
43
|
+
/** The request on screen, or null when nothing is being asked. */
|
|
44
|
+
get current(): ConfirmRequest | null;
|
|
45
|
+
/** How many calls are still waiting for an answer, the one on screen included. */
|
|
46
|
+
get pending(): number;
|
|
47
|
+
/** Ask, and resolve once the question has been answered — `true` only for the confirm action. */
|
|
48
|
+
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Answer the request on screen. The host's contract, called once the dialog has actually closed,
|
|
51
|
+
* so the awaiting caller resumes into a page with nothing on top of it.
|
|
52
|
+
*
|
|
53
|
+
* An id that is not the head is ignored, which is what makes a close event arriving after
|
|
54
|
+
* `cancelAll` harmless.
|
|
55
|
+
*/
|
|
56
|
+
settle: (id: string, confirmed: boolean) => void;
|
|
57
|
+
/**
|
|
58
|
+
* Answer everything still waiting with `false` and empty the queue. Cancel is the safe answer,
|
|
59
|
+
* so this is what sign-out, a hard navigation or the last host unmounting should do — never
|
|
60
|
+
* leaving an `await confirm(...)` pending forever.
|
|
61
|
+
*/
|
|
62
|
+
cancelAll: () => void;
|
|
63
|
+
/**
|
|
64
|
+
* Register a host. Returns the detach function, which is why `Confirmer` can hand this straight
|
|
65
|
+
* back out of an `$effect`.
|
|
66
|
+
*
|
|
67
|
+
* When the last host detaches every pending call is cancelled: a promise nobody can ever answer
|
|
68
|
+
* is worse than a `false`.
|
|
69
|
+
*/
|
|
70
|
+
attach: (present: ConfirmPresenter) => (() => void);
|
|
71
|
+
}
|
|
72
|
+
export declare const createConfirmManager: () => ConfirmManager;
|
|
73
|
+
/**
|
|
74
|
+
* The manager backing the module-level `confirm` helper.
|
|
75
|
+
*
|
|
76
|
+
* A singleton only in the browser. On the server every call hands back a fresh manager, because a
|
|
77
|
+
* module-level queue there is shared by every concurrent request.
|
|
78
|
+
*/
|
|
79
|
+
export declare const getDefaultConfirmManager: () => ConfirmManager;
|
|
80
|
+
/**
|
|
81
|
+
* Ask the question, await the answer: a one-line replacement for the browser's own `confirm()`,
|
|
82
|
+
* from anywhere, with no context to plumb and no component to mount at the call site.
|
|
83
|
+
*
|
|
84
|
+
* ```ts
|
|
85
|
+
* if (!(await confirm({ title: "Delete this night?", danger: true }))) return;
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* Resolves `true` only for the confirm action. Cancel, Escape, the close button, a click on the
|
|
89
|
+
* backdrop and the host unmounting all resolve `false`.
|
|
90
|
+
*/
|
|
91
|
+
export declare const confirm: (options: ConfirmOptions) => Promise<boolean>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { untrack } from "svelte";
|
|
2
|
+
let nextSequence = 0;
|
|
3
|
+
const createRequestId = () => `confirm-${++nextSequence}`;
|
|
4
|
+
/**
|
|
5
|
+
* The queue behind `Confirmer`. Owns ids, ordering and the promise each call is waiting on.
|
|
6
|
+
*
|
|
7
|
+
* Most apps never touch this class: `confirm(...)` goes through the process-wide default manager
|
|
8
|
+
* and a single `<Confirmer />` near the app root renders it. Construct one explicitly to scope a
|
|
9
|
+
* queue to a subtree, or to give a test a manager it can throw away afterwards.
|
|
10
|
+
*/
|
|
11
|
+
export class ConfirmManager {
|
|
12
|
+
/**
|
|
13
|
+
* Everything asked and not yet answered, oldest first. The head is the one on screen.
|
|
14
|
+
*
|
|
15
|
+
* Concurrent calls QUEUE rather than replacing or rejecting. Replacing would answer a question
|
|
16
|
+
* the user never saw, and answering it `false` is a lie about intent that a caller is free to
|
|
17
|
+
* log as "the user cancelled". Rejecting would throw at a call site written as
|
|
18
|
+
* `if (!(await confirm(...))) return;`, which is the shape this API exists to make safe. Waiting
|
|
19
|
+
* is the only rule under which no request is ever answered by anything but a person looking at
|
|
20
|
+
* it.
|
|
21
|
+
*/
|
|
22
|
+
#queue = $state([]);
|
|
23
|
+
// Plain Map and Set, not the reactive ones: nothing renders from either. The rendered surface is
|
|
24
|
+
// the queue and nothing else.
|
|
25
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
26
|
+
#resolvers = new Map();
|
|
27
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
28
|
+
#presenters = new Set();
|
|
29
|
+
/** The request on screen, or null when nothing is being asked. */
|
|
30
|
+
get current() {
|
|
31
|
+
return this.#queue[0] ?? null;
|
|
32
|
+
}
|
|
33
|
+
/** How many calls are still waiting for an answer, the one on screen included. */
|
|
34
|
+
get pending() {
|
|
35
|
+
return this.#queue.length;
|
|
36
|
+
}
|
|
37
|
+
/** Ask, and resolve once the question has been answered — `true` only for the confirm action. */
|
|
38
|
+
confirm = (options) => {
|
|
39
|
+
const request = {
|
|
40
|
+
...options,
|
|
41
|
+
challenge: options.challenge?.trim() ? options.challenge : undefined,
|
|
42
|
+
id: createRequestId(),
|
|
43
|
+
};
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
this.#resolvers.set(request.id, resolve);
|
|
46
|
+
this.#queue.push(request);
|
|
47
|
+
// Only the head is ever on screen, so a call that lands behind one waits to be presented by
|
|
48
|
+
// the `settle` that retires the request in front of it.
|
|
49
|
+
if (this.#queue.length === 1)
|
|
50
|
+
this.#present();
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Answer the request on screen. The host's contract, called once the dialog has actually closed,
|
|
55
|
+
* so the awaiting caller resumes into a page with nothing on top of it.
|
|
56
|
+
*
|
|
57
|
+
* An id that is not the head is ignored, which is what makes a close event arriving after
|
|
58
|
+
* `cancelAll` harmless.
|
|
59
|
+
*/
|
|
60
|
+
settle = (id, confirmed) => {
|
|
61
|
+
if (this.#queue[0]?.id !== id)
|
|
62
|
+
return;
|
|
63
|
+
this.#queue.shift();
|
|
64
|
+
this.#resolvers.get(id)?.(confirmed);
|
|
65
|
+
this.#resolvers.delete(id);
|
|
66
|
+
if (this.#queue.length > 0)
|
|
67
|
+
this.#present();
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Answer everything still waiting with `false` and empty the queue. Cancel is the safe answer,
|
|
71
|
+
* so this is what sign-out, a hard navigation or the last host unmounting should do — never
|
|
72
|
+
* leaving an `await confirm(...)` pending forever.
|
|
73
|
+
*/
|
|
74
|
+
cancelAll = () => {
|
|
75
|
+
const dropped = this.#queue;
|
|
76
|
+
this.#queue = [];
|
|
77
|
+
for (const request of dropped) {
|
|
78
|
+
this.#resolvers.get(request.id)?.(false);
|
|
79
|
+
this.#resolvers.delete(request.id);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Register a host. Returns the detach function, which is why `Confirmer` can hand this straight
|
|
84
|
+
* back out of an `$effect`.
|
|
85
|
+
*
|
|
86
|
+
* When the last host detaches every pending call is cancelled: a promise nobody can ever answer
|
|
87
|
+
* is worse than a `false`.
|
|
88
|
+
*/
|
|
89
|
+
attach = (present) => {
|
|
90
|
+
this.#presenters.add(present);
|
|
91
|
+
// Read without subscribing. `attach` is called from the host's `$effect`, and a dependency on
|
|
92
|
+
// the queue there would tear the host down and rebuild it on every question asked -- and the
|
|
93
|
+
// teardown cancels the very queue it had just read.
|
|
94
|
+
if (untrack(() => this.#queue.length) > 0)
|
|
95
|
+
this.#present();
|
|
96
|
+
return () => {
|
|
97
|
+
this.#presenters.delete(present);
|
|
98
|
+
if (this.#presenters.size === 0)
|
|
99
|
+
this.cancelAll();
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Deferred by a microtask, always. Two of the three callers are already inside something that
|
|
104
|
+
* must not be re-entered: `attach` runs inside the host's `$effect`, where the synchronous flush
|
|
105
|
+
* that raises a `<dialog>` is an error, and `settle` runs inside the previous dialog's own close
|
|
106
|
+
* event. Deferring uniformly is one rule instead of three, and a microtask is invisible.
|
|
107
|
+
*/
|
|
108
|
+
#present() {
|
|
109
|
+
queueMicrotask(() => {
|
|
110
|
+
if (this.#queue.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
for (const present of this.#presenters)
|
|
113
|
+
present();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export const createConfirmManager = () => new ConfirmManager();
|
|
118
|
+
let browserManager = null;
|
|
119
|
+
/**
|
|
120
|
+
* The manager backing the module-level `confirm` helper.
|
|
121
|
+
*
|
|
122
|
+
* A singleton only in the browser. On the server every call hands back a fresh manager, because a
|
|
123
|
+
* module-level queue there is shared by every concurrent request.
|
|
124
|
+
*/
|
|
125
|
+
export const getDefaultConfirmManager = () => {
|
|
126
|
+
if (typeof document === "undefined")
|
|
127
|
+
return createConfirmManager();
|
|
128
|
+
browserManager ??= createConfirmManager();
|
|
129
|
+
return browserManager;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Ask the question, await the answer: a one-line replacement for the browser's own `confirm()`,
|
|
133
|
+
* from anywhere, with no context to plumb and no component to mount at the call site.
|
|
134
|
+
*
|
|
135
|
+
* ```ts
|
|
136
|
+
* if (!(await confirm({ title: "Delete this night?", danger: true }))) return;
|
|
137
|
+
* ```
|
|
138
|
+
*
|
|
139
|
+
* Resolves `true` only for the confirm action. Cancel, Escape, the close button, a click on the
|
|
140
|
+
* backdrop and the host unmounting all resolve `false`.
|
|
141
|
+
*/
|
|
142
|
+
export const confirm = (options) => {
|
|
143
|
+
// There is nobody to ask on the server, and "no" is the only safe answer to a question about
|
|
144
|
+
// destroying something. Answering rather than queueing is also what keeps a confirm reached
|
|
145
|
+
// during SSR from hanging the render forever.
|
|
146
|
+
if (typeof document === "undefined")
|
|
147
|
+
return Promise.resolve(false);
|
|
148
|
+
return getDefaultConfirmManager().confirm(options);
|
|
149
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { challengeSatisfied, matchesChallenge } from "./challenge.js";
|
|
2
|
+
export { confirm, ConfirmManager, createConfirmManager, getDefaultConfirmManager, type ConfirmOptions, type ConfirmPresenter, type ConfirmRequest, } from "./confirm-manager.svelte.js";
|
|
3
|
+
export { default as Confirmer } from "./Confirmer.svelte";
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Anything that can receive an unexpected error: a console, a telemetry client, a test spy. */
|
|
2
|
+
export type ErrorReporter = (error: unknown) => void;
|
|
3
|
+
/**
|
|
4
|
+
* Install the app's reporter — Sentry, a logging endpoint, whatever the app already has.
|
|
5
|
+
*
|
|
6
|
+
* This is the whole point of routing reports through one function: the library and every app
|
|
7
|
+
* component keep calling `reportError`, and only the app decides where that lands. Passing `null`
|
|
8
|
+
* restores the console default, which is also what a test should do on teardown.
|
|
9
|
+
*/
|
|
10
|
+
export declare const setErrorReporter: (next: ErrorReporter | null) => void;
|
|
11
|
+
/**
|
|
12
|
+
* The single reporting channel for unexpected errors.
|
|
13
|
+
*
|
|
14
|
+
* Wire it at catch points — `svelte:boundary`'s `onerror`, SvelteKit's `handleError` hooks, a
|
|
15
|
+
* handler that swallows a rejection — and never inside a component that merely *displays* a
|
|
16
|
+
* failure, or the same error gets reported once per render.
|
|
17
|
+
*
|
|
18
|
+
* ```svelte
|
|
19
|
+
* <svelte:boundary onerror={reportError}>
|
|
20
|
+
* {@render children()}
|
|
21
|
+
* {#snippet failed(_error, reset)}<ErrorDisplay {reset} />{/snippet}
|
|
22
|
+
* </svelte:boundary>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare const reportError: (error: unknown) => void;
|
package/dist/error.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const consoleReporter = (error) => {
|
|
2
|
+
console.error(error);
|
|
3
|
+
};
|
|
4
|
+
let reporter = consoleReporter;
|
|
5
|
+
/**
|
|
6
|
+
* Install the app's reporter — Sentry, a logging endpoint, whatever the app already has.
|
|
7
|
+
*
|
|
8
|
+
* This is the whole point of routing reports through one function: the library and every app
|
|
9
|
+
* component keep calling `reportError`, and only the app decides where that lands. Passing `null`
|
|
10
|
+
* restores the console default, which is also what a test should do on teardown.
|
|
11
|
+
*/
|
|
12
|
+
export const setErrorReporter = (next) => {
|
|
13
|
+
reporter = next ?? consoleReporter;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The single reporting channel for unexpected errors.
|
|
17
|
+
*
|
|
18
|
+
* Wire it at catch points — `svelte:boundary`'s `onerror`, SvelteKit's `handleError` hooks, a
|
|
19
|
+
* handler that swallows a rejection — and never inside a component that merely *displays* a
|
|
20
|
+
* failure, or the same error gets reported once per render.
|
|
21
|
+
*
|
|
22
|
+
* ```svelte
|
|
23
|
+
* <svelte:boundary onerror={reportError}>
|
|
24
|
+
* {@render children()}
|
|
25
|
+
* {#snippet failed(_error, reset)}<ErrorDisplay {reset} />{/snippet}
|
|
26
|
+
* </svelte:boundary>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export const reportError = (error) => {
|
|
30
|
+
try {
|
|
31
|
+
reporter(error);
|
|
32
|
+
}
|
|
33
|
+
catch (reporterFailure) {
|
|
34
|
+
// A reporter that throws would replace the original failure with its own, inside a handler
|
|
35
|
+
// whose job was to contain a crash. The original error is the one worth keeping.
|
|
36
|
+
consoleReporter(error);
|
|
37
|
+
consoleReporter(reporterFailure);
|
|
38
|
+
}
|
|
39
|
+
};
|