Appearance
Dialog — live demo
This is the real @rozie-ui/dialog-vue package running on this page (VitePress is itself a Vue app). 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. Everything below is driven by the same Dialog.rozie source that compiles to all six frameworks, 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.
One source, six outputs
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">
<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.',
},
},
// 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>
<script lang="ts">
// ---- native reconcile ---------------------------------------------------
// Lock/unlock <html> scroll (no-op when disabled or pre-DOM).
const applyScrollLock = (lock) => {
if ($props.disableScrollLock) return
if (typeof document === 'undefined') return
const root = document.documentElement
if (root) root.style.overflow = lock ? 'hidden' : ''
}
// 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
if (isOpen) {
if (!el.open) el.showModal()
applyScrollLock(true)
} else {
if (el.open) el.close()
applyScrollLock(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)
})
// ---- 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, auto);
max-width: var(--rozie-dialog-max-width, min(32rem, calc(100vw - 2rem)));
max-height: var(--rozie-dialog-max-height, calc(100vh - 2rem));
border: var(--rozie-dialog-border, none);
border-radius: var(--rozie-dialog-radius, 0.75rem);
background: var(--rozie-dialog-bg, #fff);
color: var(--rozie-dialog-color, inherit);
box-shadow: var(--rozie-dialog-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, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--rozie-dialog-backdrop-filter, none);
}
.rozie-dialog-panel {
padding: var(--rozie-dialog-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, 0.15s ease), transform var(--rozie-dialog-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, 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 idiomatic, 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, rozieAttr, useControllableState } from '@rozie/runtime-react';
import './Dialog.css';
interface DialogProps {
/**
* 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" />
*/
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;
/**
* 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?: (...args: any[]) => 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' | 'ariaLabel' | 'ariaLabelledby'> & { disableBackdropClose: boolean; disableEscapeClose: boolean; disableScrollLock: boolean; ariaLabel: (string) | null; ariaLabelledby: (string) | null } = {
..._props,
disableBackdropClose: _props.disableBackdropClose ?? false,
disableEscapeClose: _props.disableEscapeClose ?? false,
disableScrollLock: _props.disableScrollLock ?? false,
ariaLabel: _props.ariaLabel ?? null,
ariaLabelledby: _props.ariaLabelledby ?? null,
};
const attrs: Record<string, unknown> = (() => {
const { open, disableBackdropClose, disableEscapeClose, disableScrollLock, ariaLabel, ariaLabelledby, defaultValue, onOpenChange, defaultOpen, ...rest } = _props as DialogProps & Record<string, unknown>;
void open; void disableBackdropClose; void disableEscapeClose; void disableScrollLock; void ariaLabel; void ariaLabelledby; void defaultValue; void onOpenChange; void defaultOpen;
return rest;
})();
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);
function applyScrollLock(lock: any) {
if (props.disableScrollLock) return;
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (root) root.style.overflow = lock ? 'hidden' : '';
}
const sync = useCallback((isOpen: any) => {
const panel = panelEl.current;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
if (isOpen) {
if (!el.open) el.showModal();
applyScrollLock(true);
} else {
if (el.open) el.close();
applyScrollLock(false);
}
}, [applyScrollLock]);
function closeWith(reason: any) {
setOpen(false);
props.onClose && props.onClose({
reason
});
}
const onCancel = useCallback((e: any) => {
if (e) e.preventDefault();
if (props.disableEscapeClose) return;
closeWith('escape');
}, [closeWith, props.disableEscapeClose]);
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]);
function show() {
setOpen(true);
}
function hide() {
closeWith('programmatic');
}
useEffect(() => {
sync(_openRef.current);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
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))} onCancel={($event) => { onCancel($event); }} onClick={($event) => { onClick($event); }} 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 setup lang="ts">
import { onMounted, ref, watch } from 'vue';
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;
/**
* 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, 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 r-model:open="confirmOpen" ariaLabelledby="confirm-title" />
*/
const open = defineModel<boolean>('open', { default: false });
const emit = defineEmits<{
close: [...args: any[]];
}>();
defineSlots<{
default(props: { }): any;
}>();
const panelElRef = ref<HTMLElement>();
// ---- native reconcile ---------------------------------------------------
// Lock/unlock <html> scroll (no-op when disabled or pre-DOM).
const applyScrollLock = (lock: any) => {
if (props.disableScrollLock) return;
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (root) root.style.overflow = lock ? 'hidden' : '';
};
// 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).
// 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;
if (isOpen) {
if (!el.open) el.showModal();
applyScrollLock(true);
} else {
if (el.open) el.close();
applyScrollLock(false);
}
};
// ---- close funnel (single $emit site) ----------------------------------
// ---- 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.
// ---- 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.
// 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);
});
watch(() => open.value, (isOpen: any) => {
sync(isOpen);
});
defineExpose({ show, hide });
</script>
<style scoped>
.rozie-dialog {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, auto);
max-width: var(--rozie-dialog-max-width, min(32rem, calc(100vw - 2rem)));
max-height: var(--rozie-dialog-max-height, calc(100vh - 2rem));
border: var(--rozie-dialog-border, none);
border-radius: var(--rozie-dialog-radius, 0.75rem);
background: var(--rozie-dialog-bg, #fff);
color: var(--rozie-dialog-color, inherit);
box-shadow: var(--rozie-dialog-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, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--rozie-dialog-backdrop-filter, none);
}
.rozie-dialog-panel {
padding: var(--rozie-dialog-padding, 1.5rem);
font: var(--rozie-dialog-font, inherit);
}
.rozie-dialog {
transition: opacity var(--rozie-dialog-transition, 0.15s ease), transform var(--rozie-dialog-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);
}
.rozie-dialog[open] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
.rozie-dialog::backdrop {
transition: opacity var(--rozie-dialog-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;
}
.rozie-dialog[open]::backdrop {
opacity: 0;
}
</style>svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* 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" />
*/
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;
/**
* 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?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let {
open = $bindable(false),
disableBackdropClose = false,
disableEscapeClose = false,
disableScrollLock = false,
ariaLabel = null,
ariaLabelledby = null,
children: __childrenProp,
snippets,
onclose,
...__rozieAttrs
}: Props = $props();
const children = $derived(__childrenProp ?? snippets?.children);
let panelEl = $state<HTMLElement | undefined>(undefined);
// ---- native reconcile ---------------------------------------------------
// Lock/unlock <html> scroll (no-op when disabled or pre-DOM).
const applyScrollLock = (lock: any) => {
if (disableScrollLock) return;
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (root) root.style.overflow = lock ? 'hidden' : '';
};
// 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).
// 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;
if (isOpen) {
if (!el.open) el.showModal();
applyScrollLock(true);
} else {
if (el.open) el.close();
applyScrollLock(false);
}
};
// ---- close funnel (single $emit site) ----------------------------------
// ---- 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.
// ---- 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.
// 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);
});
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 {
.rozie-dialog[data-rozie-s-2a679072] {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, auto);
max-width: var(--rozie-dialog-max-width, min(32rem, calc(100vw - 2rem)));
max-height: var(--rozie-dialog-max-height, calc(100vh - 2rem));
border: var(--rozie-dialog-border, none);
border-radius: var(--rozie-dialog-radius, 0.75rem);
background: var(--rozie-dialog-bg, #fff);
color: var(--rozie-dialog-color, inherit);
box-shadow: var(--rozie-dialog-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, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--rozie-dialog-backdrop-filter, none);
}
.rozie-dialog-panel[data-rozie-s-2a679072] {
padding: var(--rozie-dialog-padding, 1.5rem);
font: var(--rozie-dialog-font, inherit);
}
.rozie-dialog[data-rozie-s-2a679072] {
transition: opacity var(--rozie-dialog-transition, 0.15s ease), transform var(--rozie-dialog-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);
}
.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, 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;
}
.rozie-dialog[open][data-rozie-s-2a679072]::backdrop {
opacity: 0;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
interface DefaultCtx {}
@Component({
selector: 'rozie-dialog',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<dialog class="rozie-dialog" [attr.aria-label]="ariaLabel()" [attr.aria-labelledby]="ariaLabelledby()" #rozieSpread_0 (cancel)="onCancel($event)" (click)="onClick($event)" #rozieListenersTarget_1>
<div class="rozie-dialog-panel" #panelEl>
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot'])" />
</div>
</dialog>
`,
styles: [`
.rozie-dialog {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, auto);
max-width: var(--rozie-dialog-max-width, min(32rem, calc(100vw - 2rem)));
max-height: var(--rozie-dialog-max-height, calc(100vh - 2rem));
border: var(--rozie-dialog-border, none);
border-radius: var(--rozie-dialog-radius, 0.75rem);
background: var(--rozie-dialog-bg, #fff);
color: var(--rozie-dialog-color, inherit);
box-shadow: var(--rozie-dialog-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, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--rozie-dialog-backdrop-filter, none);
}
.rozie-dialog-panel {
padding: var(--rozie-dialog-padding, 1.5rem);
font: var(--rozie-dialog-font, inherit);
}
.rozie-dialog {
transition: opacity var(--rozie-dialog-transition, 0.15s ease), transform var(--rozie-dialog-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);
}
.rozie-dialog[open] {
opacity: 0;
transform: translateY(0.5rem) scale(0.98);
}
.rozie-dialog::backdrop {
transition: opacity var(--rozie-dialog-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;
}
.rozie-dialog[open]::backdrop {
opacity: 0;
}
`],
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
* <Dialog r-model: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);
/**
* 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<unknown>();
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private __rozieWatchInitial_0 = true;
constructor() {
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());
}
applyScrollLock = (lock: any) => {
if (this.disableScrollLock()) return;
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (root) root.style.overflow = lock ? 'hidden' : '';
};
sync = (isOpen: any) => {
const panel = this.panelEl()?.nativeElement;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
if (isOpen) {
if (!el.open) el.showModal();
this.applyScrollLock(true);
} else {
if (el.open) el.close();
this.applyScrollLock(false);
}
};
closeWith = (reason: any) => {
this.open.set(false), this.__rozieCvaOnChange(false);
this.close.emit({
reason
});
};
onCancel = (e: any) => {
if (e) e.preventDefault();
if (this.disableEscapeClose()) return;
this.closeWith('escape');
};
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');
};
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 = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
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 = [];
});
}
});
}
export default Dialog;tsx
import type { JSX } from 'solid-js';
import { children, createEffect, mergeProps, on, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, mergeListeners, rozieAttr } from '@rozie/runtime-solid';
__rozieInjectStyle('Dialog-2a679072', `.rozie-dialog[data-rozie-s-2a679072] {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, auto);
max-width: var(--rozie-dialog-max-width, min(32rem, calc(100vw - 2rem)));
max-height: var(--rozie-dialog-max-height, calc(100vh - 2rem));
border: var(--rozie-dialog-border, none);
border-radius: var(--rozie-dialog-radius, 0.75rem);
background: var(--rozie-dialog-bg, #fff);
color: var(--rozie-dialog-color, inherit);
box-shadow: var(--rozie-dialog-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, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--rozie-dialog-backdrop-filter, none);
}
.rozie-dialog-panel[data-rozie-s-2a679072] {
padding: var(--rozie-dialog-padding, 1.5rem);
font: var(--rozie-dialog-font, inherit);
}
.rozie-dialog[data-rozie-s-2a679072] {
transition: opacity var(--rozie-dialog-transition, 0.15s ease), transform var(--rozie-dialog-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);
}
.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, 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;
}
.rozie-dialog[open][data-rozie-s-2a679072]::backdrop {
opacity: 0;
}`);
interface DialogProps {
/**
* 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" />
*/
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;
/**
* 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?: (...args: unknown[]) => 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, ariaLabel: null, ariaLabelledby: null }, _props);
const [local, attrs] = splitProps(_merged, ['open', 'disableBackdropClose', 'disableEscapeClose', 'disableScrollLock', 'ariaLabel', 'ariaLabelledby', 'children', 'ref']);
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());
});
createEffect(on(() => (() => open())(), (v) => untrack(() => ((isOpen: any) => {
sync(isOpen);
})(v)), { defer: true }));
let panelElRef: HTMLElement | null = null;
// ---- native reconcile ---------------------------------------------------
// Lock/unlock <html> scroll (no-op when disabled or pre-DOM).
function applyScrollLock(lock: any) {
if (local.disableScrollLock) return;
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (root) root.style.overflow = lock ? 'hidden' : '';
}
// 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;
if (isOpen) {
if (!el.open) el.showModal();
applyScrollLock(true);
} else {
if (el.open) el.close();
applyScrollLock(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) => { onCancel($event); }, onClick: ($event) => { onClick($event); } }, 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, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
@customElement('rozie-dialog')
export default class Dialog extends SignalWatcher(LitElement) {
static styles = css`
.rozie-dialog[data-rozie-s-2a679072] {
margin: auto; /* centers in the top layer */
padding: 0;
width: var(--rozie-dialog-width, auto);
max-width: var(--rozie-dialog-max-width, min(32rem, calc(100vw - 2rem)));
max-height: var(--rozie-dialog-max-height, calc(100vh - 2rem));
border: var(--rozie-dialog-border, none);
border-radius: var(--rozie-dialog-radius, 0.75rem);
background: var(--rozie-dialog-bg, #fff);
color: var(--rozie-dialog-color, inherit);
box-shadow: var(--rozie-dialog-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, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--rozie-dialog-backdrop-filter, none);
}
.rozie-dialog-panel[data-rozie-s-2a679072] {
padding: var(--rozie-dialog-padding, 1.5rem);
font: var(--rozie-dialog-font, inherit);
}
.rozie-dialog[data-rozie-s-2a679072] {
transition: opacity var(--rozie-dialog-transition, 0.15s ease), transform var(--rozie-dialog-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);
}
.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, 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;
}
.rozie-dialog[open][data-rozie-s-2a679072]::backdrop {
opacity: 0;
}
`;
/**
* 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" />
*/
@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 }) 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 }) 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 }) disableScrollLock: boolean = false;
/**
* 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 }) 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 }) 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;
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=${this.ariaLabel} aria-labelledby=${this.ariaLabelledby} ${rozieSpread(this.$attrs)} @cancel=${($event: Event) => { this.onCancel($event); }} @click=${($event: Event) => { 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>
`;
}
applyScrollLock = (lock: any) => {
if (this.disableScrollLock) return;
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (root) root.style.overflow = lock ? 'hidden' : '';
};
sync = (isOpen: any) => {
const panel = this._refPanelEl;
const el = (panel && panel.parentElement) as HTMLDialogElement | null;
if (!el) return;
if (isOpen) {
if (!el.open) el.showModal();
this.applyScrollLock(true);
} else {
if (el.open) el.close();
this.applyScrollLock(false);
}
};
closeWith = (reason: any) => {
this._openControllable.write(false);
this.dispatchEvent(new CustomEvent("close", {
detail: {
reason
},
bubbles: true,
composed: true
}));
};
onCancel = (e: any) => {
if (e) e.preventDefault();
if (this.disableEscapeClose) return;
this.closeWith('escape');
};
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');
};
show = () => {
this._openControllable.write(true);
};
hide = () => {
this.closeWith('programmatic');
};
get open(): boolean { return this._openControllable.read(); }
set open(v: boolean) { this._openControllable.notifyPropertyWrite(v); }
/**
* 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).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['open', 'disable-backdrop-close', 'disablebackdropclose', 'disable-escape-close', 'disableescapeclose', 'disable-scroll-lock', 'disablescrolllock', '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, idiomatic 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 — all from the one source above, 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.