@marianmeres/stuic 2.60.0 → 2.62.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.
@@ -1,14 +1,11 @@
1
1
  <script lang="ts" module>
2
2
  import type { Snippet } from "svelte";
3
- import type { FocusTrapOptions } from "../../actions/focus-trap.js";
4
3
 
5
4
  export interface Props {
6
5
  visible?: boolean;
7
6
  children: Snippet;
8
7
  header?: Snippet;
9
8
  footer?: Snippet;
10
- /** Classes for the backdrop overlay element */
11
- classBackdrop?: string;
12
9
  /** Classes for the inner container (constrains content width) */
13
10
  classInner?: string;
14
11
  class?: string;
@@ -19,34 +16,23 @@
19
16
  labelledby?: string;
20
17
  /** ID reference for aria-describedby */
21
18
  describedby?: string;
22
- /** Transition duration in ms (respects prefers-reduced-motion) */
23
- transitionDuration?: number;
24
- elBackdrop?: HTMLDivElement;
25
19
  el?: HTMLDivElement;
26
- /** Enable focus trap, or pass options to customize behavior */
27
- focusTrap?: boolean | FocusTrapOptions;
28
20
  /** Called when Escape key is pressed while modal is open */
29
- onEscape?: undefined | (() => void);
21
+ onEscape?: () => void;
30
22
  /** Disable body scroll lock when modal is open */
31
23
  noScrollLock?: boolean;
32
- /** Fires when the backdrop is clicked "directly" */
33
- onBackdropClick?: undefined | (() => void);
34
24
  }
35
25
  </script>
36
26
 
37
27
  <script lang="ts">
38
- import Backdrop from "../Backdrop/Backdrop.svelte";
39
- import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
28
+ import ModalDialog from "../ModalDialog/ModalDialog.svelte";
40
29
  import { twMerge } from "../../utils/tw-merge.js";
41
30
 
42
- const prefersReduced = prefersReducedMotion();
43
-
44
31
  let {
45
32
  visible = $bindable(false),
46
33
  children,
47
34
  header,
48
35
  footer,
49
- classBackdrop,
50
36
  classInner,
51
37
  class: classProp,
52
38
  classHeader,
@@ -54,61 +40,64 @@
54
40
  classFooter,
55
41
  labelledby,
56
42
  describedby,
57
- transitionDuration = 100,
58
- // transitionEnabled = true,
59
- elBackdrop = $bindable(),
60
43
  el = $bindable(),
61
- focusTrap = true,
62
44
  onEscape,
63
45
  noScrollLock = false,
64
- onBackdropClick,
65
46
  }: Props = $props();
66
47
 
67
- let backdrop: Backdrop = $state()!;
48
+ let modalDialog: ModalDialog = $state()!;
68
49
 
69
50
  export function close() {
70
- backdrop.close();
51
+ modalDialog.close();
71
52
  }
72
53
 
73
54
  export function open(openerOrEvent?: null | HTMLElement | MouseEvent) {
74
- backdrop.open(openerOrEvent);
55
+ modalDialog.open(openerOrEvent);
56
+ visible = true;
75
57
  }
76
58
 
77
59
  export function setOpener(opener?: null | HTMLElement) {
78
- backdrop.setOpener(opener);
60
+ modalDialog.setOpener(opener);
79
61
  }
80
62
 
81
63
  export function visibility() {
82
- return backdrop.visibility();
64
+ return modalDialog.visibility();
65
+ }
66
+
67
+ // Sync visible prop with ModalDialog - when visible becomes true externally, open the dialog
68
+ $effect(() => {
69
+ if (visible && !modalDialog?.visibility().visible) {
70
+ modalDialog?.open();
71
+ }
72
+ });
73
+
74
+ function handlePreClose() {
75
+ visible = false;
76
+ // return undefined to allow close
77
+ }
78
+
79
+ function handlePreEscapeClose() {
80
+ onEscape?.();
81
+ // return undefined to allow close (preClose will set visible = false)
83
82
  }
