Appearance
Dialog — live demo
This is the real @rozie-ui/dialog-vue package running on this page (VitePress is itself a Vue app) — built from this repo's workspace, not npm: @rozie-ui/dialog-vue has not published yet, only @rozie-ui/dialog-solid has debuted so far. Open the dialog, then dismiss it by clicking the backdrop, pressing Escape, or using a button — and watch the two-way bound open value and the @close reason readout update. The same Dialog component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit. It's built on the native <dialog> element with no portal, no engine, and no required CSS — top-layer rendering, the ::backdrop scrim, the focus trap, and Esc-to-dismiss all ship inside the platform.
open is two-way bound with v-model:open — the readout updates the instant the dialog shows or dismisses, and a consumer write flows back in. The dialog renders in the top layer above this content with no portal; click the dimmed backdrop (unless you tick disableBackdropClose), press Escape, or use Cancel / Delete to close it, and watch @close report 'backdrop', 'escape', or — via the show() / hide() handle buttons — 'programmatic'. See the full API for every prop, event, and handle verb, plus theming and accessibility reference.
What ships for each framework
You author the component once as a .rozie file:
html
<!--
Dialog.rozie — a headless, accessible modal dialog built on the NATIVE
<dialog> element + showModal().
A pure-Rozie family (NO third-party engine), in the spirit of slider/captcha:
the PLATFORM is the engine. `showModal()` gives, for free and on every target,
what hand-rolled modals re-implement badly:
- TOP-LAYER rendering — escapes z-index / overflow / transform ancestors with
NO portal/teleport (better than a portal),
- a native ::backdrop pseudo-element for the scrim,
- a real FOCUS TRAP inside the dialog,
- Esc-to-dismiss (the native `cancel` event),
- focus RESTORATION to the previously-focused element on close.
Rozie owns the author-side API: the two-way `open` binding, the open↔native
reconcile, backdrop/escape close policy, optional scroll-lock, and the skin.
CONTROLLED via the `open` model. The component reconciles the native dialog to
`$props.open` from $onMount (initial) + a lazy `$watch` (transitions) — the
engine-wrapper recipe (onMount + lazy watch, never `immediate`). Every close
path funnels through `closeWith(reason)` → writes `$model.open = false` + emits
`close`; the watch then runs `el.close()`.
Authoring notes (collision classes — see the authoring playbook):
- `open` is BOTH the model prop name and a tempting event/verb name. The
imperative verbs are therefore `show` / `hide` (NOT `open`/`close`): an
`open` expose verb would collide with the `open` model (the data/model-key
==expose-verb class, listbox), and a `close` expose verb would collide with
the `@close` EVENT (ROZ121 expose==event, TipTap). `show`/`hide` are clear,
collision-free, and not inherited HTMLElement members (no ROZ137).
- $refs.panelEl is read ONLY in $onMount / the $watch CALLBACK / event
handlers — all post-mount (ROZ123-safe; the watch GETTER is
`() => $props.open`, which touches no ref). The ref is on the inner panel
<div> (not the <dialog>) to dodge an emitter ref-type-map gap; the <dialog>
is reached via `panelEl.parentElement` cast to HTMLDialogElement.
- Handler params are left UNTYPED (neutralize to `any`) so reading
`e.target` / `e.preventDefault()` typechecks across all six strict leaves.
- `showModal()` throws if already open and `close()` is a no-op when closed,
so `sync()` guards on the native `el.open` flag before calling either.
Consumer example:
<Dialog r-model:open="$data.confirmOpen" ariaLabelledby="confirm-title" @close="onClose">
<h2 id="confirm-title">Delete file?</h2>
<p>This cannot be undone.</p>
<button @click="$data.confirmOpen = false">Cancel</button>
<button @click="remove()">Delete</button>
</Dialog>
-->
<rozie name="Dialog">
<types>
/** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic'
/** The `close` event payload. */
export interface DialogClosePayload { reason: DialogCloseReason }
</types>
<props>
{
// Visibility (two-way). The sole model:true prop. Read $props.open; write
// $model.open. `r-model:open` / `v-model:open` / `[(open)]` outside.
open: {
type: Boolean,
default: false,
model: true,
docs: {
description:
'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`.',
example: '<Dialog r-model:open="confirmOpen" ariaLabelledby="confirm-title" />',
},
},
// Opt OUT of backdrop-click-to-dismiss (default: a backdrop click closes).
disableBackdropClose: {
type: Boolean,
default: false,
docs: {
description:
'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.',
},
},
// Opt OUT of Escape-to-dismiss (default: Esc closes; native `cancel`).
disableEscapeClose: {
type: Boolean,
default: false,
docs: {
description:
'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).',
},
},
// Opt OUT of locking <html> scroll while open (default: scroll is locked).
disableScrollLock: {
type: Boolean,
default: false,
docs: {
description:
'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.',
},
},
// What to focus once the dialog is shown (selector or element; default: the
// native showModal() choice).
initialFocus: {
type: [String, Element],
default: null,
docs: {
description:
'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.',
example: '<Dialog r-model:open="renameOpen" initialFocus="#label-name" />',
},
},
// Accessible name when the dialog has no visible title to point at.
ariaLabel: {
type: String,
default: null,
docs: {
description:
'Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.',
},
},
// id of the element that titles the dialog (preferred over ariaLabel when a
// visible heading exists).
ariaLabelledby: {
type: String,
default: null,
docs: {
description:
'The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog.',
},
},
}
</props>
<emits>
{
close: { payload: 'DialogClosePayload', docs: { description: 'Fired whenever the dialog dismisses: through a backdrop click, the Escape key, or a programmatic `hide()`. The payload is `{ reason }`.' } },
}
</emits>
<script lang="ts">
import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock'
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
let dialogEl: HTMLDialogElement | null = null
// Whether THIS instance currently holds one count of the shared scroll lock.
let holdsLock = false
// The element focused when the dialog opened, for the unmount-while-open
// focus return (a normal close gets the native return from close()).
let returnFocusTo: HTMLElement | null = null
// Lock/unlock <html> scroll for this instance. The actual lock/unlock is
// REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
// leaf's module, because a naive per-instance toggle unlocks scrolling the
// moment ANY dialog closes, even while an OUTER dialog is still open. This
// wrapper releases only a count this instance took: a dialog that mounts closed
// (or closes twice) must not release a count another open dialog holds. The
// opt-out is read when locking only, so toggling it while open still releases.
const setScrollLock = (lock) => {
if (lock === holdsLock) return
if (lock && $props.disableScrollLock) return
holdsLock = lock
applySharedScrollLock(lock)
}
// The element that really has focus. `document.activeElement` stops at the
// outermost shadow host (on Lit the trigger lives inside a component's shadow
// root, so it reports that component), so walk each shadowRoot.activeElement.
const deepActiveElement = () => {
let active: any = typeof document === 'undefined' ? null : document.activeElement
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement
return active
}
// Focus `initialFocus` after showModal() has made its native choice. A selector
// is matched inside the panel and, on Lit, inside the light-DOM content
// assigned to the panel's <slot> (which panel.querySelector cannot see).
const focusInitial = (panel) => {
const target: any = $props.initialFocus
if (!target) return
let node: any = null
if (typeof target === 'string') {
node = panel.querySelector(target)
const slot: any = node ? null : panel.querySelector('slot')
const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({ flatten: true }) : []
for (let i = 0; !node && i < assigned.length; i++) {
node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target)
}
} else {
node = target
}
if (node && typeof node.focus === 'function') node.focus()
}
// Reconcile the native <dialog> to the desired open state. Guarded on the
// native `el.open` flag (showModal throws if already open; close is a no-op when
// closed). Reads $refs in a post-mount callback (ROZ123-safe).
//
// The ref lives on the inner panel <div> (which the emitter types as
// HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
// HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
// has no `dialog` case, so a ref placed directly on <dialog> would be typed the
// generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
// leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
const sync = (isOpen) => {
const panel = $refs.panelEl
const el = (panel && panel.parentElement) as HTMLDialogElement | null
if (!el) return
dialogEl = el
if (isOpen) {
if (!el.open) {
const active = deepActiveElement()
returnFocusTo = active instanceof HTMLElement ? active : null
el.showModal()
focusInitial(panel)
}
setScrollLock(true)
} else {
if (el.open) el.close()
returnFocusTo = null
setScrollLock(false)
}
}
// ---- close funnel (single $emit site) ----------------------------------
const closeWith = (reason) => {
$model.open = false
$emit('close', { reason })
}
// ---- handlers ----------------------------------------------------------
// Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
// close through the model (keeping `open` in sync); honor the opt-out.
const onCancel = (e) => {
if (e) e.preventDefault()
if ($props.disableEscapeClose) return
closeWith('escape')
}
// A click whose target IS the <dialog> element (not its panel/children) is a
// backdrop click — the ::backdrop is part of the dialog box. We compare the
// real `e.target` (reliable even under Solid's event delegation) to the dialog
// element resolved via the panel ref's parent.
const onClick = (e) => {
if ($props.disableBackdropClose) return
const panel = $refs.panelEl
const el = panel && panel.parentElement
if (e && el && e.target === el) closeWith('backdrop')
}
// ---- lifecycle ---------------------------------------------------------
$onMount(() => {
sync($props.open)
})
// Lazy watch (never `immediate` — the engine must exist; onMount seeds initial).
$watch(() => $props.open, (isOpen) => {
sync(isOpen)
})
// Unmounted while open (e.g. the Dialog sits inside an `r-if` / `<Show>` that is
// removed with `open` still true): the watch never sees `open` go false, so the
// teardown does what that close would have done. It releases this instance's
// scroll-lock count and returns focus to the element focused before the dialog
// opened. While the <dialog> is still in the page, `close()` does both the
// un-inerting and the native focus return. When the framework has already
// removed it, focus has fallen to <body>; everything outside a modal dialog is
// inert until it leaves the page, so the refocus waits one task, and only
// happens if nothing else has taken focus meanwhile.
$onUnmount(() => {
setScrollLock(false)
const el = dialogEl
const back = returnFocusTo
dialogEl = null
returnFocusTo = null
if (!el || !el.open) return
if (el.isConnected) el.close()
if (!back) return
setTimeout(() => {
// Lost: on <body>, or stranded on a shadow host whose root holds no focus
// and which is not focusable itself (the dialog's removed subtree was
// inside it).
const active = deepActiveElement()
const lost = !active || active === document.body || (!!active.shadowRoot && active.tabIndex < 0)
if (back.isConnected && lost) back.focus()
}, 0)
})
// ---- imperative handle -------------------------------------------------
// show()/hide() — named to avoid the `open` model + `@close` event collisions.
const show = () => {
$model.open = true
}
const hide = () => {
closeWith('programmatic')
}
$expose({ show, hide })
</script>
<template>
<dialog
class="rozie-dialog"
:aria-label="$props.ariaLabel"
:aria-labelledby="$props.ariaLabelledby"
@cancel="onCancel($event)"
@click="onClick($event)"
>
<!-- The panel holds the consumer content and carries the ref (typed
HTMLDivElement); the <dialog> is reached via panel.parentElement. Backdrop
clicks land on the <dialog> itself (target check in onClick); clicks
inside the panel do not, so no stopPropagation gymnastics are needed. -->
<div class="rozie-dialog-panel" ref="panelEl">
<slot />
</div>
</dialog>
</template>
<style>
/*
Token-driven (mirrors slider/themes): every visual value is a
`var(--rozie-dialog-*, <fallback>)`. The shipped themes/*.css presets map these
onto shadcn/Radix, Material 3, Bootstrap 5. STRUCTURAL behavior (top-layer,
::backdrop, centering) comes from the native <dialog> and is not tokenized.
*/
/* The native dialog box. `<dialog>:not([open])` is display:none natively. When
shown via showModal() it renders in the top layer, centered by the UA; we only
restyle the box chrome. */
.rozie-dialog {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, var(--rdg-width, auto));
max-width: var(--rozie-dialog-max-width, var(--rdg-max-width, min(32rem, calc(100vw - 2rem))));
max-height: var(--rozie-dialog-max-height, var(--rdg-max-height, calc(100vh - 2rem)));
border: var(--rozie-dialog-border, var(--rdg-border, none));
border-radius: var(--rozie-dialog-radius, var(--rdg-radius, 0.75rem));
background: var(--rozie-dialog-bg, var(--rdg-bg, #fff));
color: var(--rozie-dialog-color, var(--rdg-color, inherit));
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)));
overflow: auto;
}
/* The scrim. Only painted for showModal()'d dialogs. */
.rozie-dialog::backdrop {
background: var(--rozie-dialog-backdrop-bg, var(--rdg-backdrop-bg, rgba(0, 0, 0, 0.5)));
backdrop-filter: var(--rozie-dialog-backdrop-filter, var(--rdg-backdrop-filter, none));
}
.rozie-dialog-panel {
padding: var(--rozie-dialog-padding, var(--rdg-padding, 1.5rem));
font: var(--rozie-dialog-font, inherit);
}
/* Progressive enter animation (no-op where @starting-style/allow-discrete are
unsupported — the dialog simply appears). */
@media (prefers-reduced-motion: no-preference) {
.rozie-dialog {
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;
opacity: 1;
transform: translateY(0) scale(1);
}
.rozie-dialog:not([open]) {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
@starting-style {
.rozie-dialog[open] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
}
.rozie-dialog::backdrop {
transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
opacity: 1;
}
.rozie-dialog:not([open])::backdrop {
opacity: 0;
}
@starting-style {
.rozie-dialog[open]::backdrop {
opacity: 0;
}
}
}
</style>
</rozie>…and Rozie compiles it to six framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/dialog-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import type { ReactNode } from 'react';
import { clsx, mergeListeners, pickListeners, rozieAttr, useControllableState } from '@rozie/runtime-react';
import './Dialog.css';
import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock';
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
/** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
/** The `close` event payload. */
export interface DialogClosePayload {
reason: DialogCloseReason;
}
interface DialogProps extends Omit<import('react').ComponentPropsWithoutRef<'dialog'>, 'open' | 'defaultOpen' | 'onOpenChange' | 'disableBackdropClose' | 'disableEscapeClose' | 'disableScrollLock' | 'initialFocus' | 'ariaLabel' | 'ariaLabelledby' | 'onClose' | 'children' | 'slots' | 'dangerouslySetInnerHTML'> {
/**
* 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`.
* @example
* <Dialog open={confirmOpen} onOpenChange={setConfirmOpen} ariaLabelledby="confirm-title" />
*/
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/**
* 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.
*/
disableBackdropClose?: boolean;
/**
* 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).
*/
disableEscapeClose?: boolean;
/**
* 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.
*/
disableScrollLock?: boolean;
/**
* 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.
* @example
* <Dialog open={renameOpen} onOpenChange={setRenameOpen} initialFocus="#label-name" />
*/
initialFocus?: (string | Element) | null;
/**
* Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
*/
ariaLabel?: (string) | null;
/**
* The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog.
*/
ariaLabelledby?: (string) | null;
onClose?: (payload: DialogClosePayload) => void;
children?: ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface DialogHandle {
show: (...args: any[]) => any;
hide: (...args: any[]) => any;
}
const Dialog = forwardRef<DialogHandle, DialogProps>(function Dialog(_props: DialogProps, ref): JSX.Element {
const props: Omit<DialogProps, 'disableBackdropClose' | 'disableEscapeClose' | 'disableScrollLock' | 'initialFocus' | 'ariaLabel' | 'ariaLabelledby'> & { disableBackdropClose: boolean; disableEscapeClose: boolean; disableScrollLock: boolean; initialFocus: (string | Element) | null; ariaLabel: (string) | null; ariaLabelledby: (string) | null } = {
..._props,
disableBackdropClose: _props.disableBackdropClose ?? false,
disableEscapeClose: _props.disableEscapeClose ?? false,
disableScrollLock: _props.disableScrollLock ?? false,
initialFocus: _props.initialFocus ?? null,
ariaLabel: _props.ariaLabel ?? null,
ariaLabelledby: _props.ariaLabelledby ?? null,
};
const attrs: Record<string, unknown> = (() => {
const { open, disableBackdropClose, disableEscapeClose, disableScrollLock, initialFocus, ariaLabel, ariaLabelledby, defaultValue, onOpenChange, defaultOpen, onClose, ...rest } = _props as DialogProps & Record<string, unknown>;
void open; void disableBackdropClose; void disableEscapeClose; void disableScrollLock; void initialFocus; void ariaLabel; void ariaLabelledby; void defaultValue; void onOpenChange; void defaultOpen; void onClose;
return rest;
})();
const dialogEl = useRef<HTMLDialogElement | null>(null);
const returnFocusTo = useRef<HTMLElement | null>(null);
const holdsLock = useRef(false);
const [open, setOpen] = useControllableState({
value: props.open,
defaultValue: props.defaultOpen ?? false,
onValueChange: props.onOpenChange,
});
const _openRef = useRef(open);
_openRef.current = open;
const panelEl = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
// The element focused when the dialog opened, for the unmount-while-open
// focus return (a normal close gets the native return from close()).
// Whether THIS instance currently holds one count of the shared scroll lock.
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
// Lock/unlock <html> scroll for this instance. The actual lock/unlock is
// REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
// leaf's module, because a naive per-instance toggle unlocks scrolling the
// moment ANY dialog closes, even while an OUTER dialog is still open. This
// wrapper releases only a count this instance took: a dialog that mounts closed
// (or closes twice) must not release a count another open dialog holds. The
// opt-out is read when locking only, so toggling it while open still releases.
const setScrollLock = useCallback((lock: any) => {
if (lock === holdsLock.current) return;
if (lock && props.disableScrollLock) return;
holdsLock.current = lock;
applySharedScrollLock(lock);
}, [props.disableScrollLock]);
// The element that really has focus. `document.activeElement` stops at the
// outermost shadow host (on Lit the trigger lives inside a component's shadow
// root, so it reports that component), so walk each shadowRoot.activeElement.
const deepActiveElement = useCallback(() => {
let active: any = typeof document === 'undefined' ? null : document.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
return active;
}, []);
// Focus `initialFocus` after showModal() has made its native choice. A selector
// is matched inside the panel and, on Lit, inside the light-DOM content
// assigned to the panel's <slot> (which panel.querySelector cannot see).
function focusInitial(panel: any) {
const target: any = props.initialFocus;
if (!target) return;
let node: any = null;
if (typeof target === 'string') {
node = panel.querySelector(target);
const slot: any = node ? null : panel.querySelector('slot');
const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({
flatten: true
}) : [];
for (let i = 0; !node && i < assigned.length; i++) {
node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target);
}
} else {
node = target;
}
if (node && typeof node.focus === 'function') node.focus();
}
// Reconcile the native <dialog> to the desired open state. Guarded on the
// native `el.open` flag (showModal throws if already open; close is a no-op when
// closed). Reads $refs in a post-mount callback (ROZ123-safe).
//
// The ref lives on the inner panel <div> (which the emitter types as
// HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
// HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
// has no `dialog` case, so a ref placed directly on <dialog> would be typed the
// generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
// leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
const sync = useCallback((isOpen: any) => {
const panel = panelEl.current;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
dialogEl.current = el;
if (isOpen) {
if (!el.open) {
const active = deepActiveElement();
returnFocusTo.current = active instanceof HTMLElement ? active : null;
el.showModal();
focusInitial(panel);
}
setScrollLock(true);
} else {
if (el.open) el.close();
returnFocusTo.current = null;
setScrollLock(false);
}
}, [deepActiveElement, focusInitial, setScrollLock]);
// ---- close funnel (single $emit site) ----------------------------------
function closeWith(reason: any) {
setOpen(false);
props.onClose && props.onClose({
reason
});
}
// ---- handlers ----------------------------------------------------------
// Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
// close through the model (keeping `open` in sync); honor the opt-out.
const onCancel = useCallback((e: any) => {
if (e) e.preventDefault();
if (props.disableEscapeClose) return;
closeWith('escape');
}, [closeWith, props.disableEscapeClose]);
// A click whose target IS the <dialog> element (not its panel/children) is a
// backdrop click — the ::backdrop is part of the dialog box. We compare the
// real `e.target` (reliable even under Solid's event delegation) to the dialog
// element resolved via the panel ref's parent.
const onClick = useCallback((e: any) => {
if (props.disableBackdropClose) return;
const panel = panelEl.current;
const el = panel && panel.parentElement;
if (e && el && e.target === el) closeWith('backdrop');
}, [closeWith, props.disableBackdropClose]);
// ---- lifecycle ---------------------------------------------------------
// ---- imperative handle -------------------------------------------------
// show()/hide() — named to avoid the `open` model + `@close` event collisions.
function show() {
setOpen(true);
}
function hide() {
closeWith('programmatic');
}
const _syncRef = useRef(sync);
_syncRef.current = sync;
useEffect(() => {
_syncRef.current(_openRef.current);
}, []);
useEffect(() => {
return () => {
setScrollLock(false);
const el = dialogEl.current;
const back = returnFocusTo.current;
dialogEl.current = null;
returnFocusTo.current = null;
if (!el || !el.open) return;
if (el.isConnected) el.close();
if (!back) return;
setTimeout(() => {
// Lost: on <body>, or stranded on a shadow host whose root holds no focus
// and which is not focusable itself (the dialog's removed subtree was
// inside it).
const active = deepActiveElement();
const lost = !active || active === document.body || !!active.shadowRoot && active.tabIndex < 0;
if (back.isConnected && lost) back.focus();
}, 0);
};
}, []);
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const isOpen = open;
sync(isOpen);
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ show, hide });
_rozieExposeRef.current = { show, hide };
useImperativeHandle(ref, () => ({ show: (...args: Parameters<typeof show>): ReturnType<typeof show> => _rozieExposeRef.current.show(...args), hide: (...args: Parameters<typeof hide>): ReturnType<typeof hide> => _rozieExposeRef.current.hide(...args) }), []);
return (
<>
<dialog aria-label={rozieAttr(props.ariaLabel)} aria-labelledby={rozieAttr(props.ariaLabelledby)} {...attrs} className={clsx("rozie-dialog", (attrs.className as string | undefined))} {...mergeListeners({ onCancel: ($event) => { onCancel($event); }, onClick: ($event) => { onClick($event); } } satisfies import('react').ComponentPropsWithoutRef<'dialog'> & Record<string, unknown>, pickListeners(attrs))} data-rozie-s-2a679072="">
<div className={"rozie-dialog-panel"} ref={panelEl} data-rozie-s-2a679072="">
{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
</div>
</dialog>
</>
);
});
export default Dialog;vue
<template>
<dialog class="rozie-dialog" :aria-label="props.ariaLabel" :aria-labelledby="props.ariaLabelledby" v-bind="$attrs" @cancel="onCancel($event)" @click="onClick($event)">
<div class="rozie-dialog-panel" ref="panelElRef">
<slot></slot>
</div>
</dialog>
</template>
<script lang="ts">
/** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
/** The `close` event payload. */
export interface DialogClosePayload {
reason: DialogCloseReason;
}
</script>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock';
const props = withDefaults(
defineProps<{
/**
* 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.
*/
disableBackdropClose?: boolean;
/**
* 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).
*/
disableEscapeClose?: boolean;
/**
* 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.
*/
disableScrollLock?: boolean;
/**
* 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.
* @example
* <Dialog v-model:open="renameOpen" initialFocus="#label-name" />
*/
initialFocus?: string | Element | null;
/**
* Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
*/
ariaLabel?: string | null;
/**
* The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog.
*/
ariaLabelledby?: string | null;
}>(),
{ disableBackdropClose: false, disableEscapeClose: false, disableScrollLock: false, initialFocus: null, ariaLabel: null, ariaLabelledby: null }
);
/**
* 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`.
* @example
* <Dialog v-model:open="confirmOpen" ariaLabelledby="confirm-title" />
*/
const open = defineModel<boolean>('open', { default: false });
const emit = defineEmits<{
close: [payload: DialogClosePayload];
}>();
defineSlots<{
default(props: { }): any;
}>();
const panelElRef = ref<HTMLElement>();
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
let dialogEl: HTMLDialogElement | null = null;
// Whether THIS instance currently holds one count of the shared scroll lock.
let holdsLock = false;
// The element focused when the dialog opened, for the unmount-while-open
// focus return (a normal close gets the native return from close()).
let returnFocusTo: HTMLElement | null = null;
// Lock/unlock <html> scroll for this instance. The actual lock/unlock is
// REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
// leaf's module, because a naive per-instance toggle unlocks scrolling the
// moment ANY dialog closes, even while an OUTER dialog is still open. This
// wrapper releases only a count this instance took: a dialog that mounts closed
// (or closes twice) must not release a count another open dialog holds. The
// opt-out is read when locking only, so toggling it while open still releases.
const setScrollLock = (lock: any) => {
if (lock === holdsLock) return;
if (lock && props.disableScrollLock) return;
holdsLock = lock;
applySharedScrollLock(lock);
};
// The element that really has focus. `document.activeElement` stops at the
// outermost shadow host (on Lit the trigger lives inside a component's shadow
// root, so it reports that component), so walk each shadowRoot.activeElement.
const deepActiveElement = () => {
let active: any = typeof document === 'undefined' ? null : document.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
return active;
};
// Focus `initialFocus` after showModal() has made its native choice. A selector
// is matched inside the panel and, on Lit, inside the light-DOM content
// assigned to the panel's <slot> (which panel.querySelector cannot see).
const focusInitial = (panel: any) => {
const target: any = props.initialFocus;
if (!target) return;
let node: any = null;
if (typeof target === 'string') {
node = panel.querySelector(target);
const slot: any = node ? null : panel.querySelector('slot');
const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({
flatten: true
}) : [];
for (let i = 0; !node && i < assigned.length; i++) {
node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target);
}
} else {
node = target;
}
if (node && typeof node.focus === 'function') node.focus();
};
// Reconcile the native <dialog> to the desired open state. Guarded on the
// native `el.open` flag (showModal throws if already open; close is a no-op when
// closed). Reads $refs in a post-mount callback (ROZ123-safe).
//
// The ref lives on the inner panel <div> (which the emitter types as
// HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
// HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
// has no `dialog` case, so a ref placed directly on <dialog> would be typed the
// generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
// leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
const sync = (isOpen: any) => {
const panel = panelElRef.value;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
dialogEl = el;
if (isOpen) {
if (!el.open) {
const active = deepActiveElement();
returnFocusTo = active instanceof HTMLElement ? active : null;
el.showModal();
focusInitial(panel);
}
setScrollLock(true);
} else {
if (el.open) el.close();
returnFocusTo = null;
setScrollLock(false);
}
};
// ---- close funnel (single $emit site) ----------------------------------
const closeWith = (reason: any) => {
open.value = false;
emit('close', {
reason
});
};
// ---- handlers ----------------------------------------------------------
// Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
// close through the model (keeping `open` in sync); honor the opt-out.
const onCancel = (e: any) => {
if (e) e.preventDefault();
if (props.disableEscapeClose) return;
closeWith('escape');
};
// A click whose target IS the <dialog> element (not its panel/children) is a
// backdrop click — the ::backdrop is part of the dialog box. We compare the
// real `e.target` (reliable even under Solid's event delegation) to the dialog
// element resolved via the panel ref's parent.
const onClick = (e: any) => {
if (props.disableBackdropClose) return;
const panel = panelElRef.value;
const el = panel && panel.parentElement;
if (e && el && e.target === el) closeWith('backdrop');
};
// ---- lifecycle ---------------------------------------------------------
// ---- imperative handle -------------------------------------------------
// show()/hide() — named to avoid the `open` model + `@close` event collisions.
const show = () => {
open.value = true;
};
const hide = () => {
closeWith('programmatic');
};
onMounted(() => {
sync(open.value);
});
onBeforeUnmount(() => {
setScrollLock(false);
const el = dialogEl;
const back = returnFocusTo;
dialogEl = null;
returnFocusTo = null;
if (!el || !el.open) return;
if (el.isConnected) el.close();
if (!back) return;
setTimeout(() => {
// Lost: on <body>, or stranded on a shadow host whose root holds no focus
// and which is not focusable itself (the dialog's removed subtree was
// inside it).
const active = deepActiveElement();
const lost = !active || active === document.body || !!active.shadowRoot && active.tabIndex < 0;
if (back.isConnected && lost) back.focus();
}, 0);
});
watch(() => open.value, (isOpen: any) => {
sync(isOpen);
}, { flush: 'post' });
defineExpose({ show, hide });
</script>
<style scoped>
@media (prefers-reduced-motion: no-preference) {
.rozie-dialog {
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;
opacity: 1;
transform: translateY(0) scale(1);
}
.rozie-dialog:not([open]) {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
@starting-style {
.rozie-dialog[open] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
}
.rozie-dialog::backdrop {
transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
opacity: 1;
}
.rozie-dialog:not([open])::backdrop {
opacity: 0;
}
@starting-style {
.rozie-dialog[open]::backdrop {
opacity: 0;
}
}
}
.rozie-dialog {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, var(--rdg-width, auto));
max-width: var(--rozie-dialog-max-width, var(--rdg-max-width, min(32rem, calc(100vw - 2rem))));
max-height: var(--rozie-dialog-max-height, var(--rdg-max-height, calc(100vh - 2rem)));
border: var(--rozie-dialog-border, var(--rdg-border, none));
border-radius: var(--rozie-dialog-radius, var(--rdg-radius, 0.75rem));
background: var(--rozie-dialog-bg, var(--rdg-bg, #fff));
color: var(--rozie-dialog-color, var(--rdg-color, inherit));
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)));
overflow: auto;
}
.rozie-dialog::backdrop {
background: var(--rozie-dialog-backdrop-bg, var(--rdg-backdrop-bg, rgba(0, 0, 0, 0.5)));
backdrop-filter: var(--rozie-dialog-backdrop-filter, var(--rdg-backdrop-filter, none));
}
.rozie-dialog-panel {
padding: var(--rozie-dialog-padding, var(--rdg-padding, 1.5rem));
font: var(--rozie-dialog-font, inherit);
}
</style>svelte
<script module lang="ts">
/** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
/** The `close` event payload. */
export interface DialogClosePayload {
reason: DialogCloseReason;
}
</script>
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['dialog'], 'open' | 'disableBackdropClose' | 'disableEscapeClose' | 'disableScrollLock' | 'initialFocus' | 'ariaLabel' | 'ariaLabelledby' | 'children' | 'snippets' | 'onclose'> {
/**
* 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`.
* @example
* <Dialog bind:open={confirmOpen} ariaLabelledby="confirm-title" />
*/
open?: boolean;
/**
* 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.
*/
disableBackdropClose?: boolean;
/**
* 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).
*/
disableEscapeClose?: boolean;
/**
* 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.
*/
disableScrollLock?: boolean;
/**
* 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.
* @example
* <Dialog bind:open={renameOpen} initialFocus="#label-name" />
*/
initialFocus?: (string | Element) | null;
/**
* Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
*/
ariaLabel?: (string) | null;
/**
* The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog.
*/
ariaLabelledby?: (string) | null;
children?: Snippet;
snippets?: Record<string, any>;
onclose?: (payload: DialogClosePayload) => void;
}
let {
open = $bindable(false),
disableBackdropClose = false,
disableEscapeClose = false,
disableScrollLock = false,
initialFocus = null,
ariaLabel = null,
ariaLabelledby = null,
children: __childrenProp,
snippets,
onclose,
...__rozieAttrs
}: Props = $props();
const children = $derived(__childrenProp ?? snippets?.children);
let panelEl = $state<HTMLElement | undefined>(undefined);
import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock';
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
let dialogEl: HTMLDialogElement | null = null;
// Whether THIS instance currently holds one count of the shared scroll lock.
let holdsLock = false;
// The element focused when the dialog opened, for the unmount-while-open
// focus return (a normal close gets the native return from close()).
let returnFocusTo: HTMLElement | null = null;
// Lock/unlock <html> scroll for this instance. The actual lock/unlock is
// REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
// leaf's module, because a naive per-instance toggle unlocks scrolling the
// moment ANY dialog closes, even while an OUTER dialog is still open. This
// wrapper releases only a count this instance took: a dialog that mounts closed
// (or closes twice) must not release a count another open dialog holds. The
// opt-out is read when locking only, so toggling it while open still releases.
const setScrollLock = (lock: any) => {
if (lock === holdsLock) return;
if (lock && disableScrollLock) return;
holdsLock = lock;
applySharedScrollLock(lock);
};
// The element that really has focus. `document.activeElement` stops at the
// outermost shadow host (on Lit the trigger lives inside a component's shadow
// root, so it reports that component), so walk each shadowRoot.activeElement.
const deepActiveElement = () => {
let active: any = typeof document === 'undefined' ? null : document.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
return active;
};
// Focus `initialFocus` after showModal() has made its native choice. A selector
// is matched inside the panel and, on Lit, inside the light-DOM content
// assigned to the panel's <slot> (which panel.querySelector cannot see).
const focusInitial = (panel: any) => {
const target: any = initialFocus;
if (!target) return;
let node: any = null;
if (typeof target === 'string') {
node = panel.querySelector(target);
const slot: any = node ? null : panel.querySelector('slot');
const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({
flatten: true
}) : [];
for (let i = 0; !node && i < assigned.length; i++) {
node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target);
}
} else {
node = target;
}
if (node && typeof node.focus === 'function') node.focus();
};
// Reconcile the native <dialog> to the desired open state. Guarded on the
// native `el.open` flag (showModal throws if already open; close is a no-op when
// closed). Reads $refs in a post-mount callback (ROZ123-safe).
//
// The ref lives on the inner panel <div> (which the emitter types as
// HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
// HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
// has no `dialog` case, so a ref placed directly on <dialog> would be typed the
// generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
// leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
const sync = (isOpen: any) => {
const panel = panelEl;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
dialogEl = el;
if (isOpen) {
if (!el.open) {
const active = deepActiveElement();
returnFocusTo = active instanceof HTMLElement ? active : null;
el.showModal();
focusInitial(panel);
}
setScrollLock(true);
} else {
if (el.open) el.close();
returnFocusTo = null;
setScrollLock(false);
}
};
// ---- close funnel (single $emit site) ----------------------------------
const closeWith = (reason: any) => {
open = false;
onclose?.({
reason
});
};
// ---- handlers ----------------------------------------------------------
// Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
// close through the model (keeping `open` in sync); honor the opt-out.
const onCancel = (e: any) => {
if (e) e.preventDefault();
if (disableEscapeClose) return;
closeWith('escape');
};
// A click whose target IS the <dialog> element (not its panel/children) is a
// backdrop click — the ::backdrop is part of the dialog box. We compare the
// real `e.target` (reliable even under Solid's event delegation) to the dialog
// element resolved via the panel ref's parent.
const onClick = (e: any) => {
if (disableBackdropClose) return;
const panel = panelEl;
const el = panel && panel.parentElement;
if (e && el && e.target === el) closeWith('backdrop');
};
// ---- lifecycle ---------------------------------------------------------
// ---- imperative handle -------------------------------------------------
// show()/hide() — named to avoid the `open` model + `@close` event collisions.
export const show = () => {
open = true;
};
export const hide = () => {
closeWith('programmatic');
};
onMount(() => {
sync(open);
});
onDestroy(() => (() => {
setScrollLock(false);
const el = dialogEl;
const back = returnFocusTo;
dialogEl = null;
returnFocusTo = null;
if (!el || !el.open) return;
if (el.isConnected) el.close();
if (!back) return;
setTimeout(() => {
// Lost: on <body>, or stranded on a shadow host whose root holds no focus
// and which is not focusable itself (the dialog's removed subtree was
// inside it).
const active = deepActiveElement();
const lost = !active || active === document.body || !!active.shadowRoot && active.tabIndex < 0;
if (back.isConnected && lost) back.focus();
}, 0);
})());
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => open)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((isOpen: any) => {
sync(isOpen);
})(__watchVal); }); });
</script>
<dialog aria-label={ariaLabel} aria-labelledby={ariaLabelledby} {...__rozieAttrs} class={["rozie-dialog", (__rozieAttrs)?.class]} oncancel={($event) => { onCancel($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>
<style>
:global {
@media (prefers-reduced-motion: no-preference) {
.rozie-dialog[data-rozie-s-2a679072] {
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;
opacity: 1;
transform: translateY(0) scale(1);
}
.rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072]) {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
@starting-style {
.rozie-dialog[open][data-rozie-s-2a679072] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
}
.rozie-dialog[data-rozie-s-2a679072]::backdrop {
transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
opacity: 1;
}
.rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072])::backdrop {
opacity: 0;
}
@starting-style {
.rozie-dialog[open][data-rozie-s-2a679072]::backdrop {
opacity: 0;
}
}
}
.rozie-dialog[data-rozie-s-2a679072] {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, var(--rdg-width, auto));
max-width: var(--rozie-dialog-max-width, var(--rdg-max-width, min(32rem, calc(100vw - 2rem))));
max-height: var(--rozie-dialog-max-height, var(--rdg-max-height, calc(100vh - 2rem)));
border: var(--rozie-dialog-border, var(--rdg-border, none));
border-radius: var(--rozie-dialog-radius, var(--rdg-radius, 0.75rem));
background: var(--rozie-dialog-bg, var(--rdg-bg, #fff));
color: var(--rozie-dialog-color, var(--rdg-color, inherit));
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)));
overflow: auto;
}
.rozie-dialog[data-rozie-s-2a679072]::backdrop {
background: var(--rozie-dialog-backdrop-bg, var(--rdg-backdrop-bg, rgba(0, 0, 0, 0.5)));
backdrop-filter: var(--rozie-dialog-backdrop-filter, var(--rdg-backdrop-filter, none));
}
.rozie-dialog-panel[data-rozie-s-2a679072] {
padding: var(--rozie-dialog-padding, var(--rdg-padding, 1.5rem));
font: var(--rozie-dialog-font, inherit);
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';
import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock';
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
/** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
/** The `close` event payload. */
export interface DialogClosePayload {
reason: DialogCloseReason;
}
interface DefaultCtx {}
@Component({
selector: 'rozie-dialog',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<dialog class="rozie-dialog" [attr.aria-label]="rozieAttr(ariaLabel())" [attr.aria-labelledby]="rozieAttr(ariaLabelledby())" #rozieSpread_0 (cancel)="onCancel($event)" (click)="onClick($event)" #rozieListenersTarget_1>
<div class="rozie-dialog-panel" #panelEl>
<ng-container *ngTemplateOutlet="(defaultTpl ?? __rozieFillMap()['defaultSlot'] ?? templates()?.['defaultSlot'])" />
</div>
</dialog>
`,
styles: [`
:host(rozie-dialog) { display: contents; }
@media (prefers-reduced-motion: no-preference) {
.rozie-dialog {
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;
opacity: 1;
transform: translateY(0) scale(1);
}
.rozie-dialog:not([open]) {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
@starting-style {
.rozie-dialog[open] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
}
.rozie-dialog::backdrop {
transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
opacity: 1;
}
.rozie-dialog:not([open])::backdrop {
opacity: 0;
}
@starting-style {
.rozie-dialog[open]::backdrop {
opacity: 0;
}
}
}
.rozie-dialog {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, var(--rdg-width, auto));
max-width: var(--rozie-dialog-max-width, var(--rdg-max-width, min(32rem, calc(100vw - 2rem))));
max-height: var(--rozie-dialog-max-height, var(--rdg-max-height, calc(100vh - 2rem)));
border: var(--rozie-dialog-border, var(--rdg-border, none));
border-radius: var(--rozie-dialog-radius, var(--rdg-radius, 0.75rem));
background: var(--rozie-dialog-bg, var(--rdg-bg, #fff));
color: var(--rozie-dialog-color, var(--rdg-color, inherit));
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)));
overflow: auto;
}
.rozie-dialog::backdrop {
background: var(--rozie-dialog-backdrop-bg, var(--rdg-backdrop-bg, rgba(0, 0, 0, 0.5)));
backdrop-filter: var(--rozie-dialog-backdrop-filter, var(--rdg-backdrop-filter, none));
}
.rozie-dialog-panel {
padding: var(--rozie-dialog-padding, var(--rdg-padding, 1.5rem));
font: var(--rozie-dialog-font, inherit);
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Dialog),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Dialog {
/**
* 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`.
* @example
* <rozie-dialog [(open)]="confirmOpen" ariaLabelledby="confirm-title" />
*/
open = model<boolean>(false);
/**
* 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.
*/
disableBackdropClose = input<boolean>(false);
/**
* 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).
*/
disableEscapeClose = input<boolean>(false);
/**
* 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.
*/
disableScrollLock = input<boolean>(false);
/**
* 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.
* @example
* <rozie-dialog [(open)]="renameOpen" initialFocus="#label-name" />
*/
initialFocus = input<(string | Element) | null>(null);
/**
* Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
*/
ariaLabel = input<(string) | null>(null);
/**
* The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog.
*/
ariaLabelledby = input<(string) | null>(null);
panelEl = viewChild<ElementRef<HTMLDivElement>>('panelEl');
close = output<DialogClosePayload>();
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
__rozieFills = contentChildren(RozieSlot, { descendants: true });
__rozieFillMap = computed(() => {
const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
for (const f of this.__rozieFills()) {
const k = f.rozieSlot();
if (k == null) continue;
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
map[k === '' ? 'defaultSlot' : k] = f.templateRef;
}
return map;
});
private __rozieWatchInitial_0 = true;
constructor() {
inject(DestroyRef).onDestroy(() => {
this.setScrollLock(false);
const el = this.dialogEl;
const back = this.returnFocusTo;
this.dialogEl = null;
this.returnFocusTo = null;
if (!el || !el.open) return;
if (el.isConnected) el.close();
if (!back) return;
setTimeout(() => {
// Lost: on <body>, or stranded on a shadow host whose root holds no focus
// and which is not focusable itself (the dialog's removed subtree was
// inside it).
const active = this.deepActiveElement();
const lost = !active || active === document.body || !!active.shadowRoot && active.tabIndex < 0;
if (back.isConnected && lost) back.focus();
}, 0);
});
effect(() => { const __watchVal = (() => this.open())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((isOpen: any) => {
this.sync(isOpen);
})(__watchVal); }); });
}
ngAfterViewInit() {
this.sync(this.open());
}
dialogEl: HTMLDialogElement | null = null;
// Whether THIS instance currently holds one count of the shared scroll lock.
holdsLock = false;
// The element focused when the dialog opened, for the unmount-while-open
// focus return (a normal close gets the native return from close()).
returnFocusTo: HTMLElement | null = null;
// Lock/unlock <html> scroll for this instance. The actual lock/unlock is
// REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
// leaf's module, because a naive per-instance toggle unlocks scrolling the
// moment ANY dialog closes, even while an OUTER dialog is still open. This
// wrapper releases only a count this instance took: a dialog that mounts closed
// (or closes twice) must not release a count another open dialog holds. The
// opt-out is read when locking only, so toggling it while open still releases.
setScrollLock = (lock: any) => {
if (lock === this.holdsLock) return;
if (lock && this.disableScrollLock()) return;
this.holdsLock = lock;
applySharedScrollLock(lock);
};
// The element that really has focus. `document.activeElement` stops at the
// outermost shadow host (on Lit the trigger lives inside a component's shadow
// root, so it reports that component), so walk each shadowRoot.activeElement.
deepActiveElement = () => {
let active: any = typeof document === 'undefined' ? null : document.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
return active;
};
// Focus `initialFocus` after showModal() has made its native choice. A selector
// is matched inside the panel and, on Lit, inside the light-DOM content
// assigned to the panel's <slot> (which panel.querySelector cannot see).
focusInitial = (panel: any) => {
const target: any = this.initialFocus();
if (!target) return;
let node: any = null;
if (typeof target === 'string') {
node = panel.querySelector(target);
const slot: any = node ? null : panel.querySelector('slot');
const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({
flatten: true
}) : [];
for (let i = 0; !node && i < assigned.length; i++) {
node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target);
}
} else {
node = target;
}
if (node && typeof node.focus === 'function') node.focus();
};
// Reconcile the native <dialog> to the desired open state. Guarded on the
// native `el.open` flag (showModal throws if already open; close is a no-op when
// closed). Reads $refs in a post-mount callback (ROZ123-safe).
//
// The ref lives on the inner panel <div> (which the emitter types as
// HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
// HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
// has no `dialog` case, so a ref placed directly on <dialog> would be typed the
// generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
// leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
sync = (isOpen: any) => {
const panel = this.panelEl()?.nativeElement;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
this.dialogEl = el;
if (isOpen) {
if (!el.open) {
const active = this.deepActiveElement();
this.returnFocusTo = active instanceof HTMLElement ? active : null;
el.showModal();
this.focusInitial(panel);
}
this.setScrollLock(true);
} else {
if (el.open) el.close();
this.returnFocusTo = null;
this.setScrollLock(false);
}
};
// ---- close funnel (single $emit site) ----------------------------------
closeWith = (reason: any) => {
this.open.set(false), this.__rozieCvaOnChange(false);
this.close.emit({
reason
});
};
// ---- handlers ----------------------------------------------------------
// Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
// close through the model (keeping `open` in sync); honor the opt-out.
onCancel = (e: any) => {
if (e) e.preventDefault();
if (this.disableEscapeClose()) return;
this.closeWith('escape');
};
// A click whose target IS the <dialog> element (not its panel/children) is a
// backdrop click — the ::backdrop is part of the dialog box. We compare the
// real `e.target` (reliable even under Solid's event delegation) to the dialog
// element resolved via the panel ref's parent.
onClick = (e: any) => {
if (this.disableBackdropClose()) return;
const panel = this.panelEl()?.nativeElement;
const el = panel && panel.parentElement;
if (e && el && e.target === el) this.closeWith('backdrop');
};
// ---- lifecycle ---------------------------------------------------------
// ---- imperative handle -------------------------------------------------
// show()/hide() — named to avoid the `open` model + `@close` event collisions.
show = () => {
this.open.set(true), this.__rozieCvaOnChange(true);
};
hide = () => {
this.closeWith('programmatic');
};
private __rozieCvaOnChange: (v: boolean) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: boolean | null): void {
this.open.set(v ?? false);
}
registerOnChange(fn: (v: boolean) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: Dialog,
_ctx: unknown,
): _ctx is DefaultCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));
private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default Dialog;tsx
import type { JSX } from 'solid-js';
import { children, createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, mergeListeners, pickListeners, rozieAttr } from '@rozie/runtime-solid';
import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock';
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
/** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
/** The `close` event payload. */
export interface DialogClosePayload {
reason: DialogCloseReason;
}
__rozieInjectStyle('Dialog-2a679072', `@media (prefers-reduced-motion: no-preference) {
.rozie-dialog[data-rozie-s-2a679072] {
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;
opacity: 1;
transform: translateY(0) scale(1);
}
.rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072]) {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
@starting-style {
.rozie-dialog[open][data-rozie-s-2a679072] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
}
.rozie-dialog[data-rozie-s-2a679072]::backdrop {
transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
opacity: 1;
}
.rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072])::backdrop {
opacity: 0;
}
@starting-style {
.rozie-dialog[open][data-rozie-s-2a679072]::backdrop {
opacity: 0;
}
}
}
.rozie-dialog[data-rozie-s-2a679072] {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, var(--rdg-width, auto));
max-width: var(--rozie-dialog-max-width, var(--rdg-max-width, min(32rem, calc(100vw - 2rem))));
max-height: var(--rozie-dialog-max-height, var(--rdg-max-height, calc(100vh - 2rem)));
border: var(--rozie-dialog-border, var(--rdg-border, none));
border-radius: var(--rozie-dialog-radius, var(--rdg-radius, 0.75rem));
background: var(--rozie-dialog-bg, var(--rdg-bg, #fff));
color: var(--rozie-dialog-color, var(--rdg-color, inherit));
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)));
overflow: auto;
}
.rozie-dialog[data-rozie-s-2a679072]::backdrop {
background: var(--rozie-dialog-backdrop-bg, var(--rdg-backdrop-bg, rgba(0, 0, 0, 0.5)));
backdrop-filter: var(--rozie-dialog-backdrop-filter, var(--rdg-backdrop-filter, none));
}
.rozie-dialog-panel[data-rozie-s-2a679072] {
padding: var(--rozie-dialog-padding, var(--rdg-padding, 1.5rem));
font: var(--rozie-dialog-font, inherit);
}`);
interface DialogProps extends Omit<import('solid-js').ComponentProps<'dialog'>, 'open' | 'defaultOpen' | 'onOpenChange' | 'disableBackdropClose' | 'disableEscapeClose' | 'disableScrollLock' | 'initialFocus' | 'ariaLabel' | 'ariaLabelledby' | 'onClose' | 'children' | 'slots' | 'ref' | 'innerHTML' | 'innerText' | 'textContent'> {
/**
* 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`.
* @example
* <Dialog open={confirmOpen()} onOpenChange={setConfirmOpen} ariaLabelledby="confirm-title" />
*/
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/**
* 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.
*/
disableBackdropClose?: boolean;
/**
* 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).
*/
disableEscapeClose?: boolean;
/**
* 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.
*/
disableScrollLock?: boolean;
/**
* 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.
* @example
* <Dialog open={renameOpen()} onOpenChange={setRenameOpen} initialFocus="#label-name" />
*/
initialFocus?: (string | Element) | null;
/**
* Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
*/
ariaLabel?: (string) | null;
/**
* The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog.
*/
ariaLabelledby?: (string) | null;
onClose?: (payload: DialogClosePayload) => void;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: DialogHandle) => void;
}
export interface DialogHandle {
show: (...args: any[]) => any;
hide: (...args: any[]) => any;
}
export default function Dialog(_props: DialogProps): JSX.Element {
const _merged = mergeProps({ disableBackdropClose: false, disableEscapeClose: false, disableScrollLock: false, initialFocus: null, ariaLabel: null, ariaLabelledby: null }, _props);
const [local, attrs] = splitProps(_merged, ['open', 'disableBackdropClose', 'disableEscapeClose', 'disableScrollLock', 'initialFocus', 'ariaLabel', 'ariaLabelledby', 'children', 'ref', 'onClose']);
const resolved = children(() => local.children);
onMount(() => { local.ref?.({ show, hide }); });
const [open, setOpen] = createControllableSignal<boolean>(_props as unknown as Record<string, unknown>, 'open', false);
onMount(() => {
sync(open());
});
onCleanup(() => {
setScrollLock(false);
const el = dialogEl;
const back = returnFocusTo;
dialogEl = null;
returnFocusTo = null;
if (!el || !el.open) return;
if (el.isConnected) el.close();
if (!back) return;
setTimeout(() => {
// Lost: on <body>, or stranded on a shadow host whose root holds no focus
// and which is not focusable itself (the dialog's removed subtree was
// inside it).
const active = deepActiveElement();
const lost = !active || active === document.body || !!active.shadowRoot && active.tabIndex < 0;
if (back.isConnected && lost) back.focus();
}, 0);
});
createEffect(on(() => (() => open())(), (v) => untrack(() => ((isOpen: any) => {
sync(isOpen);
})(v)), { defer: true }));
let panelElRef: HTMLElement | null = null;
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
let dialogEl: HTMLDialogElement | null = null;
// Whether THIS instance currently holds one count of the shared scroll lock.
let holdsLock = false;
// The element focused when the dialog opened, for the unmount-while-open
// focus return (a normal close gets the native return from close()).
let returnFocusTo: HTMLElement | null = null;
// Lock/unlock <html> scroll for this instance. The actual lock/unlock is
// REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
// leaf's module, because a naive per-instance toggle unlocks scrolling the
// moment ANY dialog closes, even while an OUTER dialog is still open. This
// wrapper releases only a count this instance took: a dialog that mounts closed
// (or closes twice) must not release a count another open dialog holds. The
// opt-out is read when locking only, so toggling it while open still releases.
function setScrollLock(lock: any) {
if (lock === holdsLock) return;
if (lock && local.disableScrollLock) return;
holdsLock = lock;
applySharedScrollLock(lock);
}
// The element that really has focus. `document.activeElement` stops at the
// outermost shadow host (on Lit the trigger lives inside a component's shadow
// root, so it reports that component), so walk each shadowRoot.activeElement.
function deepActiveElement() {
let active: any = typeof document === 'undefined' ? null : document.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
return active;
}
// Focus `initialFocus` after showModal() has made its native choice. A selector
// is matched inside the panel and, on Lit, inside the light-DOM content
// assigned to the panel's <slot> (which panel.querySelector cannot see).
function focusInitial(panel: any) {
const target: any = local.initialFocus;
if (!target) return;
let node: any = null;
if (typeof target === 'string') {
node = panel.querySelector(target);
const slot: any = node ? null : panel.querySelector('slot');
const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({
flatten: true
}) : [];
for (let i = 0; !node && i < assigned.length; i++) {
node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target);
}
} else {
node = target;
}
if (node && typeof node.focus === 'function') node.focus();
}
// Reconcile the native <dialog> to the desired open state. Guarded on the
// native `el.open` flag (showModal throws if already open; close is a no-op when
// closed). Reads $refs in a post-mount callback (ROZ123-safe).
//
// The ref lives on the inner panel <div> (which the emitter types as
// HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
// HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
// has no `dialog` case, so a ref placed directly on <dialog> would be typed the
// generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
// leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
function sync(isOpen: any) {
const panel = panelElRef;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
dialogEl = el;
if (isOpen) {
if (!el.open) {
const active = deepActiveElement();
returnFocusTo = active instanceof HTMLElement ? active : null;
el.showModal();
focusInitial(panel);
}
setScrollLock(true);
} else {
if (el.open) el.close();
returnFocusTo = null;
setScrollLock(false);
}
}
// ---- close funnel (single $emit site) ----------------------------------
function closeWith(reason: any) {
setOpen(false);
_props.onClose?.({
reason
});
}
// ---- handlers ----------------------------------------------------------
// Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
// close through the model (keeping `open` in sync); honor the opt-out.
function onCancel(e: any) {
if (e) e.preventDefault();
if (local.disableEscapeClose) return;
closeWith('escape');
}
// A click whose target IS the <dialog> element (not its panel/children) is a
// backdrop click — the ::backdrop is part of the dialog box. We compare the
// real `e.target` (reliable even under Solid's event delegation) to the dialog
// element resolved via the panel ref's parent.
function onClick(e: any) {
if (local.disableBackdropClose) return;
const panel = panelElRef;
const el = panel && panel.parentElement;
if (e && el && e.target === el) closeWith('backdrop');
}
// ---- lifecycle ---------------------------------------------------------
// ---- imperative handle -------------------------------------------------
// show()/hide() — named to avoid the `open` model + `@close` event collisions.
function show() {
setOpen(true);
}
function hide() {
closeWith('programmatic');
}
return (
<>
<dialog aria-label={rozieAttr(local.ariaLabel)} aria-labelledby={rozieAttr(local.ariaLabelledby)} {...attrs} class={"rozie-dialog" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onCancel: ($event: Event & { currentTarget: HTMLDialogElement; target: Element }) => { onCancel($event); }, onClick: ($event: MouseEvent & { currentTarget: HTMLDialogElement; target: Element }) => { onClick($event); } }, pickListeners(attrs))} data-rozie-s-2a679072="">
<div class={"rozie-dialog-panel"} ref={(el) => { panelElRef = el as HTMLElement; }} data-rozie-s-2a679072="">
{resolved()}
</div>
</dialog>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { applyScrollLock as applySharedScrollLock } from './internal/scrollLock';
// ---- native reconcile ---------------------------------------------------
// The <dialog> element, cached by sync() so $onUnmount can reach it without
// reading $refs during teardown.
/** Why the dialog closed: a scrim click, the Escape key, or a programmatic `hide()`. */
export type DialogCloseReason = 'backdrop' | 'escape' | 'programmatic';
/** The `close` event payload. */
export interface DialogClosePayload {
reason: DialogCloseReason;
}
export interface RozieDialogEventMap extends Omit<HTMLElementEventMap, 'close' | 'open-change'> {
'close': CustomEvent<DialogClosePayload>;
'open-change': CustomEvent<boolean>;
}
@customElement('rozie-dialog')
export default class Dialog extends SignalWatcher(LitElement) {
static styles = css`
:host{display:contents}
@media (prefers-reduced-motion: no-preference) {
.rozie-dialog[data-rozie-s-2a679072] {
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;
opacity: 1;
transform: translateY(0) scale(1);
}
.rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072]) {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
@starting-style {
.rozie-dialog[open][data-rozie-s-2a679072] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
}
.rozie-dialog[data-rozie-s-2a679072]::backdrop {
transition: opacity var(--rozie-dialog-transition, var(--rdg-transition, 0.15s ease)), overlay 0.15s ease allow-discrete, display 0.15s ease allow-discrete;
opacity: 1;
}
.rozie-dialog[data-rozie-s-2a679072]:not([open][data-rozie-s-2a679072])::backdrop {
opacity: 0;
}
@starting-style {
.rozie-dialog[open][data-rozie-s-2a679072]::backdrop {
opacity: 0;
}
}
}
.rozie-dialog[data-rozie-s-2a679072] {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, var(--rdg-width, auto));
max-width: var(--rozie-dialog-max-width, var(--rdg-max-width, min(32rem, calc(100vw - 2rem))));
max-height: var(--rozie-dialog-max-height, var(--rdg-max-height, calc(100vh - 2rem)));
border: var(--rozie-dialog-border, var(--rdg-border, none));
border-radius: var(--rozie-dialog-radius, var(--rdg-radius, 0.75rem));
background: var(--rozie-dialog-bg, var(--rdg-bg, #fff));
color: var(--rozie-dialog-color, var(--rdg-color, inherit));
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)));
overflow: auto;
}
.rozie-dialog[data-rozie-s-2a679072]::backdrop {
background: var(--rozie-dialog-backdrop-bg, var(--rdg-backdrop-bg, rgba(0, 0, 0, 0.5)));
backdrop-filter: var(--rozie-dialog-backdrop-filter, var(--rdg-backdrop-filter, none));
}
.rozie-dialog-panel[data-rozie-s-2a679072] {
padding: var(--rozie-dialog-padding, var(--rdg-padding, 1.5rem));
font: var(--rozie-dialog-font, inherit);
}
`;
/**
* 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`.
* @example
* <rozie-dialog .open=${confirmOpen} @open-change=${…} ariaLabelledby="confirm-title"></rozie-dialog>
*/
@property({ type: Boolean, attribute: 'open' }) _open_attr: boolean = false;
private _openControllable = createLitControllableProperty<boolean>({ host: this, eventName: 'open-change', defaultValue: false, initialControlledValue: undefined });
/**
* 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.
*/
@property({ type: Boolean, reflect: true, attribute: 'disable-backdrop-close' }) disableBackdropClose: boolean = false;
/**
* 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).
*/
@property({ type: Boolean, reflect: true, attribute: 'disable-escape-close' }) disableEscapeClose: boolean = false;
/**
* 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.
*/
@property({ type: Boolean, reflect: true, attribute: 'disable-scroll-lock' }) disableScrollLock: boolean = false;
/**
* 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.
* @example
* <rozie-dialog .open=${renameOpen} @open-change=${…} initialFocus="#label-name"></rozie-dialog>
*/
@property({ type: String, attribute: 'initial-focus' }) initialFocus: string | Element | null = null;
/**
* Accessible name for the dialog (`aria-label`) when there is no visible title to point at. Prefer `ariaLabelledby` when a visible heading exists.
*/
@property({ type: String, reflect: true, attribute: 'aria-label' }) ariaLabel: string | null = null;
/**
* The `id` of the element that titles the dialog (`aria-labelledby`) — preferred over `ariaLabel` when a visible heading exists inside the dialog.
*/
@property({ type: String, reflect: true, attribute: 'aria-labelledby' }) ariaLabelledby: string | null = null;
@query('[data-rozie-ref="panelEl"]') private _refPanelEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.open)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((isOpen: any) => {
this.sync(isOpen);
})(__watchVal); }); }));
this.sync(this.open);
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
(() => {
this.setScrollLock(false);
const el = this.dialogEl;
const back = this.returnFocusTo;
this.dialogEl = null;
this.returnFocusTo = null;
if (!el || !el.open) return;
if (el.isConnected) el.close();
if (!back) return;
setTimeout(() => {
// Lost: on <body>, or stranded on a shadow host whose root holds no focus
// and which is not focusable itself (the dialog's removed subtree was
// inside it).
const active = this.deepActiveElement();
const lost = !active || active === document.body || !!active.shadowRoot && active.tabIndex < 0;
if (back.isConnected && lost) back.focus();
}, 0);
})();
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'open') this._openControllable.notifyAttributeChange(value !== null);
}
render() {
return html`
<dialog class="rozie-dialog" aria-label=${rozieAttr(this.ariaLabel)} aria-labelledby=${rozieAttr(this.ariaLabelledby)} ${rozieSpread(this.$attrs)} @cancel=${($event: Event & { currentTarget: HTMLDialogElement; target: HTMLDialogElement }) => { this.onCancel($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLDialogElement; target: HTMLDialogElement }) => { this.onClick($event); }} ${rozieListeners(this.$listeners)} data-rozie-s-2a679072>
<div class="rozie-dialog-panel" data-rozie-ref="panelEl" data-rozie-s-2a679072>
<slot></slot>
</div>
</dialog>
`;
}
dialogEl: HTMLDialogElement | null = null;
// Whether THIS instance currently holds one count of the shared scroll lock.
holdsLock = false;
// The element focused when the dialog opened, for the unmount-while-open
// focus return (a normal close gets the native return from close()).
returnFocusTo: HTMLElement | null = null;
// Lock/unlock <html> scroll for this instance. The actual lock/unlock is
// REF-COUNTED (./internal/scrollLock) across every Dialog instance sharing this
// leaf's module, because a naive per-instance toggle unlocks scrolling the
// moment ANY dialog closes, even while an OUTER dialog is still open. This
// wrapper releases only a count this instance took: a dialog that mounts closed
// (or closes twice) must not release a count another open dialog holds. The
// opt-out is read when locking only, so toggling it while open still releases.
setScrollLock = (lock: any) => {
if (lock === this.holdsLock) return;
if (lock && this.disableScrollLock) return;
this.holdsLock = lock;
applySharedScrollLock(lock);
};
// The element that really has focus. `document.activeElement` stops at the
// outermost shadow host (on Lit the trigger lives inside a component's shadow
// root, so it reports that component), so walk each shadowRoot.activeElement.
deepActiveElement = () => {
let active: any = typeof document === 'undefined' ? null : document.activeElement;
while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
return active;
};
// Focus `initialFocus` after showModal() has made its native choice. A selector
// is matched inside the panel and, on Lit, inside the light-DOM content
// assigned to the panel's <slot> (which panel.querySelector cannot see).
focusInitial = (panel: any) => {
const target: any = this.initialFocus;
if (!target) return;
let node: any = null;
if (typeof target === 'string') {
node = panel.querySelector(target);
const slot: any = node ? null : panel.querySelector('slot');
const assigned: any[] = slot && typeof slot.assignedElements === 'function' ? slot.assignedElements({
flatten: true
}) : [];
for (let i = 0; !node && i < assigned.length; i++) {
node = assigned[i].matches(target) ? assigned[i] : assigned[i].querySelector(target);
}
} else {
node = target;
}
if (node && typeof node.focus === 'function') node.focus();
};
// Reconcile the native <dialog> to the desired open state. Guarded on the
// native `el.open` flag (showModal throws if already open; close is a no-op when
// closed). Reads $refs in a post-mount callback (ROZ123-safe).
//
// The ref lives on the inner panel <div> (which the emitter types as
// HTMLDivElement), and we reach the <dialog> via `panel.parentElement` cast to
// HTMLDialogElement. This sidesteps an emitter gap: the per-target ref-type map
// has no `dialog` case, so a ref placed directly on <dialog> would be typed the
// generic HTMLElement (no `.open`/`.showModal()`/`.close()`), failing strict
// leaf typecheck. Fixing it here keeps the change source-only (no emitter edit).
sync = (isOpen: any) => {
const panel = this._refPanelEl;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
this.dialogEl = el;
if (isOpen) {
if (!el.open) {
const active = this.deepActiveElement();
this.returnFocusTo = active instanceof HTMLElement ? active : null;
el.showModal();
this.focusInitial(panel);
}
this.setScrollLock(true);
} else {
if (el.open) el.close();
this.returnFocusTo = null;
this.setScrollLock(false);
}
};
// ---- close funnel (single $emit site) ----------------------------------
closeWith = (reason: any) => {
this._openControllable.write(false);
this.dispatchEvent(new CustomEvent<DialogClosePayload>("close", {
detail: {
reason
},
bubbles: true,
composed: true
}));
};
// ---- handlers ----------------------------------------------------------
// Native Esc fires `cancel` on the <dialog>. preventDefault so WE drive the
// close through the model (keeping `open` in sync); honor the opt-out.
onCancel = (e: any) => {
if (e) e.preventDefault();
if (this.disableEscapeClose) return;
this.closeWith('escape');
};
// A click whose target IS the <dialog> element (not its panel/children) is a
// backdrop click — the ::backdrop is part of the dialog box. We compare the
// real `e.target` (reliable even under Solid's event delegation) to the dialog
// element resolved via the panel ref's parent.
onClick = (e: any) => {
if (this.disableBackdropClose) return;
const panel = this._refPanelEl;
const el = panel && panel.parentElement;
if (e && el && e.target === el) this.closeWith('backdrop');
};
// ---- lifecycle ---------------------------------------------------------
// ---- imperative handle -------------------------------------------------
// show()/hide() — named to avoid the `open` model + `@close` event collisions.
show = () => {
this._openControllable.write(true);
};
hide = () => {
this.closeWith('programmatic');
};
get open(): boolean { return this._openControllable.read(); }
set open(v: boolean) { this._openControllable.notifyPropertyWrite(v); }
addEventListener<K extends keyof RozieDialogEventMap>(type: K, listener: (this: Dialog, ev: RozieDialogEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void {
super.addEventListener(type, listener, options);
}
removeEventListener<K extends keyof RozieDialogEventMap>(type: K, listener: (this: Dialog, ev: RozieDialogEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void {
super.removeEventListener(type, listener, options);
}
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*
* command-palette-per-level-virtual / portal-through-portal cluster —
* `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
* attribute, never a consumer prop) so a parent-assigned `ref=` on this
* component's own host tag can never clobber this component's OWN
* internal `data-rozie-ref` ref markers via fallthrough re-application.
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['data-rozie-ref', 'open', 'disable-backdrop-close', 'disablebackdropclose', 'disable-escape-close', 'disableescapeclose', 'disable-scroll-lock', 'disablescrolllock', 'initial-focus', 'initialfocus', 'aria-label', 'arialabel', 'aria-labelledby', 'arialabelledby']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same close event, same two-way open, same show / hide handle — identical on every target, built on the native <dialog> with no third-party engine behind it.
See also
- Dialog — showcase & API — install, quick start, theming, and the full reference.
- Headless modal dialog comparison — how
@rozie-ui/dialogstacks up against Radix Dialog, Headless UI Dialog, the native<dialog>, vue-final-modal, and Angular CDK Dialog.