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.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/esm/core/FF_Filter.d.ts +2 -2
  3. package/esm/core/FF_Filter.js +2 -2
  4. package/esm/core/FF_Validators.d.ts +2 -0
  5. package/esm/core/FF_Validators.js +8 -10
  6. package/esm/core/containsWords.d.ts +2 -2
  7. package/esm/core/containsWords.js +2 -2
  8. package/esm/core/tailwind.d.ts +3 -4
  9. package/esm/core/tailwind.js +3 -4
  10. package/esm/svelte/DemoForm.svelte +121 -0
  11. package/esm/svelte/DemoForm.svelte.d.ts +42 -0
  12. package/esm/svelte/DemoGrid.svelte +146 -55
  13. package/esm/svelte/DemoGrid.svelte.d.ts +10 -1
  14. package/esm/svelte/DialogOpenTest.svelte +10 -0
  15. package/esm/svelte/DialogOpenTest.svelte.d.ts +8 -0
  16. package/esm/svelte/FF_Config.svelte +13 -0
  17. package/esm/svelte/FF_Config.svelte.d.ts +3 -0
  18. package/esm/svelte/FF_Config.svelte.js +38 -0
  19. package/esm/svelte/FF_DialogManager.svelte +251 -0
  20. package/esm/svelte/FF_DialogManager.svelte.d.ts +13 -0
  21. package/esm/svelte/FF_PromptDefault.svelte +85 -0
  22. package/esm/svelte/FF_PromptDefault.svelte.d.ts +9 -0
  23. package/esm/svelte/FF_ToastHtml.svelte +9 -0
  24. package/esm/svelte/FF_ToastHtml.svelte.d.ts +6 -0
  25. package/esm/svelte/FF_ToastManager.svelte +22 -0
  26. package/esm/svelte/FF_ToastManager.svelte.d.ts +4 -0
  27. package/esm/svelte/dialog.svelte.d.ts +209 -0
  28. package/esm/svelte/dialog.svelte.js +243 -0
  29. package/esm/svelte/ff.svelte.d.ts +294 -0
  30. package/esm/svelte/ff.svelte.js +599 -0
  31. package/esm/svelte/index.d.ts +13 -2
  32. package/esm/svelte/index.js +8 -1
  33. package/esm/svelte/infiniteScroll.d.ts +1 -1
  34. package/esm/svelte/infiniteScroll.js +1 -1
  35. package/esm/svelte/toast.d.ts +59 -0
  36. package/esm/svelte/toast.js +92 -0
  37. package/esm/virtual/StateDemoEnum.js +1 -1
  38. package/package.json +2 -1
  39. package/esm/svelte/FF_Repo.svelte.d.ts +0 -198
  40. 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,6 @@
1
+ type $$ComponentProps = {
2
+ html: string;
3
+ };
4
+ declare const FFToastHtml: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type FFToastHtml = ReturnType<typeof FFToastHtml>;
6
+ export default FFToastHtml;
@@ -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,4 @@
1
+ import { type ToasterProps } from 'svelte-sonner';
2
+ declare const FFToastManager: import("svelte").Component<Partial<ToasterProps>, {}, "">;
3
+ type FFToastManager = ReturnType<typeof FFToastManager>;
4
+ export default FFToastManager;
@@ -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
+ };