84
83
  </script>
85
84
 
86
- <Backdrop
87
- bind:this={backdrop}
88
- bind:el={elBackdrop}
89
- bind:visible
90
- class={twMerge(
91
- // "justify-center items-center bg-black/25 p-2 sm:p-4 md:p-[10vh] transition-all",
92
- "justify-center items-center bg-black/25 transition-all",
93
- "md:p-[10vh]",
94
- classBackdrop
95
- )}
96
- {focusTrap}
97
- fadeOutDuration={transitionDuration}
98
- {onEscape}
85
+ <ModalDialog
86
+ bind:this={modalDialog}
87
+ ariaLabelledby={labelledby}
88
+ ariaDescribedby={describedby}
99
89
  {noScrollLock}
100
- {onBackdropClick}
90
+ preEscapeClose={handlePreEscapeClose}
91
+ preClose={handlePreClose}
92
+ class="bg-transparent size-full md:size-auto pointer-events-none"
101
93
  >
102
94
  <div
103
95
  bind:this={el}
104
- role="dialog"
105
- aria-modal="true"
106
- aria-labelledby={labelledby}
107
- aria-describedby={describedby}
108
96
  class={twMerge(
109
97
  "overflow-x-hidden overflow-y-hidden flex flex-col",
110
- "w-full max-w-3xl",
111
- "h-dvh md:h-full",
98
+ "w-full md:w-3xl md:max-w-[calc(100vw-2rem)]",
99
+ "h-full md:h-auto md:min-h-48 md:max-h-[80dvh]",
100
+ "pointer-events-auto",
112
101
  classInner
113
102
  )}
114
103
  >
@@ -117,7 +106,7 @@
117
106
  "bg-white dark:bg-neutral-800",
118
107
  "flex flex-col overflow-hidden",
119
108
  "rounded-none md:rounded-md",
120
- "w-full flex-1 md:max-h-2/3",
109
+ "w-full flex-1",
121
110
  classProp
122
111
  )}
123
112
  >
@@ -136,7 +125,7 @@
136
125
  {/if}
137
126
  </div>
138
127
  </div>
139
- </Backdrop>
128
+ </ModalDialog>
140
129
 
141
130
  <style>
