@rozie-ui/dialog-svelte 0.0.0-stage → 0.2.4

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 ADDED
@@ -0,0 +1,46 @@
1
+ # @rozie-ui/dialog-svelte
2
+
3
+ ## 0.2.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 05cb9db: Regenerated with the compiler fixes found while building `@rozie-ui/select`. Depending on the target and the component:
8
+ - Angular: a two-way (`model: true`) prop is now controlled while the consumer binds a value — a write by the component only emits `<name>Change`, so a consumer that refuses the change keeps the control on its value. The prop keeps its `ModelSignal` type. `$onUpdate` hooks now run (after every render); they never ran before. An output named like a native bubbling event (`change`, `submit`, `reset`, `select`) no longer also delivers the native event to a consumer's binding.
9
+ - Vue: a two-way prop bound one-way (`:value` without `v-model`) is now controlled too: the component's write emits `update:value` and the control keeps the consumer's value.
10
+ - React: an `$expose` verb now runs in `flushSync`, so calls made in one tick each see what the call before left; primitive script constants are plain `const`s; a body statement no longer reads a binding declared after it on the first render.
11
+ - Svelte, Solid: a `$watch` now sees a `$data` write made in `$onMount`.
12
+ - Lit: a `$watch` whose getter reads both props and `$data` re-runs when only the prop changes; duplicated comments in lifecycle bodies are gone.
13
+
14
+ - 05cb9db: First release of `@rozie-ui/dialog` on React, Vue, Svelte, Angular and Lit. The Solid leaf has been on npm since the 2026-09-26 wave; all six targets of the family now ship at the same version, so one version number means one component revision everywhere.
15
+
16
+ The headless, accessible modal dialog is built on the native `<dialog>` element and `showModal()`: top-layer rendering without a portal, a native `::backdrop` scrim, a real focus trap, Escape-to-dismiss, focus restoration, and a ref-counted `<html>` scroll lock that survives nested dialogs. It has a two-way `open` binding, a typed `close` event (`{ reason: 'backdrop' | 'escape' | 'programmatic' }`), an `initialFocus` prop, `show()` / `hide()` handle verbs, and design-system token bridges for shadcn/ui, Material 3 and Bootstrap 5.
17
+
18
+ The Vue leaf ships a `web-types.json` and the Lit leaf a `custom-elements.json`, so JetBrains IDEs and `lit-plugin` complete the props, events and slots.
19
+
20
+ - 05cb9db: Dialog fixes found by the pre-release audit (all six targets):
21
+ - A press that started inside the panel (selecting text, a drag) and was released over the scrim closed the dialog with reason `'backdrop'`. It no longer does; only a press that starts on the scrim is a backdrop click.
22
+ - A native close that did not go through the component (a `<form method="dialog">` submit inside the panel, or a stray `dialog.close()`) left `open` set to `true` over a closed dialog, which then could not be reopened. The `open` model now follows, and `close` fires with `reason: 'programmatic'`.
23
+ - `hide()` on a dialog that is not open no longer writes the model or emits a `close` for a dismissal that never happened.
24
+ - Lit: `ariaLabelledby` could not reach a heading in the slotted content (an `aria-labelledby` id does not cross a shadow root), so the dialog had no accessible name. It is now bridged with `ariaLabelledByElements` where the browser supports it (Chrome and Edge 135+, Safari 16.4+); elsewhere use `ariaLabel`.
25
+ - The handle's `show()` and `hide()` are typed `() => void` on every target (React, Solid and Lit typed them `(...args: any[]) => any`). A call with an argument is now a type error.
26
+ - `ariaLabel` and `ariaLabelledby` are dropped from the `<dialog>` when they are `null`, so a strict consumer compiling the Vue or React source typechecks.
27
+
28
+ - 05cb9db: A `Dialog` removed while open now releases its scroll lock and returns focus; a dialog that mounts closed no longer releases another open dialog's lock; new `initialFocus` prop (a selector or Element focused when the dialog opens).
29
+ - 05cb9db: Dialog: the `close` event handler now has a real payload type instead of `(...args: any[]) => void`. It receives `DialogClosePayload` (`{ reason: DialogCloseReason }`, where the reason is `'backdrop' | 'escape' | 'programmatic'`). Both types are exported from the package entry. A handler written against a different payload shape may now be rejected by the type checker.
30
+ - 05cb9db: Props types now accept the root element's HTML attributes when a component passes attributes through to a single root element (typed public surface, phase 3).
31
+ - React: `interface XProps extends Omit<React.ComponentPropsWithoutRef<'<tag>'>, …>` — `className`, `style`, `id`, `aria-*`, `data-*`, element-specific attributes (`disabled` on a `<button>` root, …) and DOM listeners typecheck without a cast.
32
+ - Solid: the same with `ComponentProps<'<tag>'>`.
33
+ - Svelte: `interface Props extends Omit<SvelteHTMLElements['<tag>'], …>`; an attribute the root element does not support is a type error.
34
+ - The component's own props win on a name collision. `children` stays rejected when the component has no default slot, as do the content-replacing `dangerouslySetInnerHTML` (React) and `innerHTML` / `innerText` / `textContent` (Solid).
35
+ - An `<svg>` root gets the SVG element's attributes (`fill`, `stroke`, `viewBox`, …). Any other non-HTML root (a custom element) gets the generic `HTMLAttributes<HTMLElement>` on React and Solid, and Svelte's permissive custom-element entry.
36
+ - Components with `inherit-attrs="false"`, an `r-if` root or a component root are unchanged. Vue, Angular and Lit are unchanged; they already accepted pass-through attributes.
37
+
38
+ The `.d.rozie.ts` sidecars (React, Solid, Svelte) carry the same types. Types only — no runtime change.
39
+ - @rozie/runtime-svelte@0.10.1
40
+
41
+ ## 0.1.1
42
+
43
+ ### Patch Changes
44
+
45
+ - Updated dependencies [364f4c5]
46
+ - @rozie/runtime-svelte@0.2.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 One Learning Community LTD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # @rozie-ui/dialog-svelte
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Idiomatic **svelte** `Dialog` — a headless, fully-accessible **modal dialog** built on the browser's native `<dialog>` element + `showModal()`, compiled from one [Rozie](https://github.com/One-Learning-Community/rozie.js) source. The platform IS the engine: top-layer rendering that escapes `z-index` / `overflow` / `transform` ancestors with **no portal/teleport**, a native `::backdrop` scrim, a real focus trap, Esc-to-dismiss, and focus restoration on close — all for free. Rozie owns the author-side API: the two-way `open` binding, the open↔native reconcile, backdrop/escape close policy, optional scroll-lock, and a fully-tokenised skin. This package is generated; do not edit `src/` by hand.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i @rozie-ui/dialog-svelte
9
+ ```
10
+
11
+ Peer dependencies: `svelte`. Install them alongside this package.
12
+
13
+ Also installed: `@rozie/runtime-svelte` — Rozie's small, tree-shaken runtime helper package (controllable state, keyboard navigation, event modifiers, and safe interpolation). It arrives as a regular dependency, so npm pulls it for you. Your bundler keeps only the helpers this component actually uses — typically a few hundred bytes to a few KB, minified and gzipped. [What's in it and what it costs](https://github.com/One-Learning-Community/rozie.js/blob/main/docs/guide/output-and-runtime.md).
14
+
15
+ ## Usage
16
+
17
+ ```svelte
18
+ <script lang="ts">
19
+ import Dialog from '@rozie-ui/dialog-svelte';
20
+
21
+ let open = $state(false);
22
+ </script>
23
+
24
+ <button onclick={() => (open = true)}>Open dialog</button>
25
+
26
+ <Dialog
27
+ bind:open
28
+ ariaLabelledby="confirm-title"
29
+ onclose={(e) => console.log('closed:', e.reason)}
30
+ >
31
+ <h2 id="confirm-title">Delete file?</h2>
32
+ <p>This cannot be undone.</p>
33
+ <button onclick={() => (open = false)}>Cancel</button>
34
+ <button onclick={() => (open = false)}>Delete</button>
35
+ </Dialog>
36
+ ```
37
+
38
+ ## Theming
39
+
40
+ Every cosmetic value is a `--rozie-dialog-*` CSS custom property — override any of them at any ancestor scope. The structural behaviour (top-layer, `::backdrop`, focus trap, centering) comes from the native `<dialog>` and is not tokenised. Ready-made design-system bridges ship in the package:
41
+
42
+ ```svelte
43
+ import '@rozie-ui/dialog-svelte/themes/shadcn.css'; // or material.css, bootstrap.css, base.css
44
+ ```
45
+
46
+ ## Props
47
+
48
+ | Name | Type | Default | Two-way (model) | Required |
49
+ | --- | --- | --- | :---: | :---: |
50
+ | `open` | `Boolean` | `false` | ✓ | |
51
+ | `disableBackdropClose` | `Boolean` | `false` | | |
52
+ | `disableEscapeClose` | `Boolean` | `false` | | |
53
+ | `disableScrollLock` | `Boolean` | `false` | | |
54
+ | `initialFocus` | `String \| Element` | `null` | | |
55
+ | `ariaLabel` | `String` | `null` | | |
56
+ | `ariaLabelledby` | `String` | `null` | | |
57
+
58
+ ## Events
59
+
60
+ | Event | Description |
61
+ | --- | --- |
62
+ | `close` | Fired whenever the dialog dismisses — through a backdrop click, the Escape key, a programmatic `hide()`, or a native close that did not go through the component (such as a `<form method="dialog">` submit). Payload `{ reason }` where `reason` is `'backdrop'`, `'escape'`, or `'programmatic'` (the last covers `hide()` and any native close). The two-way `open` model is set to `false` on the same tick, so you usually only need this to learn *why* it closed (e.g. to skip a confirmation on an explicit Cancel). |
63
+
64
+ ## Imperative handle
65
+
66
+ Beyond props, the component exposes imperative methods (declared once in the Rozie source via `$expose`). Grab a handle with the native ref mechanism and call them directly. The verbs are `show()` / `hide()` (not `open`/`close`) — `open` is the model prop and `close` is the dismiss event, so these names sidestep both collisions and are not inherited `HTMLElement` members:
67
+
68
+ | Method | Description |
69
+ | --- | --- |
70
+ | `show` | Open the dialog imperatively (sets the two-way `open` model to `true`). Equivalent to a consumer write of `open = true`; the native `<dialog>.showModal()` runs on the next reconcile. |
71
+ | `hide` | Close the dialog imperatively. Sets `open` to `false` and emits `close` with `{ reason: 'programmatic' }`. A no-op when the dialog is not open (nothing is dismissed, so no `close` is emitted). |
72
+
73
+ ```svelte
74
+ <script>
75
+ let dialog; // component instance via bind:this
76
+ </script>
77
+
78
+ <Dialog bind:this={dialog} bind:open><!-- … --></Dialog>
79
+ <button onclick={() => dialog.show()}>Open</button>
80
+ <button onclick={() => dialog.hide()}>Close</button>
81
+ ```
82
+
83
+ ## Slots
84
+
85
+ | Slot | Params |
86
+ | --- | --- |
87
+ | (default) | |
@@ -0,0 +1,320 @@
1
+ <script module lang="ts">
2
+ /** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
3
+ export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
4
+ /** The `close` event payload. */
5
+ export interface DialogClosePayload {
6
+ reason: DialogCloseReason;
7
+ }
8
+ </script>
9
+ <script lang="ts">
10
+ import { applyListeners } from '@rozie/runtime-svelte';
11
+
12
+ import type { Snippet } from 'svelte';
13
+ import { onDestroy, onMount, untrack } from 'svelte';
14
+
15
+ interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['dialog'], 'open' | 'disableBackdropClose' | 'disableEscapeClose' | 'disableScrollLock' | 'initialFocus' | 'ariaLabel' | 'ariaLabelledby' | 'children' | 'snippets' | 'onclose'> {
16
+ /**
17
+ * Whether the dialog is shown (two-way `r-model`). The sole `model: true` prop — two-way bind it (`r-model:open` / `v-model:open` / `bind:open` / `[(open)]`) and Dialog reconciles the native `<dialog>` to it via `showModal()` / `close()`. Every close path (backdrop, Escape, programmatic `hide()`) writes `open = false` and emits `close`.
18
+ * @example
19
+ * <Dialog bind:open={confirmOpen} ariaLabelledby="confirm-title" />
20
+ */
21
+ open?: boolean;
22
+ /**
23
+ * Opt **out** of backdrop-click-to-dismiss. By default a click on the scrim (the `<dialog>` element itself, outside the content panel) closes the dialog with `reason: 'backdrop'`; set this to require an explicit action.
24
+ */
25
+ disableBackdropClose?: boolean;
26
+ /**
27
+ * Opt **out** of Escape-to-dismiss. By default the native `cancel` event (Esc) closes with `reason: 'escape'`; the component `preventDefault()`s it so the close always flows through the `open` model. Set this to keep the dialog open on Escape (e.g. a required confirmation).
28
+ */
29
+ disableEscapeClose?: boolean;
30
+ /**
31
+ * Opt **out** of locking `<html>` scroll while the dialog is open. By default `document.documentElement` `overflow` is set to `hidden` for the duration the dialog is shown; set this to leave background scrolling enabled.
32
+ */
33
+ disableScrollLock?: boolean;
34
+ /**
35
+ * What to focus when the dialog opens: a CSS selector matched inside the dialog content, or an Element. By default the native `showModal()` choice applies: the first element with `autofocus`, otherwise the first focusable element. Use it to start on a specific field (e.g. `initialFocus="input[name=title]"`) without waiting for the dialog to mount. A selector that matches nothing, or an element that is not focusable, leaves the native choice in place.
36
+ * @example
37
+ * <Dialog bind:open={renameOpen} initialFocus="#label-name" />
38
+ */
39
+ initialFocus?: (string | Element) | null;
40
+ /**
41
+ * Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
42
+ */
43
+ ariaLabel?: (string) | null;
44
+ /**
45
+ * The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog. On the Lit element the dialog lives in a shadow root while the heading is slotted light DOM, so the attribute alone cannot reach it; Dialog hands the browser the element directly (`ariaLabelledByElements`) where that is supported (Chrome/Edge 135+, Safari 16.4+) — elsewhere, use `ariaLabel` on Lit.
46
+ */
47
+ ariaLabelledby?: (string) | null;
48
+ children?: Snippet;
49
+ snippets?: Record<string, any>;
50
+ onclose?: (payload: DialogClosePayload) => void;
51
+ }
52
+
53
+ let {
54
+ open = $bindable(false),
55
+ disableBackdropClose = false,
56
+ disableEscapeClose = false,
57
+ disableScrollLock = false,
58
+ initialFocus = null,
59
+ ariaLabel = null,
60
+ ariaLabelledby = null,
61
+ children: __childrenProp,
62
+ snippets,
63
+ onclose,
64
+ ...__rozieAttrs
65
+ }: Props = $props();
66
+
67
+ const children = $derived(__childrenProp ?? snippets?.children);
68
+
69
+ let panelEl = $state<HTMLElement | undefined>(undefined);
70
+
71
+ import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock';
72
+ // ---- native reconcile ---------------------------------------------------
73
+ // The <dialog> element, cached by sync() so $onUnmount can reach it without
74
+ // reading $refs during teardown.
75
+ let dialogEl: HTMLDialogElement | null = null;
76
+ // Whether THIS instance currently holds one count of the shared scroll lock.
77
+ let holdsLock = false;
78
+ // The element focused when the dialog opened, for the unmount-while-open
79
+ // focus return (a normal close gets the native return from close()).
80
+ let returnFocusTo: HTMLElement | null = null;
81
+ // Whether the press that is about to produce a `click` began INSIDE the panel.
82
+ // A press that starts on content (selecting text, a drag) and is released over
83
+ // the scrim yields a `click` whose target is the common ancestor, the <dialog>
84
+ // itself; without this it would be mistaken for a backdrop click.
85
+ let pressStartedInside = false;
86
+ // Lock/unlock <html> scroll for this instance. The actual lock/unlock is
87
+ // REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
88
+ // leaf's module, because a naive per-instance toggle unlocks scrolling the
89
+ // moment ANY dialog closes, even while an OUTER dialog is still open. This
90
+ // wrapper releases only a count this instance took: a dialog that mounts closed
91
+ // (or closes twice) must not release a count another open dialog holds. The
92
+ // opt-out is read when locking only, so toggling it while open still releases.
93
+ const setScrollLock = (lock: any) => {
94
+ if (lock === holdsLock) return;
95
+ if (lock && disableScrollLock) return;
96
+ holdsLock = lock;
97
+ applySharedScrollLock(lock);
98
+ };
99
+ // The element that really has focus. `document.activeElement` stops at the
100
+ // outermost shadow host (on Lit the trigger lives inside a component's shadow
101
+ // root, so it reports that component), so walk each shadowRoot.activeElement.
102
+ const deepActiveElement = () => {
103
+ let active: any = typeof document === 'undefined' ? null : document.activeElement;
104
+ while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
105
+ return active;
106
+ };
107
+ // Focus `initialFocus` after showModal() has made its native choice. A selector
108
+ // is matched inside the panel and, on Lit, inside the light-DOM content
109
+ // assigned to the panel's <slot> (which panel.querySelector cannot see).
110
+ const focusInitial = (panel: any) => {
111
+ const target: any = initialFocus;
112
+ if (!target) return;
113
+ let node: any = null;
114
+ if (typeof target === 'string') {
115
+ node = panel.querySelector(target);
116
+ const slot: any = node ? null : panel.querySelector('slot');
117
+ const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({
118
+ flatten: true
119
+ }) : [];
120
+ for (let i = 0; !node && i < assigned.length; i++) {
121
+ node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target);
122
+ }
123
+ } else {
124
+ node = target;
125
+ }
126
+ if (node && typeof node.focus === 'function') node.focus();
127
+ };
128
+ // `aria-labelledby` is an IDREF, and an IDREF never crosses a shadow-root
129
+ // boundary. Where the <dialog> lives inside an element's shadow root (the Lit
130
+ // target) the heading it names is slotted LIGHT DOM, so the attribute resolves to
131
+ // nothing and the dialog has no accessible name. Element reflection can point
132
+ // across the boundary (to an element in an ancestor tree), so when the id is not
133
+ // reachable from the dialog's own root, hand the browser the element directly.
134
+ // A no-op outside a shadow root, and where the browser lacks the property.
135
+ const bridgeLabelledBy = (el: any) => {
136
+ const id = ariaLabelledby;
137
+ const root: any = el.getRootNode();
138
+ if (!id || !root || !root.host || typeof root.getElementById !== 'function') return;
139
+ if (root.getElementById(id) || !('ariaLabelledByElements' in el)) return;
140
+ const outer: any = root.host.getRootNode();
141
+ const heading = outer && typeof outer.getElementById === 'function' ? outer.getElementById(id) : null;
142
+ if (heading) el.ariaLabelledByElements = [heading];
143
+ };
144
+ // Reconcile the native <dialog> to the desired open state. Guarded on the
145
+ // native `el.open` flag (showModal throws if already open; close is a no-op when
146
+ // closed). Reads $refs in a post-mount callback (ROZ123-safe).
147
+ //
148
+ // The ref lives on the inner panel <div> (which the emitter types as
149
+ // HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
150
+ // HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
151
+ // has no `dialog` case, so a ref placed directly on <dialog> would be typed the
152
+ // generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
153
+ // leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
154
+ const sync = (isOpen: any) => {
155
+ const panel = panelEl;
156
+ const el = (panel && panel.parentElement) as HTMLDialogElement | null;
157
+ if (!el) return;
158
+ dialogEl = el;
159
+ if (isOpen) {
160
+ if (!el.open) {
161
+ const active = deepActiveElement();
162
+ returnFocusTo = active instanceof HTMLElement ? active : null;
163
+ bridgeLabelledBy(el);
164
+ el.showModal();
165
+ focusInitial(panel);
166
+ }
167
+ setScrollLock(true);
168
+ } else {
169
+ if (el.open) el.close();
170
+ returnFocusTo = null;
171
+ setScrollLock(false);
172
+ }
173
+ };
174
+ // ---- close funnel (single $emit site) ----------------------------------
175
+ const closeWith = (reason: any) => {
176
+ open = false;
177
+ onclose?.({
178
+ reason
179
+ });
180
+ };
181
+ // ---- handlers ----------------------------------------------------------
182
+ // Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
183
+ // close through the model (keeping `open` in sync); honor the opt-out.
184
+ const onCancel = (e: any) => {
185
+ if (e) e.preventDefault();
186
+ if (disableEscapeClose) return;
187
+ closeWith('escape');
188
+ };
189
+ // Remember whether a press began on the panel's content (anywhere but the
190
+ // <dialog> box itself), so the `click` that follows can tell a real scrim click
191
+ // from the end of a drag that merely finished over the scrim.
192
+ const onPointerDown = (e: any) => {
193
+ const panel = panelEl;
194
+ const el = panel && panel.parentElement;
195
+ pressStartedInside = !!(e && el && e.target !== el);
196
+ };
197
+ // A click whose target IS the <dialog> element (not its panel/children) is a
198
+ // backdrop click — the ::backdrop is part of the dialog box. We compare the
199
+ // real `e.target` (reliable even under Solid's event delegation) to the dialog
200
+ // element resolved via the panel ref's parent. A click whose press started
201
+ // inside the panel is not one (see `pressStartedInside`).
202
+ const onClick = (e: any) => {
203
+ const startedInside = pressStartedInside;
204
+ pressStartedInside = false;
205
+ if (disableBackdropClose || startedInside) return;
206
+ const panel = panelEl;
207
+ const el = panel && panel.parentElement;
208
+ if (e && el && e.target === el) closeWith('backdrop');
209
+ };
210
+ // The native `close` event: fired by `el.close()` from our own reconcile (by then
211
+ // the model is already false, and the dialog may even have been re-opened), but
212
+ // also by closes WE did not drive — a `<form method="dialog">` submit, a stray
213
+ // `dialog.close()`. For those the model is still true over a closed dialog and
214
+ // would stay stuck (writing `open = true` again changes nothing), so fold them
215
+ // into the close funnel. Not during teardown: $onUnmount closes the dialog
216
+ // itself after clearing `dialogEl`.
217
+ const onNativeClose = () => {
218
+ const el = dialogEl;
219
+ if (!el || el.open || !open) return;
220
+ closeWith('programmatic');
221
+ };
222
+
223
+ // ---- lifecycle ---------------------------------------------------------
224
+ // ---- imperative handle -------------------------------------------------
225
+ // show()/hide() — named to avoid the `open` model + `@close` event collisions.
226
+ // hide() on a dialog that is not open is a no-op: there is nothing to dismiss, so
227
+ // it must not emit a `close` for a dismissal that never happened.
228
+ export const show: () => void = () => {
229
+ open = true;
230
+ };
231
+ export const hide: () => void = () => {
232
+ if (!open) return;
233
+ closeWith('programmatic');
234
+ };
235
+
236
+ let __rozieWatchInitial_0 = true;
237
+ $effect(() => { const __watchVal = (() => open)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((isOpen: any) => {
238
+ sync(isOpen);
239
+ })(__watchVal); }); });
240
+
241
+ onMount(() => {
242
+ sync(open);
243
+ });
244
+ onDestroy(() => (() => {
245
+ setScrollLock(false);
246
+ pressStartedInside = false;
247
+ const el = dialogEl;
248
+ const back = returnFocusTo;
249
+ dialogEl = null;
250
+ returnFocusTo = null;
251
+ if (!el || !el.open) return;
252
+ if (el.isConnected) el.close();
253
+ if (!back) return;
254
+ setTimeout(() => {
255
+ // Lost: on <body>, or stranded on a shadow host whose root holds no focus
256
+ // and which is not focusable itself (the dialog's removed subtree was
257
+ // inside it).
258
+ const active = deepActiveElement();
259
+ const lost = !active || active === document.body || !!active.shadowRoot && active.tabIndex < 0;
260
+ if (back.isConnected && lost) back.focus();
261
+ }, 0);
262
+ })());
263
+ </script>
264
+
265
+ <dialog aria-label={ariaLabel} aria-labelledby={ariaLabelledby} {...__rozieAttrs} class={["rozie-dialog", (__rozieAttrs)?.class]} oncancel={($event) => { onCancel($event); }} onclose={($event) => { onNativeClose(); }} onpointerdown={($event) => { onPointerDown($event); }} onclick={($event) => { onClick($event); }} use:applyListeners={__rozieAttrs} data-rozie-s-2a679072><div class="rozie-dialog-panel" bind:this={panelEl} data-rozie-s-2a679072>{@render children?.()}</div></dialog>
266
+
267
+ <style>
268
+ :global {
269
+ @media (prefers-reduced-motion: no-preference) {
270
+ .rozie-dialog[data-rozie-s-2a679072] {
271
+ transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), transform var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
272
+ opacity: 1;
273
+ transform: translateY(0) scale(1);
274
+ }
275
+ .rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072]) {
276
+ opacity: 0;
277
+ transform: translateY(0.5rem) scale(0.98);
278
+ }
279
+ @starting-style {
280
+ .rozie-dialog[open][data-rozie-s-2a679072] {
281
+ opacity: 0;
282
+ transform: translateY(0.5rem) scale(0.98);
283
+ }
284
+ }
285
+ .rozie-dialog[data-rozie-s-2a679072]::backdrop {
286
+ transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
287
+ opacity: 1;
288
+ }
289
+ .rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072])::backdrop {
290
+ opacity: 0;
291
+ }
292
+ @starting-style {
293
+ .rozie-dialog[open][data-rozie-s-2a679072]::backdrop {
294
+ opacity: 0;
295
+ }
296
+ }
297
+ }
298
+ .rozie-dialog[data-rozie-s-2a679072] {
299
+ margin: auto; /* centers in the top layer */
300
+ padding: 0;
301
+ width: var(--rozie-dialog-width, var(--rdg-width, auto));
302
+ max-width: var(--rozie-dialog-max-width, var(--rdg-max-width, min(32rem, calc(100vw - 2rem))));
303
+ max-height: var(--rozie-dialog-max-height, var(--rdg-max-height, calc(100vh - 2rem)));
304
+ border: var(--rozie-dialog-border, var(--rdg-border, none));
305
+ border-radius: var(--rozie-dialog-radius, var(--rdg-radius, 0.75rem));
306
+ background: var(--rozie-dialog-bg, var(--rdg-bg, #fff));
307
+ color: var(--rozie-dialog-color, var(--rdg-color, inherit));
308
+ box-shadow: var(--rozie-dialog-shadow, var(--rdg-shadow, 0 10px 38px rgba(0, 0, 0, 0.35), 0 0 1px rgba(0, 0, 0, 0.25)));
309
+ overflow: auto;
310
+ }
311
+ .rozie-dialog[data-rozie-s-2a679072]::backdrop {
312
+ background: var(--rozie-dialog-backdrop-bg, var(--rdg-backdrop-bg, rgba(0, 0, 0, 0.5)));
313
+ backdrop-filter: var(--rozie-dialog-backdrop-filter, var(--rdg-backdrop-filter, none));
314
+ }
315
+ .rozie-dialog-panel[data-rozie-s-2a679072] {
316
+ padding: var(--rozie-dialog-padding, var(--rdg-padding, 1.5rem));
317
+ font: var(--rozie-dialog-font, inherit);
318
+ }
319
+ }
320
+ </style>
@@ -0,0 +1,50 @@
1
+ /** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
2
+ export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
3
+ /** The `close` event payload. */
4
+ export interface DialogClosePayload {
5
+ reason: DialogCloseReason;
6
+ }
7
+ import type { Snippet } from 'svelte';
8
+ interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['dialog'], 'open' | 'disableBackdropClose' | 'disableEscapeClose' | 'disableScrollLock' | 'initialFocus' | 'ariaLabel' | 'ariaLabelledby' | 'children' | 'snippets' | 'onclose'> {
9
+ /**
10
+ * Whether the dialog is shown (two-way `r-model`). The sole `model: true` prop — two-way bind it (`r-model:open` / `v-model:open` / `bind:open` / `[(open)]`) and Dialog reconciles the native `<dialog>` to it via `showModal()` / `close()`. Every close path (backdrop, Escape, programmatic `hide()`) writes `open = false` and emits `close`.
11
+ * @example
12
+ * <Dialog bind:open={confirmOpen} ariaLabelledby="confirm-title" />
13
+ */
14
+ open?: boolean;
15
+ /**
16
+ * Opt **out** of backdrop-click-to-dismiss. By default a click on the scrim (the `<dialog>` element itself, outside the content panel) closes the dialog with `reason: 'backdrop'`; set this to require an explicit action.
17
+ */
18
+ disableBackdropClose?: boolean;
19
+ /**
20
+ * Opt **out** of Escape-to-dismiss. By default the native `cancel` event (Esc) closes with `reason: 'escape'`; the component `preventDefault()`s it so the close always flows through the `open` model. Set this to keep the dialog open on Escape (e.g. a required confirmation).
21
+ */
22
+ disableEscapeClose?: boolean;
23
+ /**
24
+ * Opt **out** of locking `<html>` scroll while the dialog is open. By default `document.documentElement` `overflow` is set to `hidden` for the duration the dialog is shown; set this to leave background scrolling enabled.
25
+ */
26
+ disableScrollLock?: boolean;
27
+ /**
28
+ * What to focus when the dialog opens: a CSS selector matched inside the dialog content, or an Element. By default the native `showModal()` choice applies: the first element with `autofocus`, otherwise the first focusable element. Use it to start on a specific field (e.g. `initialFocus="input[name=title]"`) without waiting for the dialog to mount. A selector that matches nothing, or an element that is not focusable, leaves the native choice in place.
29
+ * @example
30
+ * <Dialog bind:open={renameOpen} initialFocus="#label-name" />
31
+ */
32
+ initialFocus?: (string | Element) | null;
33
+ /**
34
+ * Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
35
+ */
36
+ ariaLabel?: (string) | null;
37
+ /**
38
+ * The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog. On the Lit element the dialog lives in a shadow root while the heading is slotted light DOM, so the attribute alone cannot reach it; Dialog hands the browser the element directly (`ariaLabelledByElements`) where that is supported (Chrome/Edge 135+, Safari 16.4+) — elsewhere, use `ariaLabel` on Lit.
39
+ */
40
+ ariaLabelledby?: (string) | null;
41
+ children?: Snippet;
42
+ snippets?: Record<string, any>;
43
+ onclose?: (payload: DialogClosePayload) => void;
44
+ }
45
+ declare const Dialog: import("svelte").Component<Props, {
46
+ show: () => void;
47
+ hide: () => void;
48
+ }, "open">;
49
+ type Dialog = ReturnType<typeof Dialog>;
50
+ export default Dialog;
@@ -0,0 +1,3 @@
1
+ export declare function applyScrollLock(lock: boolean): void;
2
+ /** Test-only: reset the module-level counter between test cases. */
3
+ export declare function _resetScrollLockForTests(): void;
@@ -0,0 +1,47 @@
1
+ // scrollLock.ts — a REF-COUNTED `<html>` scroll lock, shared by every `Dialog`
2
+ // instance compiled from this leaf's module (module-scope state — a plain
3
+ // per-instance toggle would unlock scrolling the moment ANY dialog closes,
4
+ // even while an OUTER dialog is still open — nested/stacked dialogs).
5
+ //
6
+ // The lock only actually applies on the 0 -> 1 transition and only actually
7
+ // releases on the 1 -> 0 transition. The ORIGINAL inline `overflow` value
8
+ // (captured on that first lock, not a hardcoded `''`) is restored when the
9
+ // count drops back to zero, so a page that already set its own inline
10
+ // `overflow` isn't clobbered by the second (or third...) dialog to close.
11
+ //
12
+ // Vendored (byte-identical) into every `@rozie-ui/dialog-*` leaf's
13
+ // `src/internal/` by `scripts/codegen.mjs`, unit-tested once here — the same
14
+ // "branchy, non-reactive helper -> src/internal" convention as the resizable
15
+ // family's `resizeMath.ts`.
16
+ let lockCount = 0;
17
+ let previousOverflow = null;
18
+ export function applyScrollLock(lock) {
19
+ if (typeof document === 'undefined')
20
+ return;
21
+ const root = document.documentElement;
22
+ if (!root)
23
+ return;
24
+ if (lock) {
25
+ if (lockCount === 0) {
26
+ previousOverflow = root.style.overflow;
27
+ root.style.overflow = 'hidden';
28
+ }
29
+ lockCount += 1;
30
+ return;
31
+ }
32
+ // Guard against an unbalanced release (a lock() that was never applied,
33
+ // e.g. because `disableScrollLock` was toggled mid-flight) — never let the
34
+ // count go negative, which would require two extra locks to re-lock.
35
+ if (lockCount === 0)
36
+ return;
37
+ lockCount -= 1;
38
+ if (lockCount === 0) {
39
+ root.style.overflow = previousOverflow ?? '';
40
+ previousOverflow = null;
41
+ }
42
+ }
43
+ /** Test-only: reset the module-level counter between test cases. */
44
+ export function _resetScrollLockForTests() {
45
+ lockCount = 0;
46
+ previousOverflow = null;
47
+ }
@@ -0,0 +1,58 @@
1
+ /*
2
+ * @rozie-ui/dialog — base token reference.
3
+ *
4
+ * Every COSMETIC value the dialog box + scrim render is a `--rozie-dialog-*`
5
+ * custom property. The component ships with inline `var(token, fallback)`
6
+ * defaults, so importing this file is OPTIONAL — it simply makes the full token
7
+ * surface explicit in one place so you can copy/override exactly what you need.
8
+ *
9
+ * The STRUCTURAL behaviour — top-layer rendering, the native `::backdrop`
10
+ * pseudo-element, UA centering, the focus trap, Esc-to-dismiss, and focus
11
+ * restoration — all come from the native `<dialog>` + `showModal()` and are NOT
12
+ * tokenized. Only the values below flow through tokens.
13
+ *
14
+ * Override any token at any ancestor scope (`:root`, `.dark`, a wrapper, or the
15
+ * `.rozie-dialog` element). See themes/shadcn.css, themes/material.css and
16
+ * themes/bootstrap.css for design-system bridges.
17
+ *
18
+ * WHERE THE DEFAULTS SIT. This file declares no public token that could shadow yours. Each
19
+ * default is the FALLBACK of a private wiring line on the document root, under `:where(:root)`
20
+ * (zero specificity): `--rdg-x: var(--rozie-dialog-x, <default>)`. The component reads every token
21
+ * public-first, `var(--rozie-dialog-x, var(--rdg-x, <inline default>))`, so a value set on ANY
22
+ * ancestor (`:root`, `.dark`, a wrapper) or on the component itself wins by plain inheritance,
23
+ * in any import order, on all six targets (Lit included: custom properties inherit into its
24
+ * shadow root). A value an element declares for itself always beats one it would inherit,
25
+ * which is why nothing on the way to the component may declare a public token.
26
+ * The lines that say `inherit` / `initial` stay plain declarations: at the root such a value
27
+ * is "unset", so the component's own fallback applies, and it never shadows an ancestor.
28
+ * A design-system bridge (themes/shadcn.css, material.css, bootstrap.css) re-declares only the
29
+ * wiring of the tokens it maps, with the design system's variable as the fallback, on the
30
+ * component (`.rozie-dialog` and the Lit host `rozie-dialog`) — so that variable is resolved AT the
31
+ * component and follows the nearest theme scope — and on `:root`, the copy a Lit component
32
+ * nested in another shadow root (out of reach of every document selector) inherits. The
33
+ * `--rdg-*` names are internal: not a stable API.
34
+ */
35
+ :where(:root) {
36
+ /* box geometry */
37
+ --rdg-width: var(--rozie-dialog-width, auto);
38
+ --rdg-max-width: var(--rozie-dialog-max-width, min(32rem, calc(100vw - 2rem)));
39
+ --rdg-max-height: var(--rozie-dialog-max-height, calc(100vh - 2rem));
40
+
41
+ /* box chrome */
42
+ --rdg-border: var(--rozie-dialog-border, none);
43
+ --rdg-radius: var(--rozie-dialog-radius, 0.75rem);
44
+ --rdg-bg: var(--rozie-dialog-bg, #fff);
45
+ --rozie-dialog-color: inherit;
46
+ --rdg-shadow: var(--rozie-dialog-shadow, 0 10px 38px rgba(0, 0, 0, 0.35), 0 0 1px rgba(0, 0, 0, 0.25));
47
+
48
+ /* scrim (the native ::backdrop) */
49
+ --rdg-backdrop-bg: var(--rozie-dialog-backdrop-bg, rgba(0, 0, 0, 0.5));
50
+ --rdg-backdrop-filter: var(--rozie-dialog-backdrop-filter, none);
51
+
52
+ /* content panel */
53
+ --rdg-padding: var(--rozie-dialog-padding, 1.5rem);
54
+ --rozie-dialog-font: inherit;
55
+
56
+ /* enter/leave transition duration */
57
+ --rdg-transition: var(--rozie-dialog-transition, 0.15s ease);
58
+ }