firstly 0.5.1 → 0.6.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/CHANGELOG.md +20 -0
- package/esm/core/FF_Filter.d.ts +2 -2
- package/esm/core/FF_Filter.js +2 -2
- package/esm/core/FF_Validators.d.ts +2 -0
- package/esm/core/FF_Validators.js +8 -10
- package/esm/core/containsWords.d.ts +2 -2
- package/esm/core/containsWords.js +2 -2
- package/esm/core/tailwind.d.ts +3 -4
- package/esm/core/tailwind.js +3 -4
- package/esm/svelte/DemoForm.svelte +121 -0
- package/esm/svelte/DemoForm.svelte.d.ts +42 -0
- package/esm/svelte/DemoGrid.svelte +146 -55
- package/esm/svelte/DemoGrid.svelte.d.ts +10 -1
- package/esm/svelte/DialogOpenTest.svelte +10 -0
- package/esm/svelte/DialogOpenTest.svelte.d.ts +8 -0
- package/esm/svelte/FF_Config.svelte +13 -0
- package/esm/svelte/FF_Config.svelte.d.ts +3 -0
- package/esm/svelte/FF_Config.svelte.js +38 -0
- package/esm/svelte/FF_DialogManager.svelte +251 -0
- package/esm/svelte/FF_DialogManager.svelte.d.ts +13 -0
- package/esm/svelte/FF_PromptDefault.svelte +85 -0
- package/esm/svelte/FF_PromptDefault.svelte.d.ts +9 -0
- package/esm/svelte/FF_ToastHtml.svelte +9 -0
- package/esm/svelte/FF_ToastHtml.svelte.d.ts +6 -0
- package/esm/svelte/FF_ToastManager.svelte +22 -0
- package/esm/svelte/FF_ToastManager.svelte.d.ts +4 -0
- package/esm/svelte/dialog.svelte.d.ts +209 -0
- package/esm/svelte/dialog.svelte.js +243 -0
- package/esm/svelte/ff.svelte.d.ts +294 -0
- package/esm/svelte/ff.svelte.js +599 -0
- package/esm/svelte/index.d.ts +13 -2
- package/esm/svelte/index.js +8 -1
- package/esm/svelte/infiniteScroll.d.ts +1 -1
- package/esm/svelte/infiniteScroll.js +1 -1
- package/esm/svelte/toast.d.ts +59 -0
- package/esm/svelte/toast.js +92 -0
- package/esm/virtual/StateDemoEnum.js +1 -1
- package/package.json +2 -1
- package/esm/svelte/FF_Repo.svelte.d.ts +0 -198
- package/esm/svelte/FF_Repo.svelte.js +0 -305
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
const KEY = Symbol('ff-config');
|
|
3
|
+
/** firstly's fallback labels, used when neither a call-site label nor `<FF_Config>` provides one. */
|
|
4
|
+
const BUILTIN_MESSAGES = {
|
|
5
|
+
confirm: 'Confirm',
|
|
6
|
+
cancel: 'Cancel',
|
|
7
|
+
ok: 'OK',
|
|
8
|
+
toast: { success: 'Success', error: 'Error', info: 'Info', warning: 'Warning' },
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Provide config to descendants. Takes a **getter** (not a snapshot) so reads stay reactive -
|
|
12
|
+
* `<FF_Config>` calls it with `() => ({ messages, dialog })` over its own props.
|
|
13
|
+
*/
|
|
14
|
+
export function setFFConfig(get) {
|
|
15
|
+
setContext(KEY, get);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Read the nearest config, merged over firstly's built-ins. Call during component init; the
|
|
19
|
+
* returned object exposes **getters**, so reading them in markup re-invokes the provider and
|
|
20
|
+
* stays reactive (and locale-correct, since labels are message functions resolved at render).
|
|
21
|
+
*/
|
|
22
|
+
export function ffConfig() {
|
|
23
|
+
const get = getContext(KEY);
|
|
24
|
+
return {
|
|
25
|
+
get messages() {
|
|
26
|
+
const m = get?.().messages;
|
|
27
|
+
// Deep-merge the `toast` sub-object so a partial override (e.g. just `error`)
|
|
28
|
+
// keeps the other built-in titles.
|
|
29
|
+
return { ...BUILTIN_MESSAGES, ...m, toast: { ...BUILTIN_MESSAGES.toast, ...m?.toast } };
|
|
30
|
+
},
|
|
31
|
+
get dialog() {
|
|
32
|
+
return get?.().dialog ?? {};
|
|
33
|
+
},
|
|
34
|
+
get toast() {
|
|
35
|
+
return get?.().toast ?? {};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
import { fade } from 'svelte/transition'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
dialog,
|
|
7
|
+
ffAutofocus,
|
|
8
|
+
ffTrapFocus,
|
|
9
|
+
resolveMessage,
|
|
10
|
+
type DialogClose,
|
|
11
|
+
type DialogConfirmArgs,
|
|
12
|
+
type DialogPromptArgs,
|
|
13
|
+
type DialogShellArgs,
|
|
14
|
+
} from './dialog.svelte.js'
|
|
15
|
+
import { ffConfig } from './FF_Config.svelte.js'
|
|
16
|
+
import FF_PromptDefault from './FF_PromptDefault.svelte'
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
shell,
|
|
20
|
+
confirm,
|
|
21
|
+
prompt,
|
|
22
|
+
}: {
|
|
23
|
+
/** Override the dialog frame. Omit to fall back to `<FF_Config>`, then the built-in default. */
|
|
24
|
+
shell?: Snippet<[DialogShellArgs]>
|
|
25
|
+
/** Override the confirm UI. Omit to fall back to `<FF_Config>`, then the built-in default. */
|
|
26
|
+
confirm?: Snippet<[DialogConfirmArgs]>
|
|
27
|
+
/** Override the prompt UI. Omit to fall back to `<FF_Config>`, then the built-in default. */
|
|
28
|
+
prompt?: Snippet<[DialogPromptArgs]>
|
|
29
|
+
} = $props()
|
|
30
|
+
|
|
31
|
+
// App-wide config (labels + skin) from the nearest `<FF_Config>`; precedence is
|
|
32
|
+
// explicit prop > FF_Config > built-in. Read once at init; its getters stay reactive.
|
|
33
|
+
const cfg = ffConfig()
|
|
34
|
+
|
|
35
|
+
const total = $derived(dialog.list.length + dialog.confirmList.length + dialog.promptList.length)
|
|
36
|
+
// Highest id across all kinds = the most-recently-opened (topmost) item.
|
|
37
|
+
const topId = $derived(
|
|
38
|
+
Math.max(
|
|
39
|
+
dialog.list.at(-1)?.id ?? 0,
|
|
40
|
+
dialog.confirmList.at(-1)?.id ?? 0,
|
|
41
|
+
dialog.promptList.at(-1)?.id ?? 0,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
// Esc dismisses the topmost item, whatever kind it is.
|
|
46
|
+
function onKeydown(e: KeyboardEvent) {
|
|
47
|
+
if (e.key !== 'Escape' || total === 0) return
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
if ((dialog.promptList.at(-1)?.id ?? 0) === topId) dialog.dismissTopPrompt()
|
|
50
|
+
else if ((dialog.confirmList.at(-1)?.id ?? 0) === topId) dialog.dismissTopConfirm()
|
|
51
|
+
else dialog.dismissTop()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Lock body scroll while anything is open.
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (total === 0) return
|
|
57
|
+
const prev = document.body.style.overflow
|
|
58
|
+
document.body.style.overflow = 'hidden'
|
|
59
|
+
return () => {
|
|
60
|
+
document.body.style.overflow = prev
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Snapshot the element focused before the first dialog opened, and restore focus to it once
|
|
65
|
+
// everything is closed (otherwise focus falls back to <body>). The `ffAutofocus`/`ffTrapFocus`
|
|
66
|
+
// actions on each panel own focus WHILE open; this only fires the restore on the 0-open edge.
|
|
67
|
+
// SSR-safe (effects don't run on the server).
|
|
68
|
+
let restoreTo: HTMLElement | null = null
|
|
69
|
+
$effect(() => {
|
|
70
|
+
if (typeof document === 'undefined') return
|
|
71
|
+
if (total > 0 && restoreTo === null) {
|
|
72
|
+
restoreTo = document.activeElement as HTMLElement | null
|
|
73
|
+
} else if (total === 0 && restoreTo !== null) {
|
|
74
|
+
const el = restoreTo
|
|
75
|
+
restoreTo = null
|
|
76
|
+
if (el.isConnected) el.focus()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Mark the app root `inert` (+ aria-hidden) while any dialog is open, so the background can't
|
|
81
|
+
// be tabbed into or read by AT. The dialog panels render at the document body level (a sibling
|
|
82
|
+
// of the app root), so inerting the root never touches the panels. SSR-safe.
|
|
83
|
+
$effect(() => {
|
|
84
|
+
if (typeof document === 'undefined' || total === 0) return
|
|
85
|
+
const root = document.querySelector<HTMLElement>(
|
|
86
|
+
'[data-sveltekit-root], #svelte, body > div:first-child',
|
|
87
|
+
)
|
|
88
|
+
if (!root || root.contains(document.activeElement)) {
|
|
89
|
+
// Fallback: no identifiable single root, or the dialog itself lives under it - skip
|
|
90
|
+
// inert (the per-panel focus trap still contains keyboard navigation).
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
root.setAttribute('inert', '')
|
|
94
|
+
root.setAttribute('aria-hidden', 'true')
|
|
95
|
+
return () => {
|
|
96
|
+
root.removeAttribute('inert')
|
|
97
|
+
root.removeAttribute('aria-hidden')
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const widthClass = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-3xl' }
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<svelte:window onkeydown={onKeydown} />
|
|
105
|
+
|
|
106
|
+
{#each dialog.list as d (d.id)}
|
|
107
|
+
{#snippet itemBody(close: DialogClose)}
|
|
108
|
+
{#if d.render.kind === 'component'}
|
|
109
|
+
{@const Comp = d.render.component}
|
|
110
|
+
<Comp {...d.render.props} {close} />
|
|
111
|
+
{:else}
|
|
112
|
+
{@render d.render.body(close)}
|
|
113
|
+
{/if}
|
|
114
|
+
{/snippet}
|
|
115
|
+
{@render (shell ?? cfg.dialog.shell ?? defaultShell)({
|
|
116
|
+
id: d.id,
|
|
117
|
+
body: itemBody,
|
|
118
|
+
close: (r) => dialog._close(d.id, r),
|
|
119
|
+
dismiss: () => dialog.requestClose(d.id),
|
|
120
|
+
dismissible: d.options.dismissible,
|
|
121
|
+
width: d.options.width,
|
|
122
|
+
isTop: d.id === topId,
|
|
123
|
+
})}
|
|
124
|
+
{/each}
|
|
125
|
+
|
|
126
|
+
{#each dialog.confirmList as c (c.id)}
|
|
127
|
+
{@render (confirm ?? cfg.dialog.confirm ?? defaultConfirm)({
|
|
128
|
+
id: c.id,
|
|
129
|
+
message: resolveMessage(c.message),
|
|
130
|
+
title: c.title === undefined ? undefined : resolveMessage(c.title),
|
|
131
|
+
confirmLabel: resolveMessage(c.confirmLabel ?? cfg.messages.confirm),
|
|
132
|
+
cancelLabel: resolveMessage(c.cancelLabel ?? cfg.messages.cancel),
|
|
133
|
+
danger: c.danger,
|
|
134
|
+
confirm: () => dialog._resolveConfirm(c.id, true),
|
|
135
|
+
cancel: () => dialog._resolveConfirm(c.id, false),
|
|
136
|
+
isTop: c.id === topId,
|
|
137
|
+
})}
|
|
138
|
+
{/each}
|
|
139
|
+
|
|
140
|
+
{#each dialog.promptList as p (p.id)}
|
|
141
|
+
{@const promptUi = prompt ?? cfg.dialog.prompt}
|
|
142
|
+
{#if promptUi}
|
|
143
|
+
{@render promptUi({
|
|
144
|
+
id: p.id,
|
|
145
|
+
title: p.title === undefined ? undefined : resolveMessage(p.title),
|
|
146
|
+
label: p.label === undefined ? undefined : resolveMessage(p.label),
|
|
147
|
+
placeholder: p.placeholder,
|
|
148
|
+
initial: p.initial,
|
|
149
|
+
confirmLabel: resolveMessage(p.confirmLabel ?? cfg.messages.ok),
|
|
150
|
+
cancelLabel: resolveMessage(p.cancelLabel ?? cfg.messages.cancel),
|
|
151
|
+
submit: (value) => dialog._resolvePrompt(p.id, value),
|
|
152
|
+
cancel: () => dialog._resolvePrompt(p.id, null),
|
|
153
|
+
})}
|
|
154
|
+
{:else}
|
|
155
|
+
<FF_PromptDefault
|
|
156
|
+
item={p}
|
|
157
|
+
onsubmit={(value) => dialog._resolvePrompt(p.id, value)}
|
|
158
|
+
oncancel={() => dialog._resolvePrompt(p.id, null)}
|
|
159
|
+
/>
|
|
160
|
+
{/if}
|
|
161
|
+
{/each}
|
|
162
|
+
|
|
163
|
+
<!-- Built-in defaults: usable with zero config and theme-adaptive via semantic tokens
|
|
164
|
+
(background/card/foreground/border/primary/muted/destructive). Pass `shell`/`confirm`/`prompt`
|
|
165
|
+
to fully restyle. -->
|
|
166
|
+
{#snippet defaultShell({ body, close, dismiss, dismissible, width }: DialogShellArgs)}
|
|
167
|
+
<div
|
|
168
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
169
|
+
transition:fade={{ duration: 120 }}
|
|
170
|
+
>
|
|
171
|
+
<button type="button" aria-label="Close" class="absolute inset-0 bg-black/50" onclick={dismiss}
|
|
172
|
+
></button>
|
|
173
|
+
<div
|
|
174
|
+
use:ffAutofocus
|
|
175
|
+
use:ffTrapFocus
|
|
176
|
+
role="dialog"
|
|
177
|
+
aria-modal="true"
|
|
178
|
+
tabindex="-1"
|
|
179
|
+
class="bg-background text-foreground border-border relative z-[1] max-h-[90vh] w-full {widthClass[
|
|
180
|
+
width
|
|
181
|
+
]} overflow-auto rounded-lg border p-5 shadow-xl"
|
|
182
|
+
>
|
|
183
|
+
{@render body(close)}
|
|
184
|
+
<!-- Rendered after the body (but absolute top-right) so autofocus lands on the
|
|
185
|
+
body's first input, and the close button is last in tab order. -->
|
|
186
|
+
{#if dismissible}
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
aria-label="Close"
|
|
190
|
+
class="text-muted-foreground hover:text-foreground absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-md"
|
|
191
|
+
onclick={dismiss}
|
|
192
|
+
>
|
|
193
|
+
✕
|
|
194
|
+
</button>
|
|
195
|
+
{/if}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
{/snippet}
|
|
199
|
+
|
|
200
|
+
{#snippet defaultConfirm({
|
|
201
|
+
id,
|
|
202
|
+
message,
|
|
203
|
+
title,
|
|
204
|
+
confirmLabel,
|
|
205
|
+
cancelLabel,
|
|
206
|
+
danger,
|
|
207
|
+
confirm: onConfirm,
|
|
208
|
+
cancel,
|
|
209
|
+
}: DialogConfirmArgs)}
|
|
210
|
+
<div
|
|
211
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
212
|
+
transition:fade={{ duration: 120 }}
|
|
213
|
+
>
|
|
214
|
+
<button type="button" aria-label="Cancel" class="absolute inset-0 bg-black/50" onclick={cancel}
|
|
215
|
+
></button>
|
|
216
|
+
<div
|
|
217
|
+
use:ffAutofocus
|
|
218
|
+
use:ffTrapFocus
|
|
219
|
+
role="alertdialog"
|
|
220
|
+
aria-modal="true"
|
|
221
|
+
tabindex="-1"
|
|
222
|
+
aria-labelledby={title ? `ff-dlg-title-${id}` : undefined}
|
|
223
|
+
aria-label={title ? undefined : message}
|
|
224
|
+
aria-describedby="ff-dlg-desc-{id}"
|
|
225
|
+
class="bg-background text-foreground border-border relative z-[1] w-full max-w-sm rounded-lg border p-5 shadow-xl"
|
|
226
|
+
>
|
|
227
|
+
{#if title}
|
|
228
|
+
<h2 id="ff-dlg-title-{id}" class="mb-2 text-lg font-semibold">{title}</h2>
|
|
229
|
+
{/if}
|
|
230
|
+
<p id="ff-dlg-desc-{id}" class="text-sm whitespace-pre-line">{message}</p>
|
|
231
|
+
<div class="mt-5 flex justify-end gap-2">
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
class="border-border hover:bg-muted rounded-md border px-3 py-1.5 text-sm"
|
|
235
|
+
onclick={cancel}
|
|
236
|
+
>
|
|
237
|
+
{cancelLabel}
|
|
238
|
+
</button>
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
class="{danger
|
|
242
|
+
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
|
243
|
+
: 'bg-primary text-primary-foreground hover:bg-primary/90'} rounded-md px-3 py-1.5 text-sm font-medium"
|
|
244
|
+
onclick={onConfirm}
|
|
245
|
+
>
|
|
246
|
+
{confirmLabel}
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
{/snippet}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type DialogConfirmArgs, type DialogPromptArgs, type DialogShellArgs } from './dialog.svelte.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
/** Override the dialog frame. Omit to fall back to `<FF_Config>`, then the built-in default. */
|
|
5
|
+
shell?: Snippet<[DialogShellArgs]>;
|
|
6
|
+
/** Override the confirm UI. Omit to fall back to `<FF_Config>`, then the built-in default. */
|
|
7
|
+
confirm?: Snippet<[DialogConfirmArgs]>;
|
|
8
|
+
/** Override the prompt UI. Omit to fall back to `<FF_Config>`, then the built-in default. */
|
|
9
|
+
prompt?: Snippet<[DialogPromptArgs]>;
|
|
10
|
+
};
|
|
11
|
+
declare const FFDialogManager: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type FFDialogManager = ReturnType<typeof FFDialogManager>;
|
|
13
|
+
export default FFDialogManager;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte'
|
|
3
|
+
import { fade } from 'svelte/transition'
|
|
4
|
+
|
|
5
|
+
import { ffAutofocus, ffTrapFocus, resolveMessage, type PromptItem } from './dialog.svelte.js'
|
|
6
|
+
import { ffConfig } from './FF_Config.svelte.js'
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
item,
|
|
10
|
+
onsubmit,
|
|
11
|
+
oncancel,
|
|
12
|
+
}: {
|
|
13
|
+
item: PromptItem
|
|
14
|
+
onsubmit: (value: string) => void
|
|
15
|
+
oncancel: () => void
|
|
16
|
+
} = $props()
|
|
17
|
+
|
|
18
|
+
// Labels fall back to `<FF_Config>` (then the built-in) when the call site omits them.
|
|
19
|
+
const cfg = ffConfig()
|
|
20
|
+
|
|
21
|
+
// Seed once from the (per-instance, keyed) item; the user then edits `value` freely.
|
|
22
|
+
let value = $state(untrack(() => item.initial))
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div
|
|
26
|
+
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
27
|
+
transition:fade={{ duration: 120 }}
|
|
28
|
+
>
|
|
29
|
+
<button type="button" aria-label="Cancel" class="absolute inset-0 bg-black/50" onclick={oncancel}
|
|
30
|
+
></button>
|
|
31
|
+
<!-- role/aria-modal live on this panel div (a `<form>` can't carry an interactive role); the
|
|
32
|
+
form inside handles submit. ffTrapFocus keeps Tab within the panel. -->
|
|
33
|
+
<div
|
|
34
|
+
use:ffAutofocus
|
|
35
|
+
use:ffTrapFocus
|
|
36
|
+
role="dialog"
|
|
37
|
+
aria-modal="true"
|
|
38
|
+
tabindex="-1"
|
|
39
|
+
aria-labelledby={item.title ? `ff-prompt-title-${item.id}` : undefined}
|
|
40
|
+
aria-label={item.title ? undefined : item.label ? resolveMessage(item.label) : undefined}
|
|
41
|
+
class="bg-background text-foreground border-border relative z-[1] w-full max-w-sm rounded-lg border p-5 shadow-xl"
|
|
42
|
+
>
|
|
43
|
+
<form
|
|
44
|
+
onsubmit={(e) => {
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
onsubmit(value.trim())
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{#if item.title}
|
|
50
|
+
<h2 id="ff-prompt-title-{item.id}" class="mb-2 text-lg font-semibold">
|
|
51
|
+
{resolveMessage(item.title)}
|
|
52
|
+
</h2>
|
|
53
|
+
{/if}
|
|
54
|
+
{#if item.label}
|
|
55
|
+
<label class="text-muted-foreground mb-1 block text-sm" for="ff-prompt-{item.id}">
|
|
56
|
+
{resolveMessage(item.label)}
|
|
57
|
+
</label>
|
|
58
|
+
{/if}
|
|
59
|
+
<input
|
|
60
|
+
id="ff-prompt-{item.id}"
|
|
61
|
+
bind:value
|
|
62
|
+
placeholder={item.placeholder}
|
|
63
|
+
class="border-border bg-card focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
64
|
+
/>
|
|
65
|
+
{#if item.hint}
|
|
66
|
+
<p class="text-muted-foreground mt-1 text-xs">{item.hint(value)}</p>
|
|
67
|
+
{/if}
|
|
68
|
+
<div class="mt-5 flex justify-end gap-2">
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
class="border-border hover:bg-muted rounded-md border px-3 py-1.5 text-sm"
|
|
72
|
+
onclick={oncancel}
|
|
73
|
+
>
|
|
74
|
+
{resolveMessage(item.cancelLabel ?? cfg.messages.cancel)}
|
|
75
|
+
</button>
|
|
76
|
+
<button
|
|
77
|
+
type="submit"
|
|
78
|
+
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1.5 text-sm font-medium"
|
|
79
|
+
>
|
|
80
|
+
{resolveMessage(item.confirmLabel ?? cfg.messages.ok)}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</form>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PromptItem } from './dialog.svelte.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
item: PromptItem;
|
|
4
|
+
onsubmit: (value: string) => void;
|
|
5
|
+
oncancel: () => void;
|
|
6
|
+
};
|
|
7
|
+
declare const FFPromptDefault: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type FFPromptDefault = ReturnType<typeof FFPromptDefault>;
|
|
9
|
+
export default FFPromptDefault;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Internal: renders a toast description as HTML so callers can pass markup
|
|
3
|
+
// (`<b>`, `<br>`, links, …). svelte-sonner renders a non-string `description`
|
|
4
|
+
// as `<Description {...componentProps} />`, so we receive `html` via props.
|
|
5
|
+
let { html }: { html: string } = $props()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
9
|
+
{@html html}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte'
|
|
3
|
+
import { Toaster, type ToasterProps } from 'svelte-sonner'
|
|
4
|
+
|
|
5
|
+
import { resolveMessage } from '../core/FF_Validators.js'
|
|
6
|
+
import { ffConfig } from './FF_Config.svelte.js'
|
|
7
|
+
import { _setToastTitleResolver } from './toast.js'
|
|
8
|
+
|
|
9
|
+
// Explicit props win over <FF_Config> toast over firstly defaults.
|
|
10
|
+
let props: Partial<ToasterProps> = $props()
|
|
11
|
+
const cfg = ffConfig()
|
|
12
|
+
|
|
13
|
+
// Bridge <FF_Config>'s per-kind titles into the (module-level) `toast` fn. Client-only:
|
|
14
|
+
// toasts never fire during SSR, so this keeps no request-shared state on the server. The
|
|
15
|
+
// closure re-reads config at toast time, so locale-aware message functions resolve fresh.
|
|
16
|
+
onMount(() => {
|
|
17
|
+
_setToastTitleResolver((kind) => resolveMessage(cfg.messages.toast[kind]))
|
|
18
|
+
})
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<!-- Precedence (later wins): firstly defaults → <FF_Config> toast → explicit props. -->
|
|
22
|
+
<Toaster richColors position="top-right" {...cfg.toast} {...props} />
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { Component, ComponentProps, Snippet } from 'svelte';
|
|
2
|
+
import type { LocalizedMessage } from '../core/FF_Validators.js';
|
|
3
|
+
export { resolveMessage } from '../core/FF_Validators.js';
|
|
4
|
+
/**
|
|
5
|
+
* `dialog` - firstly's headless async dialog layer (Svelte 5 runes).
|
|
6
|
+
*
|
|
7
|
+
* It owns the *logic* only - the queue, the async resolution, the dismissal rules. It ships
|
|
8
|
+
* **no markup**: mount `<FF_DialogManager>` once and give it your own `shell` / `confirm` /
|
|
9
|
+
* `prompt` snippets, so each app styles the dialog however it likes (Tailwind or otherwise)
|
|
10
|
+
* while sharing this behaviour. The dialog body is a snippet receiving a `close(result?)` callback.
|
|
11
|
+
*
|
|
12
|
+
* One result contract for all three: `show` / `confirm` / `prompt` resolve a `DialogResult`
|
|
13
|
+
* (`{ ok: true, data } | { ok: false }`). `confirm` carries no `data` (read `.ok`); `prompt`'s
|
|
14
|
+
* `data` is the trimmed string. So `{ ok }` always means "went through" and `ok: false` means
|
|
15
|
+
* cancelled/dismissed - no per-method `boolean` / `null` special-casing.
|
|
16
|
+
*
|
|
17
|
+
* ```svelte
|
|
18
|
+
* import { dialog } from './'
|
|
19
|
+
*
|
|
20
|
+
* {#snippet body(close)}
|
|
21
|
+
* <input bind:value={name} />
|
|
22
|
+
* <button onclick={() => close({ ok: true, data: { name } })}>OK</button>
|
|
23
|
+
* <button onclick={() => close()}>Cancel</button>
|
|
24
|
+
* {/snippet}
|
|
25
|
+
*
|
|
26
|
+
* const r = await dialog.show(body, { dismissible: true })
|
|
27
|
+
* if (r.ok) { ...r.data }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The unified result of every `dialog.*` call. `{ ok: true, data }` = went through (confirmed /
|
|
32
|
+
* submitted), `{ ok: false }` = cancelled or dismissed. `confirm` uses `DialogResult<void>` (no
|
|
33
|
+
* meaningful `data` - read `.ok`); `prompt` is `DialogResult<string>`; `show<T>` is `DialogResult<T>`.
|
|
34
|
+
*/
|
|
35
|
+
export type DialogResult<T = void> = {
|
|
36
|
+
ok: true;
|
|
37
|
+
data: T;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
};
|
|
41
|
+
export type DialogClose<T = unknown> = (result?: {
|
|
42
|
+
ok: true;
|
|
43
|
+
data: T;
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
} | T) => void;
|
|
47
|
+
export type DialogOptions = {
|
|
48
|
+
/** Allow closing via Escape / backdrop / close button. Default true. */
|
|
49
|
+
dismissible?: boolean;
|
|
50
|
+
/** Hook to confirm closure (e.g. dirty-form check). Return false to keep it open. */
|
|
51
|
+
allowClose?: () => boolean | Promise<boolean>;
|
|
52
|
+
/** Width preset, passed through to your shell snippet. */
|
|
53
|
+
width?: 'sm' | 'md' | 'lg';
|
|
54
|
+
};
|
|
55
|
+
/** How a `show`/`open` dialog body is rendered: an inline snippet, or a component + props. */
|
|
56
|
+
export type DialogRender = {
|
|
57
|
+
kind: 'snippet';
|
|
58
|
+
body: Snippet<[DialogClose]>;
|
|
59
|
+
} | {
|
|
60
|
+
kind: 'component';
|
|
61
|
+
component: Component<any>;
|
|
62
|
+
props: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
/** Infer the close-data type a component resolves, from its `close: DialogClose<T>` prop. */
|
|
65
|
+
export type DialogDataOf<C extends Component<any>> = ComponentProps<C> extends {
|
|
66
|
+
close?: DialogClose<infer T>;
|
|
67
|
+
} ? T : unknown;
|
|
68
|
+
export type DialogItem = {
|
|
69
|
+
id: number;
|
|
70
|
+
render: DialogRender;
|
|
71
|
+
options: Required<Pick<DialogOptions, 'dismissible' | 'width'>> & Pick<DialogOptions, 'allowClose'>;
|
|
72
|
+
resolve: (r: DialogResult<unknown>) => void;
|
|
73
|
+
};
|
|
74
|
+
/** Confirms are rendered by `FF_DialogManager` via your `confirm` snippet - no body to write, just a string. */
|
|
75
|
+
export type ConfirmItem = {
|
|
76
|
+
id: number;
|
|
77
|
+
message: LocalizedMessage;
|
|
78
|
+
title?: LocalizedMessage;
|
|
79
|
+
/** Omitted = fall back to `<FF_Config>`'s `messages.confirm` (then the built-in). */
|
|
80
|
+
confirmLabel?: LocalizedMessage;
|
|
81
|
+
/** Omitted = fall back to `<FF_Config>`'s `messages.cancel` (then the built-in). */
|
|
82
|
+
cancelLabel?: LocalizedMessage;
|
|
83
|
+
/** Style the confirm action as destructive. */
|
|
84
|
+
danger: boolean;
|
|
85
|
+
resolve: (r: DialogResult<void>) => void;
|
|
86
|
+
};
|
|
87
|
+
/** A single-text-input prompt, rendered by `FF_DialogManager` (built-in default or your `prompt` snippet). */
|
|
88
|
+
export type PromptItem = {
|
|
89
|
+
id: number;
|
|
90
|
+
title?: LocalizedMessage;
|
|
91
|
+
label?: LocalizedMessage;
|
|
92
|
+
placeholder?: string;
|
|
93
|
+
initial: string;
|
|
94
|
+
/** Omitted = fall back to `<FF_Config>`'s `messages.ok` (then the built-in). */
|
|
95
|
+
confirmLabel?: LocalizedMessage;
|
|
96
|
+
/** Omitted = fall back to `<FF_Config>`'s `messages.cancel` (then the built-in). */
|
|
97
|
+
cancelLabel?: LocalizedMessage;
|
|
98
|
+
/** Optional live hint under the field (e.g. a derived key preview). */
|
|
99
|
+
hint?: (value: string) => string;
|
|
100
|
+
resolve: (r: DialogResult<string>) => void;
|
|
101
|
+
};
|
|
102
|
+
/** Args handed to your dialog `shell` snippet - render a backdrop + panel, then `{@render body(close)}`. */
|
|
103
|
+
export type DialogShellArgs = {
|
|
104
|
+
id: number;
|
|
105
|
+
body: Snippet<[DialogClose]>;
|
|
106
|
+
/** Close with an explicit result, e.g. `close({ ok: true, data })`. */
|
|
107
|
+
close: DialogClose;
|
|
108
|
+
/** Dismiss (Esc / backdrop / close button) - honours `dismissible` + `allowClose`. */
|
|
109
|
+
dismiss: () => void;
|
|
110
|
+
dismissible: boolean;
|
|
111
|
+
width: 'sm' | 'md' | 'lg';
|
|
112
|
+
isTop: boolean;
|
|
113
|
+
};
|
|
114
|
+
/** Args handed to your `confirm` snippet. Labels arrive already resolved to strings. */
|
|
115
|
+
export type DialogConfirmArgs = {
|
|
116
|
+
id: number;
|
|
117
|
+
message: string;
|
|
118
|
+
title?: string;
|
|
119
|
+
confirmLabel: string;
|
|
120
|
+
cancelLabel: string;
|
|
121
|
+
danger: boolean;
|
|
122
|
+
confirm: () => void;
|
|
123
|
+
cancel: () => void;
|
|
124
|
+
isTop: boolean;
|
|
125
|
+
};
|
|
126
|
+
/** Args handed to your `prompt` snippet. Labels arrive already resolved to strings. */
|
|
127
|
+
export type DialogPromptArgs = {
|
|
128
|
+
id: number;
|
|
129
|
+
title?: string;
|
|
130
|
+
label?: string;
|
|
131
|
+
placeholder?: string;
|
|
132
|
+
initial: string;
|
|
133
|
+
confirmLabel: string;
|
|
134
|
+
cancelLabel: string;
|
|
135
|
+
submit: (value: string) => void;
|
|
136
|
+
cancel: () => void;
|
|
137
|
+
};
|
|
138
|
+
export declare const dialog: {
|
|
139
|
+
readonly list: readonly DialogItem[];
|
|
140
|
+
readonly confirmList: readonly ConfirmItem[];
|
|
141
|
+
readonly promptList: readonly PromptItem[];
|
|
142
|
+
/** Open a dialog. Resolves when it closes. `body` is a snippet receiving `close(result?)`. */
|
|
143
|
+
show<T = unknown>(body: Snippet<[DialogClose<T>]>, options?: DialogOptions): Promise<DialogResult<T>>;
|
|
144
|
+
/**
|
|
145
|
+
* Open a dialog from a **component + props** (the natural door for reusable dialogs).
|
|
146
|
+
* `close` is injected as a prop; declare it as `close: DialogClose<T>` and `open` infers
|
|
147
|
+
* the resolved `data` type from it - no call-site generic, no cast. `props` is a snapshot
|
|
148
|
+
* at open time; for reactive bodies use `show(snippet)`.
|
|
149
|
+
*/
|
|
150
|
+
open<C extends Component<any>>(component: C, options?: DialogOptions & {
|
|
151
|
+
props?: Omit<ComponentProps<C>, "close">;
|
|
152
|
+
}): Promise<DialogResult<DialogDataOf<C>>>;
|
|
153
|
+
/**
|
|
154
|
+
* Yes/no confirmation, rendered via the manager's `confirm` snippet. Resolves a
|
|
155
|
+
* `DialogResult` - `{ ok: true }` when confirmed, `{ ok: false }` when cancelled/dismissed
|
|
156
|
+
* (no `data`). Same `{ ok }` shape as `show`/`prompt`, so `if ((await dialog.confirm(...)).ok)`.
|
|
157
|
+
* Labels accept a `LocalizedMessage` (a string, or a paraglide/i18next message fn).
|
|
158
|
+
*/
|
|
159
|
+
confirm(message: LocalizedMessage, opts?: {
|
|
160
|
+
title?: LocalizedMessage;
|
|
161
|
+
confirmLabel?: LocalizedMessage;
|
|
162
|
+
cancelLabel?: LocalizedMessage;
|
|
163
|
+
danger?: boolean;
|
|
164
|
+
}): Promise<DialogResult<void>>;
|
|
165
|
+
/**
|
|
166
|
+
* Ask for a single text value. Resolves a `DialogResult<string>` - `{ ok: true, data }` with the
|
|
167
|
+
* (trimmed) value, or `{ ok: false }` if cancelled/dismissed. The `{ ok }` flag disambiguates
|
|
168
|
+
* "cancelled" from "submitted an empty string" (both would collapse to a falsy value otherwise).
|
|
169
|
+
* Rendered via the manager's `prompt` snippet, or a built-in default.
|
|
170
|
+
*/
|
|
171
|
+
prompt(opts?: {
|
|
172
|
+
title?: LocalizedMessage;
|
|
173
|
+
label?: LocalizedMessage;
|
|
174
|
+
placeholder?: string;
|
|
175
|
+
initial?: string;
|
|
176
|
+
confirmLabel?: LocalizedMessage;
|
|
177
|
+
cancelLabel?: LocalizedMessage;
|
|
178
|
+
hint?: (value: string) => string;
|
|
179
|
+
}): Promise<DialogResult<string>>;
|
|
180
|
+
/** Internal (manager): resolve a prompt with a value (or null to cancel). */
|
|
181
|
+
_resolvePrompt(id: number, value: string | null): void;
|
|
182
|
+
/** Dismiss the topmost prompt (resolves `{ ok: false }`). */
|
|
183
|
+
dismissTopPrompt(): void;
|
|
184
|
+
/** Internal (manager): resolve a dialog with an explicit result. */
|
|
185
|
+
_close(id: number, result: unknown): Promise<void>;
|
|
186
|
+
/** Internal (manager): dismiss a specific dialog (Esc / backdrop / close button) - honours `dismissible` + `allowClose`. */
|
|
187
|
+
requestClose(id: number): Promise<void>;
|
|
188
|
+
/** Internal (manager): resolve a confirm. */
|
|
189
|
+
_resolveConfirm(id: number, yes: boolean): void;
|
|
190
|
+
/** Dismiss the topmost dialog (honours `dismissible`). */
|
|
191
|
+
dismissTop(): Promise<void>;
|
|
192
|
+
/** Dismiss the topmost confirm (resolves `{ ok: false }`). */
|
|
193
|
+
dismissTopConfirm(): void;
|
|
194
|
+
/** Force-close everything (e.g. on full-page navigation). All resolve `{ ok: false }`. */
|
|
195
|
+
closeAll(): void;
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* `use:ffAutofocus` - focus the first focusable element inside the node (after mount).
|
|
199
|
+
* Put it on your dialog panel so keyboard users land inside it.
|
|
200
|
+
*/
|
|
201
|
+
export declare function ffAutofocus(node: HTMLElement): void;
|
|
202
|
+
/**
|
|
203
|
+
* `use:ffTrapFocus` - keep Tab / Shift+Tab cycling within `node` (a dialog panel) instead
|
|
204
|
+
* of escaping into the background. Put it on your panel alongside `ffAutofocus`. SSR-safe
|
|
205
|
+
* (the listener is only attached in the browser, where actions run).
|
|
206
|
+
*/
|
|
207
|
+
export declare function ffTrapFocus(node: HTMLElement): {
|
|
208
|
+
destroy(): void;
|
|
209
|
+
};
|