142
131
  .main {
@@ -1,12 +1,9 @@
1
1
  import type { Snippet } from "svelte";
2
- import type { FocusTrapOptions } from "../../actions/focus-trap.js";
3
2
  export interface Props {
4
3
  visible?: boolean;
5
4
  children: Snippet;
6
5
  header?: Snippet;
7
6
  footer?: Snippet;
8
- /** Classes for the backdrop overlay element */
9
- classBackdrop?: string;
10
7
  /** Classes for the inner container (constrains content width) */
11
8
  classInner?: string;
12
9
  class?: string;
@@ -17,26 +14,19 @@ export interface Props {
17
14
  labelledby?: string;
18
15
  /** ID reference for aria-describedby */
19
16
  describedby?: string;
20
- /** Transition duration in ms (respects prefers-reduced-motion) */
21
- transitionDuration?: number;
22
- elBackdrop?: HTMLDivElement;
23
17
  el?: HTMLDivElement;
24
- /** Enable focus trap, or pass options to customize behavior */
25
- focusTrap?: boolean | FocusTrapOptions;
26
18
  /** Called when Escape key is pressed while modal is open */
27
- onEscape?: undefined | (() => void);
19
+ onEscape?: () => void;
28
20
  /** Disable body scroll lock when modal is open */
29
21
  noScrollLock?: boolean;
30
- /** Fires when the backdrop is clicked "directly" */
31
- onBackdropClick?: undefined | (() => void);
32
22
  }
33
23
  declare const Modal: import("svelte").Component<Props, {
34
24
  close: () => void;
35
25
  open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
36
26
  setOpener: (opener?: null | HTMLElement) => void;
37
27
  visibility: () => {
38
- readonly visible: boolean;
28
+ readonly visible: boolean | undefined;
39
29
  };
40
- }, "el" | "visible" | "elBackdrop">;
30
+ }, "el" | "visible">;
41
31
  type Modal = ReturnType<typeof Modal>;
42
32
  export default Modal;
@@ -1,17 +1,14 @@
1
1
  # Modal
2
2
 
3
- A centered modal dialog with optional header and footer sections. Built on top of `Backdrop` with focus trap and scroll locking.
3
+ A styled modal dialog with optional header and footer sections. Built on top of `ModalDialog` (native `<dialog>` element) with focus trap and scroll locking.
4
4
 
5
5
  ## Props
6
6
 
7
7
  | Prop | Type | Default | Description |
8
8
  |------|------|---------|-------------|
9
9
  | `visible` | `boolean` | `false` | Controls visibility (bindable) |
10
- | `focusTrap` | `boolean \| FocusTrapOptions` | `true` | Enable focus trapping |
11
- | `transitionDuration` | `number` | `100` | Fade transition duration (ms) |
12
10
  | `onEscape` | `() => void` | - | Callback on Escape key |
13
11
  | `noScrollLock` | `boolean` | `false` | Disable body scroll lock |
14
- | `classBackdrop` | `string` | - | CSS for backdrop overlay |
15
12
  | `classInner` | `string` | - | CSS for inner width container |
16
13
  | `class` | `string` | - | CSS for modal box |
17
14
  | `classHeader` | `string` | - | CSS for header section |
@@ -20,7 +17,6 @@ A centered modal dialog with optional header and footer sections. Built on top o
20
17
  | `labelledby` | `string` | - | ARIA labelledby ID |
21
18
  | `describedby` | `string` | - | ARIA describedby ID |
22
19
  | `el` | `HTMLDivElement` | - | Modal element reference (bindable) |
23
- | `elBackdrop` | `HTMLDivElement` | - | Backdrop element reference (bindable) |
24
20
 
25
21
  ## Snippets
26
22
 
@@ -109,9 +105,24 @@ A centered modal dialog with optional header and footer sections. Built on top o
109
105
  ```svelte
110
106
  <Modal
111
107
  bind:this={modal}
112
- classInner="max-w-lg"
108
+ classInner="md:w-lg"
113
109
  class="rounded-lg"
114
110
  >
115
111
  <div class="p-6">Smaller modal</div>
116
112
  </Modal>
117
113
  ```
114
+
115
+ ## Responsive Behavior
116
+
117
+ By default, Modal is:
118
+ - **Mobile**: Full screen with 1rem margins from viewport edges
119
+ - **Desktop (md+)**: Centered, max-width 768px, auto height with max 80vh
120
+
121
+ ## Relationship to ModalDialog
122
+
123
+ Modal is a higher-level component built on `ModalDialog`. It provides:
124
+ - Pre-styled header/main/footer layout
125
+ - Responsive sizing (fullscreen mobile, centered desktop)
126
+ - Conventional styling (background, rounded corners, etc.)
127
+
128
+ Use `ModalDialog` directly when you need full control over the content layout.
@@ -13,6 +13,12 @@
13
13
  preEscapeClose?: () => any;
14
14
  /** Pre-close hook. Return false to prevent close. */
15
15
  preClose?: () => any;
16
+ /** ID reference for aria-labelledby */
17
+ ariaLabelledby?: string;
18
+ /** ID reference for aria-describedby */
19
+ ariaDescribedby?: string;
20
+ /** Disable body scroll lock when dialog is open */
21
+ noScrollLock?: boolean;
16
22
  }
17
23
  </script>
18
24
 
@@ -37,6 +43,9 @@
37
43
  noEscapeClose,
38
44
  preEscapeClose,
39
45
  preClose,
46
+ ariaLabelledby,
47
+ ariaDescribedby,
48
+ noScrollLock,
40
49
  }: Props = $props();
41
50
 
42
51
  // important to start as undefined (because of scroll save/restore)
