@marianmeres/stuic 3.141.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.
@@ -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;
@@ -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;
@@ -169,6 +169,19 @@
169
169
  return el.checkVisibility?.() ?? el.offsetParent !== null;
170
170
  }
171
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
+
172
185
  function on_document_paste(e: ClipboardEvent) {
173
186
  // someone (an editor, another paste handler) already claimed it
174
187
  if (e.defaultPrevented) return;
@@ -181,13 +194,18 @@
181
194
  return t.handle(e);
182
195
  }
183
196
  }
184
- // 2) a paste with no focus anywhere falls back to the single mounted
185
- // (and visible) pasteable field, so a bare Ctrl/Cmd-V works on a freshly
186
- // loaded page with no prior click. With several fields mounted the
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
187
203
  // routing would be ambiguous, so focus (a click on the field) must decide.
188
- if (paste_targets.size === 1 && is_unclaimed_focus(active)) {
204
+ if (paste_targets.size === 1 && !is_text_entry(active)) {
189
205
  const [t] = paste_targets;
190
- if (is_visible(t.el)) t.handle(e);
206
+ if (is_visible(t.el) && (is_unclaimed_focus(active) || shares_modal(t.el, active))) {
207
+ t.handle(e);
208
+ }
191
209
  }
192
210
  }
193
211
 
@@ -260,10 +278,13 @@
260
278
  * Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
261
279
  * the field. Routing: a paste is consumed while the field (or a control
262
280
  * inside it) holds focus — clicking anywhere in the field focuses it. On
263
- * top of that, a paste with NO focus anywhere (fresh page, focus parked on
264
- * <body>) is routed here as long as this is the only pasteable — and
265
- * visible FieldAssets on the page, so a bare Ctrl/Cmd-V works with no
266
- * prior click. Focus elsewhere is respected and never hijacked: not a text
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
267
288
  * input/textarea/select/contenteditable (even one nested inside the field
268
289
  * via the label/description/below snippets), not any other widget; with
269
290
  * several pasteable fields mounted, click (focus) one to pick the
@@ -79,10 +79,13 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
79
79
  * Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
80
80
  * the field. Routing: a paste is consumed while the field (or a control
81
81
  * inside it) holds focus — clicking anywhere in the field focuses it. On
82
- * top of that, a paste with NO focus anywhere (fresh page, focus parked on
83
- * <body>) is routed here as long as this is the only pasteable — and
84
- * visible FieldAssets on the page, so a bare Ctrl/Cmd-V works with no
85
- * prior click. Focus elsewhere is respected and never hijacked: not a text
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
86
89
  * input/textarea/select/contenteditable (even one nested inside the field
87
90
  * via the label/description/below snippets), not any other widget; with
88
91
  * several pasteable fields mounted, click (focus) one to pick the
@@ -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
- /** Called when Escape key is pressed while modal is open */
22
- onEscape?: () => void;
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
- onEscape?.();
86
- // return undefined to allow close (preClose will set visible = false)
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
- /** Called when Escape key is pressed while modal is open */
20
- onEscape?: () => void;
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 | Default | Description |
8
- | -------------- | ---------------- | ------- | ---------------------------------- |
9
- | `visible` | `boolean` | `false` | Controls visibility (bindable) |
10
- | `onEscape` | `() => void` | - | Callback on Escape key |
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) |
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
- if (!noEscapeClose) {
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
- let allowed = await preEscapeClose?.();
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.141.0",
3
+ "version": "3.142.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",