Skip to content

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 ​

Pre-1.0 — APIs may change between minor versions.