@@ -45,30 +54,42 @@
45
54
  let dialog = $state<HTMLDialogElement>()!;
46
55
  let box = $state<HTMLDivElement>()!;
47
56
  let _opener: undefined | null | HTMLElement = $state();
57
+ let _isClosing = false;
48
58
 
49
59
  export function open(openerOrEvent?: null | HTMLElement | MouseEvent) {
60
+ if (visible) return; // Already open
50
61
  visible = true;
51
62
  setOpener(
52
- (openerOrEvent as any)?.currentTarget ?? openerOrEvent ?? document.activeElement
63
+ openerOrEvent instanceof MouseEvent
64
+ ? (openerOrEvent.currentTarget as HTMLElement)
65
+ : openerOrEvent ?? (document.activeElement as HTMLElement)
53
66
  );
54
- // clog("will showModal");
55
67
  // dialog must be rendered in the DOM before it can be opened...
56
68
  waitForNextRepaint().then(() => {
57
- // clog("dialog.showModal()");
58
- dialog.showModal();
69
+ try {
70
+ dialog?.showModal();
71
+ } catch (e) {
72
+ console.error("ModalDialog: Failed to open dialog:", e);
73
+ visible = false;
74
+ }
59
75
  });
60
76
  }
61
77
 
62
78
  export function close() {
79
+ if (_isClosing || !visible) return;
80
+ _isClosing = true;
63
81
  (async () => {
64
- const allowed = await preClose?.();
65
- // explicit false prevents close
66
- if (allowed !== false) {
67
- // clog("dialog.close()");
68
- dialog?.close();
69
- visible = false;
70
- _opener?.focus();
71
- _opener = null;
82
+ try {
83
+ const allowed = await preClose?.();
84
+ // explicit false prevents close
85
+ if (allowed !== false) {
86
+ dialog?.close();
87
+ visible = false;
88
+ _opener?.focus();
89
+ _opener = null;
90
+ }
91
+ } finally {
92
+ _isClosing = false;
72
93
  }
73
94
  })();
74
95
  }
@@ -77,6 +98,14 @@
77
98
  _opener = opener;
78
99
  }
79
100
 
