{"name":"dialog","type":"registry:component","title":"Dialog","description":"A modal dialog component that overlays content on the primary window. Uses native HTML dialog element with smooth animations, focus management, and accessibility features. Supports ESC key and backdrop click to close.","categories":["ui","dialog","modal","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","dependencies":["lucide-static"],"files":[{"path":"registry/ui/dialog/dialog.ts","type":"registry:ui","content":"import { css, html, LitElement, nothing, type PropertyValues } from \"lit\";\nimport { customElement, property, query, state } from \"lit/decorators.js\";\nimport { unsafeSVG } from \"lit/directives/unsafe-svg.js\";\nimport { X } from \"lucide-static\";\nimport { TW } from \"@/registry/lib/tailwindMixin\";\nimport { cn } from \"@/registry/lib/utils\";\n\nconst TwLitElement = TW(LitElement);\n\n/**\n * Dialog component properties\n */\nexport interface DialogProperties {\n  open?: boolean;\n  modal?: boolean;\n}\n\nexport interface DialogTriggerProperties {\n  disabled?: boolean;\n}\n\nexport interface DialogContentProperties {\n  closeOnEscape?: boolean;\n  closeOnBackdrop?: boolean;\n}\n\nexport interface DialogCloseEvent {\n  reason: \"escape\" | \"backdrop\" | \"close-button\" | \"programmatic\";\n}\n\nexport interface DialogOpenChangeEvent extends CustomEvent {\n  detail: { open: boolean };\n}\n\n/**\n * Root dialog container managing state\n */\n@customElement(\"ui-dialog\")\nexport class Dialog extends TwLitElement implements DialogProperties {\n  @property({ type: Boolean, reflect: true }) open = false;\n  @property({ type: Boolean }) modal = true;\n\n  @query(\"ui-dialog-content\") contentElement?: DialogContent;\n\n  render() {\n    return html`\n      <style>\n        :host {\n          display: contents;\n        }\n      </style>\n      <slot name=\"trigger\"></slot>\n      <slot name=\"content\"></slot>\n    `;\n  }\n\n  override connectedCallback() {\n    super.connectedCallback();\n    this.addEventListener(\n      \"trigger-click\",\n      this.handleTriggerClick as EventListener,\n    );\n    this.addEventListener(\n      \"content-close\",\n      this.handleContentClose as EventListener,\n    );\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\n      \"trigger-click\",\n      this.handleTriggerClick as EventListener,\n    );\n    this.removeEventListener(\n      \"content-close\",\n      this.handleContentClose as EventListener,\n    );\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"open\")) {\n      // Dispatch dialog-open-change event\n      this.dispatchEvent(\n        new CustomEvent(\"dialog-open-change\", {\n          detail: { open: this.open },\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    }\n  }\n\n  private handleTriggerClick = () => {\n    this.open = !this.open;\n  };\n\n  private handleContentClose = (e: CustomEvent) => {\n    this.open = false;\n\n    this.dispatchEvent(\n      new CustomEvent(\"dialog-close\", {\n        detail: { reason: e.detail.reason },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n}\n\n/**\n * Dialog trigger button\n */\n@customElement(\"ui-dialog-trigger\")\nexport class DialogTrigger\n  extends TwLitElement\n  implements DialogTriggerProperties\n{\n  @property({ type: Boolean }) disabled = false;\n\n  render() {\n    return html`\n      <style>\n        :host {\n          display: contents;\n        }\n      </style>\n      <div @click=${this.handleClick}>\n        <slot></slot>\n      </div>\n    `;\n  }\n\n  private handleClick = () => {\n    if (!this.disabled) {\n      this.dispatchEvent(\n        new CustomEvent(\"trigger-click\", {\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    }\n  };\n}\n\n/**\n * Dialog content with native dialog element\n */\n@customElement(\"ui-dialog-content\")\nexport class DialogContent\n  extends TwLitElement\n  implements DialogContentProperties\n{\n  @property({ type: Boolean }) closeOnEscape = true;\n  @property({ type: Boolean }) closeOnBackdrop = true;\n\n  @query(\"dialog\") dialogElement!: HTMLDialogElement;\n  @state() private isOpen = false;\n  @state() private isAnimating = false;\n\n  private previouslyFocusedElement?: HTMLElement;\n  private observer?: MutationObserver;\n  private _pendingStateChange?: symbol;\n\n  render() {\n    // Only render when open or animating\n    if (!this.isOpen && !this.isAnimating) {\n      return nothing;\n    }\n\n    return html`\n      <dialog\n        class=${cn(\n          \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200\",\n          \"backdrop:bg-black/80\",\n          \"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n          \"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n          \"sm:rounded-lg\",\n          this.className,\n        )}\n        data-state=${this.isOpen ? \"open\" : \"closed\"}\n        @cancel=${this.handleCancel}\n        @click=${this.handleBackdropClick}\n      >\n        <div class=\"p-6\">\n          <slot></slot>\n        </div>\n        <ui-dialog-close></ui-dialog-close>\n      </dialog>\n    `;\n  }\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Sync with parent dialog state\n    const dialog = this.closest(\"ui-dialog\") as Dialog | null;\n    if (dialog) {\n      this.isOpen = dialog.open;\n\n      // Observe parent dialog for open attribute changes\n      this.observer = new MutationObserver(() => {\n        const shouldOpen = dialog.open;\n        if (shouldOpen !== this.isOpen) {\n          if (shouldOpen) {\n            this.showDialog(dialog.modal);\n          } else {\n            this.closeDialog();\n          }\n        }\n      });\n      this.observer.observe(dialog, {\n        attributes: true,\n        attributeFilter: [\"open\"],\n      });\n\n      // Show dialog if already open\n      if (dialog.open) {\n        // Wait for first render\n        this.updateComplete.then(() => {\n          this.showDialog(dialog.modal);\n        });\n      }\n    }\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.observer?.disconnect();\n    this.observer = undefined;\n  }\n\n  override firstUpdated() {\n    // Link title and description for accessibility\n    const title = this.querySelector(\"ui-dialog-title\");\n    const description = this.querySelector(\"ui-dialog-description\");\n\n    if (title) {\n      const titleId =\n        title.id ||\n        `dialog-title-${Math.random().toString(36).substring(2, 11)}`;\n      title.id = titleId;\n      this.dialogElement?.setAttribute(\"aria-labelledby\", titleId);\n    }\n\n    if (description) {\n      const descId =\n        description.id ||\n        `dialog-desc-${Math.random().toString(36).substring(2, 11)}`;\n      description.id = descId;\n      this.dialogElement?.setAttribute(\"aria-describedby\", descId);\n    }\n  }\n\n  private showDialog(modal: boolean) {\n    const stateId = Symbol();\n    this._pendingStateChange = stateId;\n\n    this.isOpen = true;\n    this.isAnimating = true;\n\n    // Wait for render\n    this.updateComplete.then(() => {\n      // Ignore stale calls\n      if (this._pendingStateChange !== stateId) return;\n      if (!this.dialogElement) return;\n\n      this.previouslyFocusedElement = document.activeElement as HTMLElement;\n\n      if (modal) {\n        this.dialogElement.showModal();\n      } else {\n        this.dialogElement.show();\n      }\n\n      // Use animationend event for opening animation too\n      const handleAnimationEnd = () => {\n        if (this._pendingStateChange !== stateId) return;\n        this.isAnimating = false;\n        this.dialogElement?.removeEventListener(\n          \"animationend\",\n          handleAnimationEnd,\n        );\n      };\n\n      this.dialogElement?.addEventListener(\"animationend\", handleAnimationEnd);\n\n      // Fallback timeout in case animation doesn't fire\n      setTimeout(() => {\n        if (this.isAnimating && this._pendingStateChange === stateId) {\n          handleAnimationEnd();\n        }\n      }, 300);\n    });\n  }\n\n  private closeDialog() {\n    const stateId = Symbol();\n    this._pendingStateChange = stateId;\n\n    this.isOpen = false;\n    this.isAnimating = true;\n\n    // Use animationend event for proper sync\n    const handleAnimationEnd = () => {\n      // Ignore stale calls\n      if (this._pendingStateChange !== stateId) return;\n\n      if (this.dialogElement?.open) {\n        this.dialogElement.close();\n      }\n      this.isAnimating = false;\n      this.previouslyFocusedElement?.focus();\n      this.dialogElement?.removeEventListener(\n        \"animationend\",\n        handleAnimationEnd,\n      );\n    };\n\n    this.dialogElement?.addEventListener(\"animationend\", handleAnimationEnd);\n\n    // Fallback timeout in case animation doesn't fire\n    setTimeout(() => {\n      if (this.isAnimating && this._pendingStateChange === stateId) {\n        handleAnimationEnd();\n      }\n    }, 300);\n  }\n\n  private handleCancel = (e: Event) => {\n    // Cancel event is fired when ESC is pressed\n    if (!this.closeOnEscape) {\n      e.preventDefault();\n      return;\n    }\n\n    this.dispatchEvent(\n      new CustomEvent(\"content-close\", {\n        detail: { reason: \"escape\" },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  private handleBackdropClick = (e: MouseEvent) => {\n    if (!this.closeOnBackdrop) return;\n\n    // Check if click is on backdrop (dialog element itself, not children)\n    const rect = this.dialogElement?.getBoundingClientRect();\n    if (!rect) return;\n\n    const clickedInDialog =\n      rect.top <= e.clientY &&\n      e.clientY <= rect.top + rect.height &&\n      rect.left <= e.clientX &&\n      e.clientX <= rect.left + rect.width;\n\n    // If click is outside the dialog bounds (on the backdrop)\n    if (!clickedInDialog || e.target === this.dialogElement) {\n      this.dispatchEvent(\n        new CustomEvent(\"content-close\", {\n          detail: { reason: \"backdrop\" },\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    }\n  };\n}\n\n/**\n * Dialog header container\n */\n@customElement(\"ui-dialog-header\")\nexport class DialogHeader extends TwLitElement {\n  render() {\n    return html`\n      <div\n        class=${cn(\n          \"flex flex-col space-y-1.5 text-center sm:text-left\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Dialog title (required for accessibility)\n */\n@customElement(\"ui-dialog-title\")\nexport class DialogTitle extends TwLitElement {\n  render() {\n    return html`\n      <h2\n        class=${cn(\n          \"text-lg font-semibold leading-none tracking-tight\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </h2>\n    `;\n  }\n}\n\n/**\n * Dialog description\n */\n@customElement(\"ui-dialog-description\")\nexport class DialogDescription extends TwLitElement {\n  render() {\n    return html`\n      <p class=${cn(\"text-sm text-muted-foreground\", this.className)}>\n        <slot></slot>\n      </p>\n    `;\n  }\n}\n\n/**\n * Dialog footer container\n */\n@customElement(\"ui-dialog-footer\")\nexport class DialogFooter extends TwLitElement {\n  render() {\n    return html`\n      <div\n        class=${cn(\n          \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n          this.className,\n        )}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Dialog close button\n */\n@customElement(\"ui-dialog-close\")\nexport class DialogClose extends TwLitElement {\n  static styles = css`\n    :host {\n      position: absolute;\n      right: 1rem;\n      top: 1rem;\n    }\n  `;\n\n  render() {\n    return html`\n      <button\n        type=\"button\"\n        class=${cn(\n          \"absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity\",\n          \"hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n          \"disabled:pointer-events-none\",\n          this.className,\n        )}\n        @click=${this.handleClick}\n        aria-label=\"Close\"\n      >\n        ${unsafeSVG(X)}\n        <span class=\"sr-only\">Close</span>\n      </button>\n    `;\n  }\n\n  private handleClick = () => {\n    this.dispatchEvent(\n      new CustomEvent(\"content-close\", {\n        detail: { reason: \"close-button\" },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n}\n\n// Register components in global type map\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-dialog\": Dialog;\n    \"ui-dialog-trigger\": DialogTrigger;\n    \"ui-dialog-content\": DialogContent;\n    \"ui-dialog-header\": DialogHeader;\n    \"ui-dialog-title\": DialogTitle;\n    \"ui-dialog-description\": DialogDescription;\n    \"ui-dialog-footer\": DialogFooter;\n    \"ui-dialog-close\": DialogClose;\n  }\n\n  interface HTMLElementEventMap {\n    \"dialog-open-change\": DialogOpenChangeEvent;\n    \"dialog-close\": CustomEvent<DialogCloseEvent>;\n  }\n}\n"}]}