@marianmeres/stuic 3.140.0 → 3.142.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/dist/components/Backdrop/Backdrop.svelte +5 -0
- package/dist/components/Backdrop/Backdrop.svelte.d.ts +5 -0
- package/dist/components/CronInput/CronInput.svelte +1 -1
- package/dist/components/CronInput/README.md +2 -2
- package/dist/components/CronInput/cron-next-run.svelte.js +1 -1
- package/dist/components/Drawer/Drawer.svelte +5 -0
- package/dist/components/Drawer/Drawer.svelte.d.ts +5 -0
- package/dist/components/Input/FieldAssets.svelte +126 -18
- package/dist/components/Input/FieldAssets.svelte.d.ts +16 -5
- package/dist/components/Input/README.md +16 -3
- package/dist/components/Modal/Modal.svelte +21 -4
- package/dist/components/Modal/Modal.svelte.d.ts +15 -2
- package/dist/components/Modal/README.md +40 -13
- package/dist/components/Modal/index.d.ts +1 -1
- package/dist/components/Modal/index.js +1 -1
- package/dist/components/ModalDialog/ModalDialog.fixture.svelte +7 -1
- package/dist/components/ModalDialog/ModalDialog.svelte +12 -2
- package/package.json +2 -2
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
transitionEnabled?: boolean;
|
|
16
16
|
el?: HTMLDivElement;
|
|
17
17
|
children?: Snippet;
|
|
18
|
+
/**
|
|
19
|
+
* Fired on Escape while visible. **Notify-only** — Backdrop does not close
|
|
20
|
+
* itself; the caller owns close via `bind:visible` or `close()`. The return
|
|
21
|
+
* value is ignored (there is no Escape veto here, unlike `Modal.onEscape`).
|
|
22
|
+
*/
|
|
18
23
|
onEscape?: () => void;
|
|
19
24
|
onBackdropClick?: () => void;
|
|
20
25
|
visible?: boolean;
|
|
@@ -8,6 +8,11 @@ export interface Props extends Record<string, any> {
|
|
|
8
8
|
transitionEnabled?: boolean;
|
|
9
9
|
el?: HTMLDivElement;
|
|
10
10
|
children?: Snippet;
|
|
11
|
+
/**
|
|
12
|
+
* Fired on Escape while visible. **Notify-only** — Backdrop does not close
|
|
13
|
+
* itself; the caller owns close via `bind:visible` or `close()`. The return
|
|
14
|
+
* value is ignored (there is no Escape veto here, unlike `Modal.onEscape`).
|
|
15
|
+
*/
|
|
11
16
|
onEscape?: () => void;
|
|
12
17
|
onBackdropClick?: () => void;
|
|
13
18
|
visible?: boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CronInput
|
|
2
2
|
|
|
3
|
-
A cron-expression editor with three progressive surfaces: **preset picker**, **5-field visual editor**, and **raw expression input** — plus a human-readable summary and the next-run preview. Backed by [`@marianmeres/cron`](https://www.npmjs.com/package/@marianmeres/cron).
|
|
3
|
+
A cron-expression editor with three progressive surfaces: **preset picker**, **5-field visual editor**, and **raw expression input** — plus a human-readable summary and the next-run preview. Backed by [`@marianmeres/cron-parser`](https://www.npmjs.com/package/@marianmeres/cron-parser).
|
|
4
4
|
|
|
5
5
|
The cron expression string is the single source of truth; field / preset / raw surfaces are all views over it. All three stay in sync automatically when the bound `value` changes.
|
|
6
6
|
|
|
@@ -109,7 +109,7 @@ The component uses `new CronParser(value)` to validate. When invalid:
|
|
|
109
109
|
|
|
110
110
|
## Timezone
|
|
111
111
|
|
|
112
|
-
All next-run calculations use the **host's local timezone**. There is no `timezone` prop today. DST transitions are handled correctly by `@marianmeres/cron`, but the displayed "next run" will naturally follow local-time semantics.
|
|
112
|
+
All next-run calculations use the **host's local timezone**. There is no `timezone` prop today. DST transitions are handled correctly by `@marianmeres/cron-parser`, but the displayed "next run" will naturally follow local-time semantics.
|
|
113
113
|
|
|
114
114
|
## Props
|
|
115
115
|
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
focusTrap?: boolean | FocusTrapOptions;
|
|
17
17
|
/** Used in `fly` config. Should match tw classes for optimal animation. May include css units. */
|
|
18
18
|
animOffset?: string | number;
|
|
19
|
+
/**
|
|
20
|
+
* Fired on Escape while open. **Notify-only** — Drawer does not close itself
|
|
21
|
+
* (it forwards to Backdrop); the caller owns close via `bind:visible` or
|
|
22
|
+
* `close()`. The return value is ignored (no Escape veto, unlike `Modal`).
|
|
23
|
+
*/
|
|
19
24
|
onEscape?: () => void;
|
|
20
25
|
onOutside?: () => void;
|
|
21
26
|
noBackdropScrollLock?: boolean;
|
|
@@ -14,6 +14,11 @@ export interface Props {
|
|
|
14
14
|
focusTrap?: boolean | FocusTrapOptions;
|
|
15
15
|
/** Used in `fly` config. Should match tw classes for optimal animation. May include css units. */
|
|
16
16
|
animOffset?: string | number;
|
|
17
|
+
/**
|
|
18
|
+
* Fired on Escape while open. **Notify-only** — Drawer does not close itself
|
|
19
|
+
* (it forwards to Backdrop); the caller owns close via `bind:visible` or
|
|
20
|
+
* `close()`. The return value is ignored (no Escape veto, unlike `Modal`).
|
|
21
|
+
*/
|
|
17
22
|
onEscape?: () => void;
|
|
18
23
|
onOutside?: () => void;
|
|
19
24
|
noBackdropScrollLock?: boolean;
|
|
@@ -131,6 +131,93 @@
|
|
|
131
131
|
return map[getFileTypeLabel(ext ?? "unknown")] ?? iconFile;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// --- Clipboard paste plumbing (see the `pasteable` prop) -------------------
|
|
135
|
+
// All mounted pasteable instances coordinate through ONE document-level
|
|
136
|
+
// `paste` listener (installed while at least one is mounted). Document-level
|
|
137
|
+
// (rather than a listener on the field wrapper) because browsers dispatch
|
|
138
|
+
// `paste` at the focused/selection node — with focus on <body> the event
|
|
139
|
+
// never bubbles through the field, which made the feature look dead unless
|
|
140
|
+
// the field was clicked first.
|
|
141
|
+
type PasteTarget = {
|
|
142
|
+
el: HTMLElement;
|
|
143
|
+
handle: (e: ClipboardEvent) => void;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const paste_targets = new Set<PasteTarget>();
|
|
147
|
+
|
|
148
|
+
// True only when NOTHING is focused (browsers park focus on <body>/<html>).
|
|
149
|
+
// The fallback below must never fire while the user has deliberately focused
|
|
150
|
+
// some other element — a text input obviously, but also any other widget
|
|
151
|
+
// (e.g. a different, non-pasteable FieldAssets): pasting "into" the thing
|
|
152
|
+
// they focused must not teleport files to an unrelated field.
|
|
153
|
+
function is_unclaimed_focus(el: Element | null): boolean {
|
|
154
|
+
return !el || el === document.body || el === document.documentElement;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Text-entry elements own their pastes even when they live INSIDE the field
|
|
158
|
+
// (consumer content via the label/description/below snippets) — an editor's
|
|
159
|
+
// image paste must insert into the editor, not upload into the field.
|
|
160
|
+
function is_text_entry(el: Element | null): boolean {
|
|
161
|
+
if (!el) return false;
|
|
162
|
+
if ((el as HTMLElement).isContentEditable) return true;
|
|
163
|
+
return ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A field hidden by CSS (kept-mounted inactive tab panel etc.) must not
|
|
167
|
+
// claim the no-focus fallback — the user would see nothing happen.
|
|
168
|
+
function is_visible(el: HTMLElement): boolean {
|
|
169
|
+
return el.checkVisibility?.() ?? el.offsetParent !== null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A modal/dialog (drawer, modal) takes focus on open, so `active` is the
|
|
173
|
+
// dialog panel — or a control inside it — and NEVER <body>. When the field
|
|
174
|
+
// lives in such a dialog, treat a non-text focus within that SAME dialog as
|
|
175
|
+
// unclaimed too, so a bare Ctrl/Cmd-V attaches with no prior click. Scoped to
|
|
176
|
+
// a shared [aria-modal]/[role=dialog] ancestor on purpose: it must not make
|
|
177
|
+
// the field greedy on ordinary (non-modal) pages, where a deliberately
|
|
178
|
+
// focused control elsewhere still owns its paste.
|
|
179
|
+
function shares_modal(fieldEl: HTMLElement, active: Element | null): boolean {
|
|
180
|
+
if (!active) return false;
|
|
181
|
+
const modal = fieldEl.closest?.("[aria-modal='true'],[role='dialog']");
|
|
182
|
+
return !!modal && modal.contains(active);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function on_document_paste(e: ClipboardEvent) {
|
|
186
|
+
// someone (an editor, another paste handler) already claimed it
|
|
187
|
+
if (e.defaultPrevented) return;
|
|
188
|
+
const active = document.activeElement;
|
|
189
|
+
// 1) the field holding focus always wins — unless focus sits in a
|
|
190
|
+
// text-entry element nested inside it: stand down entirely
|
|
191
|
+
for (const t of paste_targets) {
|
|
192
|
+
if (t.el.contains(active)) {
|
|
193
|
+
if (is_text_entry(active)) return;
|
|
194
|
+
return t.handle(e);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// 2) fall back to the SINGLE mounted + visible pasteable field for a paste
|
|
198
|
+
// NOT owned by a focused text-entry element, when focus is EITHER unclaimed
|
|
199
|
+
// (fresh page, focus on <body>) OR parked on a non-text control inside the
|
|
200
|
+
// SAME modal/dialog as the field (the drawer/modal case: the panel or the
|
|
201
|
+
// row that opened it holds focus, so <body> is never active). A bare
|
|
202
|
+
// Ctrl/Cmd-V then works with no prior click. With several fields mounted the
|
|
203
|
+
// routing would be ambiguous, so focus (a click on the field) must decide.
|
|
204
|
+
if (paste_targets.size === 1 && !is_text_entry(active)) {
|
|
205
|
+
const [t] = paste_targets;
|
|
206
|
+
if (is_visible(t.el) && (is_unclaimed_focus(active) || shares_modal(t.el, active))) {
|
|
207
|
+
t.handle(e);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function register_paste_target(t: PasteTarget): () => void {
|
|
213
|
+
if (!paste_targets.size) document.addEventListener("paste", on_document_paste);
|
|
214
|
+
paste_targets.add(t);
|
|
215
|
+
return () => {
|
|
216
|
+
paste_targets.delete(t);
|
|
217
|
+
if (!paste_targets.size) document.removeEventListener("paste", on_document_paste);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
134
221
|
type SnippetWithId = Snippet<[{ id: string }]>;
|
|
135
222
|
|
|
136
223
|
export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
@@ -189,11 +276,22 @@
|
|
|
189
276
|
ordered?: boolean;
|
|
190
277
|
/**
|
|
191
278
|
* Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
|
|
192
|
-
* the field.
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
279
|
+
* the field. Routing: a paste is consumed while the field (or a control
|
|
280
|
+
* inside it) holds focus — clicking anywhere in the field focuses it. On
|
|
281
|
+
* top of that, a bare Ctrl/Cmd-V with no prior click is routed here — as
|
|
282
|
+
* long as this is the only pasteable + visible FieldAssets on the page —
|
|
283
|
+
* when focus is NOT in a text-entry element and is either unclaimed (fresh
|
|
284
|
+
* page, focus parked on <body>) or parked on a non-text control inside the
|
|
285
|
+
* SAME modal/dialog as the field (a drawer/modal takes focus on open, so
|
|
286
|
+
* <body> is never active there). Focus elsewhere is respected and never
|
|
287
|
+
* hijacked: not a text
|
|
288
|
+
* input/textarea/select/contenteditable (even one nested inside the field
|
|
289
|
+
* via the label/description/below snippets), not any other widget; with
|
|
290
|
+
* several pasteable fields mounted, click (focus) one to pick the
|
|
291
|
+
* destination. Disabled and `isLoading` fields take no pastes. Pasted
|
|
292
|
+
* files share the validation + upload path with drag and the file picker
|
|
293
|
+
* (`accept`, `cardinality` and `processAssets` all apply). No-op unless
|
|
294
|
+
* `processAssets` is provided. Default `false`.
|
|
197
295
|
*/
|
|
198
296
|
pasteable?: boolean;
|
|
199
297
|
//
|
|
@@ -526,11 +624,12 @@
|
|
|
526
624
|
}
|
|
527
625
|
|
|
528
626
|
// --- Clipboard paste (opt-in via `pasteable`) -------------------------------
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
627
|
+
// Wired through the module-level document `paste` listener (see PasteTarget
|
|
628
|
+
// above): the instance registers itself while mounted, and receives the paste
|
|
629
|
+
// when it holds focus — or, as the only mounted pasteable field, when the
|
|
630
|
+
// paste is unclaimed (focus not in a text-entry element). Pasted files go
|
|
631
|
+
// through `handleIncomingFiles`, inheriting accept/cardinality validation and
|
|
632
|
+
// the processAssets upload — no synthetic `change` event.
|
|
534
633
|
function extractClipboardFiles(e: ClipboardEvent): File[] {
|
|
535
634
|
const dt = e.clipboardData;
|
|
536
635
|
if (!dt) return [];
|
|
@@ -556,11 +655,11 @@
|
|
|
556
655
|
handleIncomingFiles(files);
|
|
557
656
|
}
|
|
558
657
|
|
|
559
|
-
// Focus the wrapper on any click inside it so a following paste
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
658
|
+
// Focus the wrapper on any click inside it so a following paste routes here
|
|
659
|
+
// (the document-level listener routes by focus containment). Capture phase:
|
|
660
|
+
// fires even though the inner thumbnail/control buttons stopPropagation, and
|
|
661
|
+
// even in browsers that don't focus <button> on click (Safari/Firefox on
|
|
662
|
+
// macOS). Skipped when focus is already inside the field.
|
|
564
663
|
function focusForPaste() {
|
|
565
664
|
if (!pasteable || !wrapEl) return;
|
|
566
665
|
if (wrapEl.contains(document.activeElement)) return;
|
|
@@ -570,12 +669,17 @@
|
|
|
570
669
|
}
|
|
571
670
|
|
|
572
671
|
$effect(() => {
|
|
573
|
-
|
|
672
|
+
// disabled and still-loading fields do not take pastes at all
|
|
673
|
+
if (!pasteable || typeof processAssets !== "function" || !wrapEl) return;
|
|
674
|
+
if (disabled || isLoading) return;
|
|
574
675
|
const el = wrapEl;
|
|
575
|
-
el
|
|
676
|
+
const unregister = register_paste_target({ el, handle: handlePaste });
|
|
677
|
+
// clicking still focuses the field: gives the `:focus-within` ring (the
|
|
678
|
+
// visible "paste lands here" affordance) and lets THIS field win the
|
|
679
|
+
// routing when several pasteable fields are mounted
|
|
576
680
|
el.addEventListener("click", focusForPaste, true);
|
|
577
681
|
return () => {
|
|
578
|
-
|
|
682
|
+
unregister();
|
|
579
683
|
el.removeEventListener("click", focusForPaste, true);
|
|
580
684
|
};
|
|
581
685
|
});
|
|
@@ -764,7 +868,11 @@
|
|
|
764
868
|
{classLabelBox}
|
|
765
869
|
{classInputBox}
|
|
766
870
|
classInputBoxWrap={twMerge(
|
|
871
|
+
// the ring is the "paste lands here" affordance — only show it when
|
|
872
|
+
// paste actually works (same gating as the paste-target registration)
|
|
767
873
|
pasteable &&
|
|
874
|
+
typeof processAssets === "function" &&
|
|
875
|
+
!disabled &&
|
|
768
876
|
"focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-(--stuic-color-ring)",
|
|
769
877
|
classInputBoxWrap
|
|
770
878
|
)}
|
|
@@ -77,11 +77,22 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
77
77
|
ordered?: boolean;
|
|
78
78
|
/**
|
|
79
79
|
* Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
|
|
80
|
-
* the field.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
80
|
+
* the field. Routing: a paste is consumed while the field (or a control
|
|
81
|
+
* inside it) holds focus — clicking anywhere in the field focuses it. On
|
|
82
|
+
* top of that, a bare Ctrl/Cmd-V with no prior click is routed here — as
|
|
83
|
+
* long as this is the only pasteable + visible FieldAssets on the page —
|
|
84
|
+
* when focus is NOT in a text-entry element and is either unclaimed (fresh
|
|
85
|
+
* page, focus parked on <body>) or parked on a non-text control inside the
|
|
86
|
+
* SAME modal/dialog as the field (a drawer/modal takes focus on open, so
|
|
87
|
+
* <body> is never active there). Focus elsewhere is respected and never
|
|
88
|
+
* hijacked: not a text
|
|
89
|
+
* input/textarea/select/contenteditable (even one nested inside the field
|
|
90
|
+
* via the label/description/below snippets), not any other widget; with
|
|
91
|
+
* several pasteable fields mounted, click (focus) one to pick the
|
|
92
|
+
* destination. Disabled and `isLoading` fields take no pastes. Pasted
|
|
93
|
+
* files share the validation + upload path with drag and the file picker
|
|
94
|
+
* (`accept`, `cardinality` and `processAssets` all apply). No-op unless
|
|
95
|
+
* `processAssets` is provided. Default `false`.
|
|
85
96
|
*/
|
|
86
97
|
pasteable?: boolean;
|
|
87
98
|
classWrap?: string;
|
|
@@ -482,9 +482,22 @@ an upload progress indicator while processing).
|
|
|
482
482
|
### Paste from the clipboard (`pasteable`)
|
|
483
483
|
|
|
484
484
|
Opt in with `pasteable` to let users paste image/file data from the clipboard (Ctrl/Cmd-V) —
|
|
485
|
-
a screenshot, a copied image, etc.
|
|
486
|
-
|
|
487
|
-
|
|
485
|
+
a screenshot, a copied image, etc. Routing (via a single document-level listener shared by
|
|
486
|
+
all mounted pasteable fields):
|
|
487
|
+
|
|
488
|
+
- a paste is consumed while the field (or a control inside it) holds focus — clicking
|
|
489
|
+
anywhere in the field focuses it, and a `:focus-within` ring on the input box signals the
|
|
490
|
+
paste-ready state;
|
|
491
|
+
- a paste with **no focus anywhere** (fresh page — focus parked on `<body>`) is routed to
|
|
492
|
+
the field as long as it is the _only_ pasteable (and visible) FieldAssets on the page, so
|
|
493
|
+
a bare Ctrl/Cmd-V works with no prior click;
|
|
494
|
+
- focus elsewhere is respected: pasting while a text input, textarea, select,
|
|
495
|
+
contenteditable (even one nested _inside_ the field via the `label`/`description`/`below`
|
|
496
|
+
snippets) or any other widget holds focus is never hijacked. With several pasteable fields
|
|
497
|
+
mounted, an unfocused paste is ambiguous and ignored — click (focus) a field to pick the
|
|
498
|
+
destination;
|
|
499
|
+
- `disabled` and `isLoading` fields take no pastes.
|
|
500
|
+
|
|
488
501
|
Pasted files go through the same path as the picker and drag-and-drop, so `accept`,
|
|
489
502
|
`cardinality` and `processAssets` all apply. No-op unless `processAssets` is provided.
|
|
490
503
|
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Return type of `Modal`'s `onEscape` handler.
|
|
6
|
+
*
|
|
7
|
+
* Resolve to `false` to VETO the Escape-close and keep the modal open; any
|
|
8
|
+
* other value (including `undefined`) closes as before. May be async — the
|
|
9
|
+
* modal stays open until the returned promise resolves.
|
|
10
|
+
*/
|
|
11
|
+
export type EscapeVeto = void | boolean | Promise<void | boolean>;
|
|
12
|
+
|
|
4
13
|
export interface Props {
|
|
5
14
|
visible?: boolean;
|
|
6
15
|
children: Snippet;
|
|
@@ -18,8 +27,13 @@
|
|
|
18
27
|
/** ID reference for aria-describedby */
|
|
19
28
|
describedby?: string;
|
|
20
29
|
el?: HTMLDivElement;
|
|
21
|
-
/**
|
|
22
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Called when Escape is pressed while the modal is open.
|
|
32
|
+
* Return (or resolve to) `false` to VETO the close and keep the modal
|
|
33
|
+
* open; any other value (incl. `undefined`) closes as before. May be
|
|
34
|
+
* async — the modal stays open until the returned promise resolves.
|
|
35
|
+
*/
|
|
36
|
+
onEscape?: () => EscapeVeto;
|
|
23
37
|
/** Disable body scroll lock when modal is open */
|
|
24
38
|
noScrollLock?: boolean;
|
|
25
39
|
/** Disable close on backdrop / outside click */
|
|
@@ -82,8 +96,11 @@
|
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
function handlePreEscapeClose() {
|
|
85
|
-
|
|
86
|
-
//
|
|
99
|
+
// Forward to ModalDialog.preEscapeClose, which closes only when the result
|
|
100
|
+
// is !== false and already awaits it — so an async veto is honoured and a
|
|
101
|
+
// `false` return keeps the modal open (preClose sets visible = false only
|
|
102
|
+
// when the close is actually allowed to proceed).
|
|
103
|
+
return onEscape?.();
|
|
87
104
|
}
|
|
88
105
|
</script>
|
|
89
106
|
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { Snippet } from "svelte";
|
|
2
|
+
/**
|
|
3
|
+
* Return type of `Modal`'s `onEscape` handler.
|
|
4
|
+
*
|
|
5
|
+
* Resolve to `false` to VETO the Escape-close and keep the modal open; any
|
|
6
|
+
* other value (including `undefined`) closes as before. May be async — the
|
|
7
|
+
* modal stays open until the returned promise resolves.
|
|
8
|
+
*/
|
|
9
|
+
export type EscapeVeto = void | boolean | Promise<void | boolean>;
|
|
2
10
|
export interface Props {
|
|
3
11
|
visible?: boolean;
|
|
4
12
|
children: Snippet;
|
|
@@ -16,8 +24,13 @@ export interface Props {
|
|
|
16
24
|
/** ID reference for aria-describedby */
|
|
17
25
|
describedby?: string;
|
|
18
26
|
el?: HTMLDivElement;
|
|
19
|
-
/**
|
|
20
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Called when Escape is pressed while the modal is open.
|
|
29
|
+
* Return (or resolve to) `false` to VETO the close and keep the modal
|
|
30
|
+
* open; any other value (incl. `undefined`) closes as before. May be
|
|
31
|
+
* async — the modal stays open until the returned promise resolves.
|
|
32
|
+
*/
|
|
33
|
+
onEscape?: () => EscapeVeto;
|
|
21
34
|
/** Disable body scroll lock when modal is open */
|
|
22
35
|
noScrollLock?: boolean;
|
|
23
36
|
/** Disable close on backdrop / outside click */
|
|
@@ -4,19 +4,19 @@ A styled modal dialog with optional header and footer sections. Built on top of
|
|
|
4
4
|
|
|
5
5
|
## Props
|
|
6
6
|
|
|
7
|
-
| Prop | Type
|
|
8
|
-
| -------------- |
|
|
9
|
-
| `visible` | `boolean`
|
|
10
|
-
| `onEscape` | `() => void
|
|
11
|
-
| `noScrollLock` | `boolean`
|
|
12
|
-
| `classInner` | `string`
|
|
13
|
-
| `class` | `string`
|
|
14
|
-
| `classHeader` | `string`
|
|
15
|
-
| `classMain` | `string`
|
|
16
|
-
| `classFooter` | `string`
|
|
17
|
-
| `labelledby` | `string`
|
|
18
|
-
| `describedby` | `string`
|
|
19
|
-
| `el` | `HTMLDivElement`
|
|
7
|
+
| Prop | Type | Default | Description |
|
|
8
|
+
| -------------- | --------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| `visible` | `boolean` | `false` | Controls visibility (bindable) |
|
|
10
|
+
| `onEscape` | `() => void \| boolean \| Promise<void \| boolean>` | - | Escape handler. Return/resolve `false` to **veto** the close and keep the modal open (see below); anything else (incl. `undefined`) closes as before |
|
|
11
|
+
| `noScrollLock` | `boolean` | `false` | Disable body scroll lock |
|
|
12
|
+
| `classInner` | `string` | - | CSS for inner width container |
|
|
13
|
+
| `class` | `string` | - | CSS for modal box |
|
|
14
|
+
| `classHeader` | `string` | - | CSS for header section |
|
|
15
|
+
| `classMain` | `string` | - | CSS for main content |
|
|
16
|
+
| `classFooter` | `string` | - | CSS for footer section |
|
|
17
|
+
| `labelledby` | `string` | - | ARIA labelledby ID |
|
|
18
|
+
| `describedby` | `string` | - | ARIA describedby ID |
|
|
19
|
+
| `el` | `HTMLDivElement` | - | Modal element reference (bindable) |
|
|
20
20
|
|
|
21
21
|
## Snippets
|
|
22
22
|
|
|
@@ -92,6 +92,33 @@ A styled modal dialog with optional header and footer sections. Built on top of
|
|
|
92
92
|
</Modal>
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
### Vetoable Escape close (confirm before dismiss)
|
|
96
|
+
|
|
97
|
+
`onEscape` can **keep the modal open** by returning (or resolving to) `false` —
|
|
98
|
+
useful for an "unsaved changes" guard. It may be async: the modal stays open
|
|
99
|
+
until the returned promise resolves, so you can await a confirm dialog.
|
|
100
|
+
|
|
101
|
+
```svelte
|
|
102
|
+
<script lang="ts">
|
|
103
|
+
let modal: Modal;
|
|
104
|
+
let dirty = $state(false);
|
|
105
|
+
|
|
106
|
+
// Return false to VETO the close and keep the modal open.
|
|
107
|
+
async function requestClose(): Promise<boolean> {
|
|
108
|
+
if (dirty && !(await confirmDiscard())) return false; // veto — stay open
|
|
109
|
+
modal.close();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<Modal bind:this={modal} onEscape={requestClose} noClickOutsideClose>
|
|
115
|
+
<div class="p-6">Edit form with unsaved changes…</div>
|
|
116
|
+
</Modal>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
> Only the Escape path is vetoable. The Cancel button and programmatic
|
|
120
|
+
> `close()` are yours to gate — run the same confirm before calling `close()`.
|
|
121
|
+
|
|
95
122
|
### Custom Sizing
|
|
96
123
|
|
|
97
124
|
```svelte
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default as Modal, type Props as ModalProps } from "./Modal.svelte";
|
|
1
|
+
export { default as Modal, type Props as ModalProps, type EscapeVeto, } from "./Modal.svelte";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default as Modal } from "./Modal.svelte";
|
|
1
|
+
export { default as Modal, } from "./Modal.svelte";
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// fixture holds the `bind:this` ref and exposes an opener button that calls
|
|
6
6
|
// `.open()`; ModalDialog props are forwarded through `...rest`. The default
|
|
7
7
|
// content (an "inside" button) is the required `children` snippet — it also
|
|
8
|
-
// gives the focus trap a real focusable element to auto-focus.
|
|
8
|
+
// gives the focus trap a real focusable element to auto-focus. A second
|
|
9
|
+
// "programmatic-close" button (kept INSIDE the dialog so it stays clickable
|
|
10
|
+
// while showModal() makes the rest of the page inert) calls `.close()` so a
|
|
11
|
+
// test can assert the programmatic close path is NOT gated by the Escape veto.
|
|
9
12
|
import ModalDialog from "./ModalDialog.svelte";
|
|
10
13
|
|
|
11
14
|
let dialog = $state<ModalDialog>();
|
|
@@ -16,4 +19,7 @@
|
|
|
16
19
|
|
|
17
20
|
<ModalDialog bind:this={dialog} {...rest}>
|
|
18
21
|
<button data-testid="inside">Inside</button>
|
|
22
|
+
<button data-testid="programmatic-close" onclick={() => dialog?.close()}>
|
|
23
|
+
close
|
|
24
|
+
</button>
|
|
19
25
|
</ModalDialog>
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
let box = $state<HTMLDivElement>()!;
|
|
59
59
|
let _opener: undefined | null | HTMLElement = $state();
|
|
60
60
|
let _isClosing = false;
|
|
61
|
+
let _isEscaping = false;
|
|
61
62
|
|
|
62
63
|
export function open(openerOrEvent?: null | HTMLElement | MouseEvent) {
|
|
63
64
|
if (visible) return; // Already open
|
|
@@ -143,13 +144,22 @@
|
|
|
143
144
|
// do not allow additional onkeydown listeners on this dialog
|
|
144
145
|
e.stopImmediatePropagation();
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
// `noEscapeClose` short-circuits (unchanged). `_isEscaping` is a
|
|
148
|
+
// re-entrancy latch: with an async `preEscapeClose` veto, a rapid
|
|
149
|
+
// second Escape would otherwise invoke the hook again and stack a
|
|
150
|
+
// duplicate confirm. It resets in `finally`, so a vetoed close can be
|
|
151
|
+
// retried on a subsequent Escape.
|
|
152
|
+
if (noEscapeClose || _isEscaping) return;
|
|
153
|
+
_isEscaping = true;
|
|
154
|
+
try {
|
|
147
155
|
// explicit false prevents close
|
|
148
|
-
|
|
156
|
+
const allowed = await preEscapeClose?.();
|
|
149
157
|
if (allowed !== false) {
|
|
150
158
|
// `preClose` will be handled next
|
|
151
159
|
close();
|
|
152
160
|
}
|
|
161
|
+
} finally {
|
|
162
|
+
_isEscaping = false;
|
|
153
163
|
}
|
|
154
164
|
}
|
|
155
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/stuic",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.142.0",
|
|
4
4
|
"packageManager": "pnpm@11.5.0",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "vite dev",
|
|
@@ -179,7 +179,7 @@
|
|
|
179
179
|
"dependencies": {
|
|
180
180
|
"@marianmeres/clog": "^3.21.0",
|
|
181
181
|
"@marianmeres/countries": "^1.0.1",
|
|
182
|
-
"@marianmeres/cron": "^
|
|
182
|
+
"@marianmeres/cron-parser": "^1.0.1",
|
|
183
183
|
"@marianmeres/design-tokens": "^1.12.0",
|
|
184
184
|
"@marianmeres/icons-fns": "^5.0.0",
|
|
185
185
|
"@marianmeres/item-collection": "^1.4.2",
|