{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "dialog",
  "title": "Dialog",
  "type": "registry:ui",
  "description": "Free-form modal primitive — composable from `Dialog`, `DialogTrigger`, `DialogContent`, and friends. Use for forms, info cards, pickers, and any custom modal layout. For confirm/destructive prompts, prefer the prebuilt `AlertModal` shortcut.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/dialog/dialog.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nexport type DialogCloseBehavior = 'auto' | 'click-outside' | 'esc' | 'manual' | 'none'\n\nlet uid = 0\n\n// Boolean props whose default is `true`: `attr=\"false\"` turns them off.\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\n// React's DialogContent classes verbatim; `grid` is `open:grid` (a plain\n// `grid` beats the closed <dialog>'s UA `display: none`) and the overlay is the\n// dialog's ::backdrop. `text-foreground` undoes the modal dialog's UA\n// `color: CanvasText` (React's portal inherits the page's text colour).\nconst contentClasses =\n  'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-emphasized data-[state=open]:blur-in-2 data-[state=closed]:blur-out-2 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 hidden open:grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 shadow-lg duration-200 data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200 sm:max-w-lg'\n\n// DialogScrollContent: the <dialog> is React's scrolling overlay (full\n// viewport, `grid place-items-center overflow-y-auto`), the content is a box\n// inside it — so a long body scrolls the overlay, not the page. The UA\n// modal-dialog box (margin, max size, border, padding) is undone.\nconst scrollOverlayClasses = cn(\n  'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-emphasized data-[state=open]:blur-in-2 data-[state=closed]:blur-out-2 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-foreground/50 fixed inset-0 z-50 hidden open:grid place-items-center overflow-y-auto data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200',\n  'text-foreground m-0 size-full max-h-none max-w-none border-0 p-0 backdrop:bg-transparent',\n)\nconst scrollContentClasses =\n  'bg-background relative z-50 my-4 grid w-full max-w-lg gap-4 border p-4 shadow-lg duration-200 sm:rounded-lg md:w-full'\n\n// React's DialogOverlay classes verbatim — rendered as a sibling <div> only when\n// `modal` is false (a non-modal <dialog> has no ::backdrop). `pointer-events-none`\n// keeps the page interactive, which is the point of a non-modal dialog;\n// dismissal is handled by a document pointerdown listener instead.\nconst backdropClasses =\n  'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:ease-emphasized data-[state=open]:blur-in-2 data-[state=closed]:blur-out-2 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-foreground/50 fixed inset-0 z-50 data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200'\n\nconst closeClasses =\n  \"text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-md transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\nconst scrollCloseClasses =\n  'text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-md transition-colors outline-none focus-visible:ring-[3px]'\n\n/**\n * <uip-dialog> — the registry Dialog (Dialog + DialogTrigger + DialogContent /\n * DialogScrollContent + DialogHeader/Title/Description + DialogFooter +\n * DialogClose) as ONE web component.\n *\n *   <uip-dialog heading=\"Edit profile\" description=\"…\" class=\"[&::part(content)]:sm:max-w-md\">\n *     <uip-button slot=\"trigger\" variant=\"outline\">Edit profile</uip-button>\n *     …body…\n *     <uip-button slot=\"footer\" variant=\"outline\" data-dialog-close>Cancel</uip-button>\n *     <uip-button slot=\"footer\">Save changes</uip-button>\n *   </uip-dialog>\n *\n * Built on the native <dialog> + showModal(): the browser puts it in the top\n * layer (no portal), makes the page inert, traps focus, restores focus to the\n * trigger on close and closes on Escape. A click on the backdrop closes it.\n *\n * ARIA references can't cross a shadow boundary, so the accessible name and\n * description come from the `heading` / `description` attributes rendered\n * INSIDE the shadow root (aria-labelledby/-describedby then resolve). The\n * `title` / `description` slots accept rich markup instead; a slotted title\n * names the dialog from its text.\n *\n * - Any slotted element with `data-dialog-close` closes the dialog (DialogClose).\n * - Wrap a long body in <uip-dialog-body> (DialogBody) to scroll it at 60vh\n *   between the pinned header and footer.\n * - `scroll-content`: DialogScrollContent — the overlay scrolls, for very long\n *   bodies (the close button is always shown, with React's scroll-content style).\n * - `hide-header`: keeps the header for assistive tech but visually hidden\n *   (`sr-only`). The header / footer are dropped when they have no content.\n * - `show-close-button=\"false\"` hides the top-right close button.\n * - `modal` (default true; `modal=\"false\"` for non-modal, like Radix's `modal`\n *   prop): non-modal uses `show()` — the page stays interactive (no inert, no\n *   focus trap) and the dimming is a plain `pointer-events-none` backdrop div\n *   (a non-modal dialog has no ::backdrop).\n * - `close-behavior`: `auto` (default) | `click-outside` | `esc` | `manual` |\n *   `none` — which dismissals are allowed (same values as <uip-popover>).\n *   `manual` / `none` block Escape and backdrop dismissal; unlike the popover,\n *   the `cancel` event still fires so consumers can confirm-then-close.\n * - `persist`: localStorage key for the open state (empty attribute = an\n *   auto-generated key), like <uip-popover>'s `persist`.\n *\n * Parts (style from the host, e.g. `class=\"[&::part(content)]:sm:max-w-md\n * [&::part(footer)]:sm:justify-start\"`): `content`, `overlay` (only with\n * `scroll-content`; otherwise the overlay is `content`'s ::backdrop),\n * `backdrop` (only non-modal: the dimming div), `header`, `title`,\n * `description`, `footer`, `close`.\n *\n * Slots: `trigger` (opens on click), `header-start` (inside the header, above\n * the title — e.g. an icon), `title`, `description`, default (body), `footer`.\n * Methods: `show()`, `close()`. Events: `open-change` (detail: { open }),\n * `cancel` (cancelable, detail: { reason: 'escape' | 'backdrop' }) and `close`\n * (cancelable, detail: { reason }) — fired in that order when Escape or a\n * backdrop click requests dismissal; `preventDefault()` on either keeps the\n * dialog open. The close button, `data-dialog-close` elements and `close()`\n * always close (like Radix's DialogClose).\n */\nexport class UipDialog extends LitElement {\n  static styles = [tailwind]\n\n  static properties = {\n    open: { type: Boolean, reflect: true },\n    heading: {},\n    description: {},\n    showCloseButton: { type: Boolean, attribute: 'show-close-button', converter: trueByDefault },\n    hideHeader: { type: Boolean, attribute: 'hide-header' },\n    scrollContent: { type: Boolean, attribute: 'scroll-content' },\n    modal: { converter: trueByDefault },\n    closeBehavior: { attribute: 'close-behavior' },\n    persist: {},\n    state: { state: true },\n    backdropVisible: { state: true },\n    hasTitle: { state: true },\n    hasDescription: { state: true },\n    hasHeaderStart: { state: true },\n    hasFooter: { state: true },\n  }\n\n  open = false\n  heading?: string\n  description?: string\n  showCloseButton = true\n  hideHeader = false\n  scrollContent = false\n  modal = true\n  closeBehavior: DialogCloseBehavior = 'auto'\n  persist?: string\n  private state: 'open' | 'closed' = 'closed'\n  private backdropVisible = false\n  private hasTitle = false\n  private hasDescription = false\n  private hasHeaderStart = false\n  private hasFooter = false\n  private readonly autoKey = `uipkge-dialog-${++uid}`\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'dialog')\n    // Hydrate a persisted open state.\n    const key = this.storageKey\n    if (key && !isServer) {\n      try {\n        if (localStorage.getItem(key) === '1' && !this.open) this.setOpen(true)\n      } catch {\n        /* storage unavailable */\n      }\n    }\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.removeDocListeners()\n  }\n\n  private get storageKey() {\n    if (this.persist === undefined || this.persist === null) return null\n    return this.persist || this.autoKey\n  }\n\n  private get trigger(): HTMLElement | undefined {\n    const slot = this.renderRoot?.querySelector<HTMLSlotElement>('slot[name=\"trigger\"]')\n    return slot?.assignedElements({ flatten: true })[0] as HTMLElement | undefined\n  }\n\n  private get dialog() {\n    return this.renderRoot.querySelector('dialog')\n  }\n\n  /** The content box (what counts as \"inside\" for outside-click dismissal). */\n  private get contentBox(): Element | null {\n    return this.renderRoot.querySelector('[part=\"content\"]')\n  }\n\n  show() {\n    this.setOpen(true)\n  }\n\n  close() {\n    this.setOpen(false)\n  }\n\n  private setOpen(open: boolean) {\n    if (this.open === open) return\n    this.open = open\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open }, bubbles: true, composed: true }))\n  }\n\n  // --- dismissal (closeBehavior, like <uip-popover>) --------------------------\n  private allows(kind: 'outside' | 'esc') {\n    const b = this.closeBehavior\n    if (b === 'manual' || b === 'none') return false\n    if (kind === 'outside') return b !== 'esc'\n    return b !== 'click-outside'\n  }\n\n  /**\n   * Escape or a backdrop click requests dismissal: fire cancelable `cancel`,\n   * then (unless closeBehavior blocks it) cancelable `close`. Either can be\n   * prevented; the close button / data-dialog-close / close() bypass this.\n   */\n  private requestDismiss(reason: 'escape' | 'backdrop') {\n    if (!this.open) return\n    const cancelEv = new CustomEvent('cancel', {\n      detail: { reason },\n      bubbles: true,\n      composed: true,\n      cancelable: true,\n    })\n    if (!this.dispatchEvent(cancelEv)) return\n    if (!this.allows(reason === 'escape' ? 'esc' : 'outside')) return\n    const closeEv = new CustomEvent('close', {\n      detail: { reason },\n      bubbles: true,\n      composed: true,\n      cancelable: true,\n    })\n    if (!this.dispatchEvent(closeEv)) return\n    this.setOpen(false)\n  }\n\n  // Non-modal only: the browser fires no `cancel` and ::backdrop doesn't exist,\n  // so watch Escape + outside pointerdown on the document (like the popover).\n  private onDocKeyDown = (e: KeyboardEvent) => {\n    if (e.key !== 'Escape' || !this.open || this.modal) return\n    e.stopPropagation()\n    this.requestDismiss('escape')\n  }\n\n  private onDocPointerDown = (e: PointerEvent) => {\n    if (!this.open || this.modal) return\n    const path = e.composedPath()\n    if (this.contentBox && path.includes(this.contentBox)) return\n    const t = this.trigger\n    if (t && path.includes(t)) return\n    this.requestDismiss('backdrop')\n  }\n\n  private addDocListeners() {\n    addEventListener('pointerdown', this.onDocPointerDown, true)\n    addEventListener('keydown', this.onDocKeyDown, true)\n  }\n\n  private removeDocListeners() {\n    removeEventListener('pointerdown', this.onDocPointerDown, true)\n    removeEventListener('keydown', this.onDocKeyDown, true)\n  }\n\n  private savePersisted() {\n    const key = this.storageKey\n    if (!key) return\n    try {\n      if (this.open) localStorage.setItem(key, '1')\n      else localStorage.removeItem(key)\n    } catch {\n      /* storage unavailable */\n    }\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('open')) {\n      this.state = this.open ? 'open' : 'closed'\n      // Mount the non-modal backdrop with the enter animation; it unmounts in\n      // done() below so the exit animation plays first.\n      if (this.open) this.backdropVisible = true\n    }\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (isServer) return\n    // DialogTrigger: same attributes React/Radix put on the trigger (see popover.ts).\n    const trigger = this.trigger\n    if (trigger) {\n      trigger.setAttribute('aria-haspopup', 'dialog')\n      trigger.setAttribute('aria-expanded', String(this.open))\n      trigger.setAttribute('data-state', this.state)\n    }\n    if (!changed.has('open') && !changed.has('modal')) return\n    this.savePersisted()\n    if (!this.modal && this.open) this.addDocListeners()\n    else this.removeDocListeners()\n    const dialog = this.dialog\n    if (!dialog) return\n    if (this.open && !dialog.open) {\n      if (this.modal) dialog.showModal()\n      else dialog.show()\n      // showModal() focuses the first focusable (often a footer button) and\n      // scrolls it into view; Radix focuses with preventScroll, so a long\n      // dialog opens at its top.\n      dialog.scrollTop = 0\n    } else if (this.open && dialog.open && changed.has('modal')) {\n      // A dialog can't switch modes while open — reopen it in the new mode.\n      // (The intermediate close()'s native `close` arrives async, after the\n      // dialog is open again; onClose ignores it by checking dialog.open.)\n      dialog.close()\n      if (this.modal) dialog.showModal()\n      else dialog.show()\n      this.backdropVisible = !this.modal\n    } else if (!this.open && dialog.open) {\n      // Play the exit animation (data-state=closed), then actually close. The\n      // timeout covers tabs where animations never finish (hidden/background).\n      let closed = false\n      const done = () => {\n        if (closed || this.open) return\n        closed = true\n        dialog.close()\n        this.backdropVisible = false\n      }\n      setTimeout(done, 400)\n      const anims = dialog.getAnimations({ subtree: true })\n      if (anims.length) Promise.all(anims.map((a) => a.finished)).then(done, done)\n      else done()\n    }\n  }\n\n  // Escape on a modal dialog: the browser fires `cancel`; prevent the native\n  // instant close and route it through the dismiss flow (cancel/close events +\n  // closeBehavior). (Non-modal dialogs get no native cancel; onDocKeyDown covers them.)\n  private onCancel(e: Event) {\n    e.preventDefault()\n    this.requestDismiss('escape')\n  }\n\n  // Native `close` arrives async, so it can't tell the exit path's close()\n  // from the mode-switch branch's intermediate close() by timing — check the\n  // dialog instead: only sync `open` off when it really is closed.\n  private onClose() {\n    if (!this.dialog?.open) this.close()\n  }\n\n  // A modal click on the ::backdrop lands on the <dialog> itself (with\n  // scroll-content, on the overlay around the box). Non-modal clicks on the\n  // dialog box are content clicks — outside dismissal is onDocPointerDown.\n  // DialogClose elements are marked with data-dialog-close.\n  private onDialogClick(e: MouseEvent) {\n    if (e.target === this.dialog && this.modal) return this.requestDismiss('backdrop')\n    const closer = e.composedPath().find((n) => n instanceof Element && n.hasAttribute('data-dialog-close'))\n    if (closer) this.close()\n  }\n\n  private slotText(name: string) {\n    const slot = this.renderRoot.querySelector<HTMLSlotElement>(`slot[name=\"${name}\"]`)\n    return slot?.assignedNodes({ flatten: true }).map((n) => n.textContent ?? '').join(' ').trim() || undefined\n  }\n\n  private hasSlotted(e: Event) {\n    return (e.target as HTMLSlotElement).assignedNodes().some((n) => n.nodeType === 1 || n.textContent?.trim())\n  }\n\n  render() {\n    const slottedTitle = this.hasTitle ? this.slotText('title') : undefined\n    const hasHeader = !!(this.heading || this.description || this.hasTitle || this.hasDescription || this.hasHeaderStart)\n    const hasDescription = !!(this.description || this.hasDescription)\n    const body = html`\n      <div\n        part=\"header\"\n        data-slot=\"dialog-header\"\n        class=${cn('flex flex-col gap-2 pr-8 text-left', this.hideHeader && 'sr-only')}\n        ?hidden=${!hasHeader}\n      >\n        <slot name=\"header-start\" @slotchange=${(e: Event) => (this.hasHeaderStart = this.hasSlotted(e))}></slot>\n        <h2 part=\"title\" id=\"uip-dialog-title\" data-slot=\"dialog-title\" class=\"text-lg leading-none font-semibold\">\n          <slot name=\"title\" @slotchange=${(e: Event) => (this.hasTitle = this.hasSlotted(e))}>${this.heading}</slot>\n        </h2>\n        <p\n          part=\"description\"\n          id=\"uip-dialog-description\"\n          data-slot=\"dialog-description\"\n          class=\"text-muted-foreground text-sm\"\n          ?hidden=${!hasDescription}\n        >\n          <slot name=\"description\" @slotchange=${(e: Event) => (this.hasDescription = this.hasSlotted(e))}\n            >${this.description}</slot\n          >\n        </p>\n      </div>\n      <slot></slot>\n      <div\n        part=\"footer\"\n        data-slot=\"dialog-footer\"\n        class=\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\"\n        ?hidden=${!this.hasFooter}\n      >\n        <slot name=\"footer\" @slotchange=${(e: Event) => (this.hasFooter = this.hasSlotted(e))}></slot>\n      </div>\n      ${this.showCloseButton || this.scrollContent\n        ? html`<button\n            type=\"button\"\n            part=\"close\"\n            data-slot=\"dialog-close\"\n            class=${this.scrollContent ? scrollCloseClasses : closeClasses}\n            @click=${() => this.close()}\n          >\n            ${icon(X, 'x', this.scrollContent ? 'size-4' : '')}\n            <span class=\"sr-only\">Close</span>\n          </button>`\n        : nothing}\n    `\n    const labelling = {\n      labelledby: this.heading ? 'uip-dialog-title' : undefined,\n      label: !this.heading && slottedTitle ? slottedTitle : undefined,\n      describedby: hasDescription ? 'uip-dialog-description' : undefined,\n    }\n    return html`\n      <slot name=\"trigger\" @click=${() => this.show()} @slotchange=${() => this.requestUpdate()}></slot>\n      ${!this.modal && !this.scrollContent && this.backdropVisible\n        ? html`<div\n            part=\"backdrop\"\n            data-uipkge=\"\"\n            data-slot=\"dialog-overlay\"\n            data-state=${this.state}\n            aria-hidden=\"true\"\n            class=${cn(backdropClasses, 'pointer-events-none')}\n          ></div>`\n        : nothing}\n      ${this.scrollContent\n        ? html`<dialog\n            part=\"overlay\"\n            data-uipkge=\"\"\n            data-slot=\"dialog-overlay\"\n            data-state=${this.state}\n            aria-labelledby=${labelling.labelledby ?? nothing}\n            aria-label=${labelling.label ?? nothing}\n            aria-describedby=${labelling.describedby ?? nothing}\n            class=${cn(scrollOverlayClasses, !this.modal && 'pointer-events-none')}\n            @cancel=${this.onCancel}\n            @close=${this.onClose}\n            @click=${this.onDialogClick}\n          >\n            <div\n              part=\"content\"\n              data-uipkge=\"\"\n              data-slot=\"dialog-content\"\n              data-state=${this.state}\n              class=${cn(scrollContentClasses, !this.modal && 'pointer-events-auto')}\n            >\n              ${body}\n            </div>\n          </dialog>`\n        : html`<dialog\n            part=\"content\"\n            data-uipkge=\"\"\n            data-slot=\"dialog-content\"\n            data-state=${this.state}\n            aria-labelledby=${labelling.labelledby ?? nothing}\n            aria-label=${labelling.label ?? nothing}\n            aria-describedby=${labelling.describedby ?? nothing}\n            class=${cn(contentClasses, 'text-foreground m-0 backdrop:bg-foreground/50')}\n            @cancel=${this.onCancel}\n            @close=${this.onClose}\n            @click=${this.onDialogClick}\n          >\n            ${body}\n          </dialog>`}\n    `\n  }\n}\n\n/**\n * <uip-dialog-body> — DialogBody: the scrollable content between the header\n * and the footer (put it in <uip-dialog>'s default slot). It scrolls at\n * `max-h-[60vh]` and bleeds to the dialog edges so the scrollbar sits at the\n * border, not inside the padding. Style it with `class=\"[&::part(base)]:…\"`.\n */\nexport class UipDialogBody extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'dialog-body')\n  }\n\n  render() {\n    return html`<div part=\"base\" data-slot=\"dialog-body\" class=\"-mx-4 max-h-[60vh] overflow-y-auto px-4\">\n      <slot></slot>\n    </div>`\n  }\n}\n\ncustomElements.get('uip-dialog') || customElements.define('uip-dialog', UipDialog)\ncustomElements.get('uip-dialog-body') || customElements.define('uip-dialog-body', UipDialogBody)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-dialog': UipDialog\n    'uip-dialog-body': UipDialogBody\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/dialog/dialog.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