101
+ export function visibility() {
102
+ return {
103
+ get visible() {
104
+ return visible;
105
+ },
106
+ };
107
+ }
108
+
80
109
  onClickOutside(
81
110
  () => box,
82
111
  () => !noClickOutsideClose && close()
@@ -84,7 +113,7 @@
84
113
 
85
114
  $effect(() => {
86
115
  // noop if we're undefined ($effect runs immediately as onMount)
87
- if (visible === undefined) return;
116
+ if (visible === undefined || noScrollLock) return;
88
117
  visible ? BodyScroll.lock() : BodyScroll.unlock();
89
118
  });
90
119
 
@@ -101,6 +130,8 @@
101
130
  bind:this={dialog}
102
131
  use:focusTrap
103
132
  data-type={type}
133
+ aria-labelledby={ariaLabelledby}
134
+ aria-describedby={ariaDescribedby}
104
135
  class={twMerge(
105
136
  "stuic-modal-dialog",
106
137
  "fixed inset-4 m-auto size-auto",
@@ -109,6 +140,12 @@
109
140
  "bg-transparent backdrop:bg-black/40",
110
141
  classDialog
111
142
  )}
143
+ onclick={(e) => {
144
+ // Close when clicking directly on the dialog (backdrop area), not its children
145
+ if (e.target === dialog && !noClickOutsideClose) {
146
+ close();
147
+ }
148
+ }}
112
149
  onkeydown={async (e) => {
113
150
  if (e.key === "Escape" && visible) {
114
151
  // clog("on Escape keydown, preventing default and stopping propagation");
@@ -11,11 +11,20 @@ export interface Props {
11
11
  preEscapeClose?: () => any;
12
12
  /** Pre-close hook. Return false to prevent close. */
13
13
  preClose?: () => any;
14
+ /** ID reference for aria-labelledby */
15
+ ariaLabelledby?: string;
16
+ /** ID reference for aria-describedby */
17
+ ariaDescribedby?: string;
18
+ /** Disable body scroll lock when dialog is open */
19
+ noScrollLock?: boolean;
14
20
  }
15
21
  declare const ModalDialog: import("svelte").Component<Props, {
16
22
  open: (openerOrEvent?: null | HTMLElement | MouseEvent) => void;
17
23
  close: () => void;
18
24
  setOpener: (opener?: null | HTMLElement) => void;
25
+ visibility: () => {
26
+ readonly visible: boolean | undefined;
27
+ };
19
28
  }, "">;
20
29
  type ModalDialog = ReturnType<typeof ModalDialog>;
21
30
  export default ModalDialog;
@@ -8,11 +8,14 @@ A modal component using the native HTML `<dialog>` element with `showModal()`. P
8
8
  |------|------|---------|-------------|
9
9
  | `noClickOutsideClose` | `boolean` | `false` | Disable close on outside click |
10
10
  | `noEscapeClose` | `boolean` | `false` | Disable close on Escape key |
11
+ | `noScrollLock` | `boolean` | `false` | Disable body scroll lock |
11
12
  | `preEscapeClose` | `() => any` | - | Hook before Escape close (return `false` to prevent) |
12
13
  | `preClose` | `() => any` | - | Hook before any close (return `false` to prevent) |
13
14
  | `type` | `string` | - | Optional UI hint (added as `data-type` attribute) |
14
15
  | `class` | `string` | - | CSS for content box |
15
16
  | `classDialog` | `string` | - | CSS for dialog element |
17
+ | `ariaLabelledby` | `string` | - | ID reference for aria-labelledby |
18
+ | `ariaDescribedby` | `string` | - | ID reference for aria-describedby |
16
19
 
17
20
  ## Methods
18
21
 
@@ -21,6 +24,7 @@ A modal component using the native HTML `<dialog>` element with `showModal()`. P
21
24
  | `open(opener?)` | Open modal with `showModal()`, optionally track opener |
22
25
  | `close()` | Close modal |
23
26
  | `setOpener(el)` | Set element to refocus when closed |
27
+ | `visibility()` | Returns object with `visible` getter |
24
28
 
25
29
  ## Usage
26
30
 
@@ -92,12 +96,27 @@ A modal component using the native HTML `<dialog>` element with `showModal()`. P
92
96
  </ModalDialog>
93
97
  ```
94
98
 
95
- ## Differences from Modal
99
+ ### Check Visibility State
96
100
 
97
- | Feature | Modal | ModalDialog |
98
- |---------|-------|-------------|
99
- | Implementation | Custom backdrop | Native `<dialog>` |
100
- | Backdrop | Via `Backdrop` component | Native `::backdrop` |
101
- | Browser support | All modern | Requires `<dialog>` support |
102
- | Stacking | Manual z-index | Top layer (always on top) |
103
- | Accessibility | Manual ARIA | Built-in |
101
+ ```svelte
102
+ <script lang="ts">
103
+ let dialog: ModalDialog;
104
+
105
+ function logVisibility() {
106
+ console.log('Is visible:', dialog.visibility().visible);
107
+ }
108
+ </script>
109
+ ```
110
+
111
+ ## Relationship to Modal
112
+
113
+ `Modal` is a higher-level component built on top of `ModalDialog`.
114
+
115
+ | Feature | ModalDialog | Modal |
116
+ |---------|-------------|-------|
117
+ | Layout | Raw content | Header/main/footer structure |
118
+ | Styling | Minimal | Pre-styled box with backgrounds |
119
+ | Sizing | Manual | Responsive (fullscreen mobile, centered desktop) |
120
+ | Use case | Full control | Quick conventional modals |
121
+
122
+ Use `ModalDialog` when you need complete control over the modal content and styling. Use `Modal` for conventional modal dialogs with header/footer sections.