{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "dialog",
  "title": "Dialog",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/dialog/dialog.component.ts",
      "content": "import {\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  OnInit,\n  Output,\n  TemplateRef,\n  ViewChild,\n  ViewContainerRef,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport { buttonVariants } from '@/ui/button/button.component'\nimport {\n  BodyPortal,\n  afterExitAnimation,\n  lockScroll,\n  pushDismissableLayer,\n  trapFocus,\n  uniqueId,\n} from '@/ui/popper/popper'\n\n/**\n * Cancelable event emitted by Content's dismiss / focus outputs. Mirrors Radix: call\n * `event.preventDefault()` to keep the dialog open (escapeKeyDown, pointerDownOutside,\n * interactOutside) or to skip the automatic focus move (openAutoFocus, closeAutoFocus).\n * `detail.originalEvent` is the DOM event that caused it.\n */\nexport type DialogDismissEvent = CustomEvent<{ originalEvent: Event }>\n\nconst dismissEvent = (type: string, originalEvent: Event): DialogDismissEvent =>\n  new CustomEvent(type, { cancelable: true, detail: { originalEvent } })\n\n/**\n * Angular port of UIPKGE Dialog with Radix Dialog behaviour (what the React registry\n * wraps): Content renders in a body portal over an Overlay, focus moves into the\n * dialog and is trapped while modal, page scroll locks, Escape / overlay clicks\n * dismiss (cancelable through the Radix-named outputs), focus returns to the trigger\n * on close, and Title / Description wire aria-labelledby / aria-describedby. Trigger\n * and Close are directives (the `asChild` equivalent -- put them on any button).\n * Tailwind class strings are identical to the React source.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog, [ui-dialog]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"dialog\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n    // Radix Root renders no element: keep the wrapper out of layout.\n    class: 'contents',\n  },\n  template: `<ng-content />`,\n})\nexport class UiDialogComponent implements OnChanges {\n  /** Controlled open state (pair with `openChange`). Leave unset for uncontrolled use. */\n  @Input() open?: boolean\n  @Input({ transform: booleanAttribute }) defaultOpen = false\n  @Input({ transform: booleanAttribute }) modal = true\n  @Output() openChange = new EventEmitter<boolean>()\n\n  readonly contentId = uniqueId('dialog-content')\n  /** Set by Title / Description so Content can wire aria-labelledby / aria-describedby. */\n  titleId: string | null = null\n  descriptionId: string | null = null\n  /** Element focus returns to on close (set by UiDialogTriggerComponent). */\n  trigger?: HTMLElement\n  content?: DialogContentBase\n  private readonly _open = signal<boolean | null>(null)\n\n  get isOpen(): boolean {\n    if (this.open !== undefined) return this.open\n    return this._open() ?? this.defaultOpen\n  }\n\n  ngOnChanges(): void {\n    this.content?.sync()\n  }\n\n  setOpen(value: boolean): void {\n    if (value === this.isOpen) return\n    this._open.set(value)\n    this.openChange.emit(value)\n    this.content?.sync()\n  }\n\n  toggle(): void {\n    this.setOpen(!this.isOpen)\n  }\n}\n\nexport const DIALOG_OVERLAY_CLASS =\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\nexport const DIALOG_CONTENT_CLASS =\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 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200 sm:max-w-lg'\n\nconst SCROLL_OVERLAY_CLASS =\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 grid place-items-center overflow-y-auto data-[state=closed]:duration-[var(--dur-exit)] data-[state=open]:duration-200'\n\nconst SCROLL_CONTENT_CLASS =\n  'bg-background relative z-50 my-8 grid w-full max-w-lg gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg md:w-full'\n\n/**\n * Standalone Overlay part (DialogContent renders its own overlay, as in React). Only\n * meaningful when you build a custom content layout; reflects the dialog's data-state.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog-overlay, [ui-dialog-overlay]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"dialog-overlay\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'dialog ? (dialog.isOpen ? \"open\" : \"closed\") : null',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiDialogOverlayComponent {\n  readonly dialog = inject(UiDialogComponent, { optional: true })\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn(DIALOG_OVERLAY_CLASS, this.className)\n  }\n}\n\nconst TABBABLE =\n  'button:not([disabled]),input:not([disabled]):not([type=\"hidden\"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"]),[contenteditable=\"true\"]'\n\n/** Open / close lifecycle shared by DialogContent and DialogScrollContent. */\n@Directive()\nexport abstract class DialogContentBase implements OnInit, OnChanges, OnDestroy {\n  readonly dialog = inject(UiDialogComponent)\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n\n  @Input('class') className?: string\n  /** ARIA role of the content element (AlertModal passes `alertdialog`). */\n  @Input() role: 'dialog' | 'alertdialog' = 'dialog'\n  @Output() openAutoFocus = new EventEmitter<DialogDismissEvent>()\n  @Output() closeAutoFocus = new EventEmitter<DialogDismissEvent>()\n  @Output() escapeKeyDown = new EventEmitter<DialogDismissEvent>()\n  @Output() pointerDownOutside = new EventEmitter<DialogDismissEvent>()\n  @Output() interactOutside = new EventEmitter<DialogDismissEvent>()\n\n  @ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>\n  readonly state = signal<'open' | 'closed'>('closed')\n  private panelEl?: HTMLElement\n  private previouslyFocused: HTMLElement | null = null\n  private interactedOutside = false\n  private cleanups: (() => void)[] = []\n\n  constructor() {\n    this.dialog.content = this\n  }\n\n  ngOnInit(): void {\n    // defaultOpen / open=true on first render: no input change will call sync().\n    if (this.dialog.isOpen) queueMicrotask(() => this.sync())\n  }\n\n  ngOnChanges(): void {\n    if (this.portal.attached) this.sync()\n  }\n\n  /** Mirrors root open state into the portal (called by the root on every change). */\n  sync(): void {\n    if (this.dialog.isOpen && (!this.portal.attached || this.state() === 'closed')) {\n      // Reopened mid exit-animation: drop the closing panel and start fresh.\n      if (this.portal.attached) this.portal.detach()\n      this.show()\n    } else if (!this.dialog.isOpen && this.portal.attached && this.state() === 'open') void this.hide()\n  }\n\n  /** Hook for ScrollContent: pointer-downs that should never count as \"outside\". */\n  protected ignorePointerDown(_event: PointerEvent): boolean {\n    return false\n  }\n\n  private show(): void {\n    this.previouslyFocused = document.activeElement as HTMLElement | null\n    this.interactedOutside = false\n    this.state.set('open')\n    const host = this.portal.attach(this.tpl)\n    const panel = host.querySelector<HTMLElement>('[data-slot=\"dialog-content\"]')!\n    this.panelEl = panel\n    this.cleanups.push(\n      pushDismissableLayer({\n        contains: (t) => panel.contains(t),\n        onEscape: (e) => {\n          const event = dismissEvent('escapeKeyDown', e)\n          this.escapeKeyDown.emit(event)\n          if (!event.defaultPrevented) this.dialog.setOpen(false)\n        },\n        onPointerDownOutside: (e) => {\n          if (this.ignorePointerDown(e)) return\n          // Non-modal: the trigger toggles on its own click; don't dismiss first.\n          if (!this.dialog.modal && this.dialog.trigger?.contains(e.target as Node)) return\n          const event = dismissEvent('pointerDownOutside', e)\n          this.pointerDownOutside.emit(event)\n          this.interactOutside.emit(event)\n          if (event.defaultPrevented) return\n          this.interactedOutside = true\n          this.dialog.setOpen(false)\n        },\n      }),\n    )\n    if (this.dialog.modal) this.cleanups.push(trapFocus(panel), lockScroll())\n    queueMicrotask(() => {\n      if (!panel.isConnected) return\n      const event = dismissEvent('openAutoFocus', new Event('focus'))\n      this.openAutoFocus.emit(event)\n      if (event.defaultPrevented) return\n      // Radix FocusScope: first tabbable (links skipped), else the content itself.\n      const first = panel.querySelector<HTMLElement>('[autofocus]') ?? panel.querySelector<HTMLElement>(TABBABLE)\n      ;(first ?? panel).focus({ preventScroll: true })\n      // Radix FocusScope selects the text of an auto-focused input.\n      if (first instanceof HTMLInputElement) first.select()\n    })\n  }\n\n  private async hide(): Promise<void> {\n    this.state.set('closed')\n    this.cleanups.splice(0).forEach((fn) => fn())\n    await afterExitAnimation(this.panelEl, 200)\n    if (this.dialog.isOpen) return\n    this.portal.detach()\n    this.panelEl = undefined\n    const event = dismissEvent('closeAutoFocus', new Event('blur'))\n    this.closeAutoFocus.emit(event)\n    if (event.defaultPrevented || (!this.dialog.modal && this.interactedOutside)) return\n    const target = this.dialog.trigger ?? this.previouslyFocused\n    if (target?.isConnected) target.focus({ preventScroll: true })\n  }\n\n  ngOnDestroy(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n    if (this.dialog.content === this) this.dialog.content = undefined\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog-content, [ui-dialog-content]',\n  standalone: true,\n  // The host stays where it is declared (no box); the dialog renders in a body portal.\n  host: { '[attr.hidden]': '\"\"' },\n  template: `\n    <ng-template #tpl>\n      @if (dialog.modal) {\n        <div data-uipkge=\"\" data-slot=\"dialog-overlay\" [attr.data-state]=\"state()\" [class]=\"overlayClass\"></div>\n      }\n      <div\n        [id]=\"dialog.contentId\"\n        [attr.role]=\"role\"\n        aria-modal=\"true\"\n        tabindex=\"-1\"\n        data-uipkge=\"\"\n        data-slot=\"dialog-content\"\n        [attr.data-state]=\"state()\"\n        [attr.aria-labelledby]=\"dialog.titleId\"\n        [attr.aria-describedby]=\"dialog.descriptionId\"\n        [class]=\"contentClass\"\n      >\n        <ng-content />\n        @if (showCloseButton) {\n          <button\n            type=\"button\"\n            data-uipkge=\"\"\n            data-slot=\"dialog-close\"\n            class=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n            (click)=\"dialog.setOpen(false)\"\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"24\"\n              height=\"24\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"lucide lucide-x\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M18 6 6 18\" />\n              <path d=\"m6 6 12 12\" />\n            </svg>\n            <span class=\"sr-only\">Close</span>\n          </button>\n        }\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiDialogContentComponent extends DialogContentBase {\n  @Input({ transform: booleanAttribute }) showCloseButton = true\n  readonly overlayClass = DIALOG_OVERLAY_CLASS\n\n  get contentClass(): string {\n    return cn(DIALOG_CONTENT_CLASS, this.className)\n  }\n}\n\n/** Content inside a scrollable overlay: very long bodies scroll the overlay, not the page. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog-scroll-content, [ui-dialog-scroll-content]',\n  standalone: true,\n  host: { '[attr.hidden]': '\"\"' },\n  template: `\n    <ng-template #tpl>\n      <div data-uipkge=\"\" data-slot=\"dialog-overlay\" [attr.data-state]=\"state()\" [class]=\"overlayClass\">\n        <div\n          [id]=\"dialog.contentId\"\n          [attr.role]=\"role\"\n          aria-modal=\"true\"\n          tabindex=\"-1\"\n          data-uipkge=\"\"\n          data-slot=\"dialog-content\"\n          [attr.data-state]=\"state()\"\n          [attr.aria-labelledby]=\"dialog.titleId\"\n          [attr.aria-describedby]=\"dialog.descriptionId\"\n          [class]=\"contentClass\"\n        >\n          <ng-content />\n          <button\n            type=\"button\"\n            data-uipkge=\"\"\n            data-slot=\"dialog-close\"\n            class=\"hover:bg-secondary absolute top-4 right-4 rounded-md p-0.5 transition-colors duration-200\"\n            (click)=\"dialog.setOpen(false)\"\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"24\"\n              height=\"24\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"lucide lucide-x h-4 w-4\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M18 6 6 18\" />\n              <path d=\"m6 6 12 12\" />\n            </svg>\n            <span class=\"sr-only\">Close</span>\n          </button>\n        </div>\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiDialogScrollContentComponent extends DialogContentBase {\n  readonly overlayClass = SCROLL_OVERLAY_CLASS\n\n  get contentClass(): string {\n    return cn(SCROLL_CONTENT_CLASS, this.className)\n  }\n\n  /** React: a pointer-down on the overlay's scrollbar must not dismiss. */\n  protected override ignorePointerDown(event: PointerEvent): boolean {\n    const target = event.target as HTMLElement | null\n    if (!target || typeof event.offsetX !== 'number') return false\n    return event.offsetX > target.clientWidth || event.offsetY > target.clientHeight\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog-header, [ui-dialog-header]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"dialog-header\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiDialogHeaderComponent {\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn('flex flex-col gap-2 text-center sm:text-left', this.className)\n  }\n}\n\n/** Dialog Close as a directive: put it on any element (Radix `DialogClose asChild`). */\n@Directive({\n  selector: 'ui-dialog-close, [ui-dialog-close]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"dialog-close\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.type]': 'isButton ? \"button\" : null',\n    '(click)': 'dialog.setOpen(false)',\n  },\n})\nexport class UiDialogCloseComponent {\n  readonly dialog = inject(UiDialogComponent)\n  readonly isButton = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement.tagName === 'BUTTON'\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog-footer, [ui-dialog-footer]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"dialog-footer\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[class]': 'hostClass',\n  },\n  template: `\n    <ng-content />\n    @if (showCloseButton) {\n      <button\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"button\"\n        data-variant=\"outline\"\n        data-size=\"default\"\n        [class]=\"closeClass\"\n        (click)=\"dialog?.setOpen(false)\"\n      >\n        Close\n      </button>\n    }\n  `,\n})\nexport class UiDialogFooterComponent {\n  readonly dialog = inject(UiDialogComponent, { optional: true })\n  @Input('class') className?: string\n  @Input({ transform: booleanAttribute }) showCloseButton = false\n  readonly closeClass = buttonVariants({ variant: 'outline' })\n\n  get hostClass(): string {\n    return cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', this.className)\n  }\n}\n\n/** Radix renders an <h2>; use `<h2 ui-dialog-title>` for the same element (custom tags get role=heading). */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog-title, [ui-dialog-title]',\n  standalone: true,\n  host: {\n    '[attr.id]': 'id',\n    '[attr.role]': 'custom ? \"heading\" : null',\n    '[attr.aria-level]': 'custom ? 2 : null',\n    '[attr.data-slot]': '\"dialog-title\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiDialogTitleComponent implements OnDestroy {\n  private readonly dialog = inject(UiDialogComponent, { optional: true })\n  readonly custom = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement.tagName.includes('-')\n  readonly id = uniqueId('dialog-title')\n  @Input('class') className?: string\n\n  constructor() {\n    if (this.dialog) this.dialog.titleId = this.id\n  }\n\n  get hostClass(): string {\n    return cn(this.custom && 'block', 'text-lg leading-none font-semibold', this.className)\n  }\n\n  ngOnDestroy(): void {\n    if (this.dialog?.titleId === this.id) this.dialog.titleId = null\n  }\n}\n\n/** Radix renders a <p>; use `<p ui-dialog-description>` for the same element. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-dialog-description, [ui-dialog-description]',\n  standalone: true,\n  host: {\n    '[attr.id]': 'id',\n    '[attr.data-slot]': '\"dialog-description\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiDialogDescriptionComponent implements OnDestroy {\n  private readonly dialog = inject(UiDialogComponent, { optional: true })\n  private readonly custom = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement.tagName.includes('-')\n  readonly id = uniqueId('dialog-description')\n  @Input('class') className?: string\n\n  constructor() {\n    if (this.dialog) this.dialog.descriptionId = this.id\n  }\n\n  get hostClass(): string {\n    return cn(this.custom && 'block', 'text-muted-foreground text-sm', this.className)\n  }\n\n  ngOnDestroy(): void {\n    if (this.dialog?.descriptionId === this.id) this.dialog.descriptionId = null\n  }\n}\n\n/** Dialog Trigger as a directive: put it on any button (Radix `DialogTrigger asChild`). */\n@Directive({\n  selector: 'ui-dialog-trigger, [ui-dialog-trigger]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': 'dataSlot',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.type]': 'isButton ? \"button\" : null',\n    '[attr.aria-haspopup]': '\"dialog\"',\n    '[attr.aria-expanded]': 'dialog.isOpen',\n    '[attr.aria-controls]': 'dialog.contentId',\n    '[attr.data-state]': 'dialog.isOpen ? \"open\" : \"closed\"',\n    '(click)': 'dialog.toggle()',\n  },\n})\nexport class UiDialogTriggerComponent implements OnDestroy {\n  // Radix Slot: the trigger's data-slot overrides a wrapped component's own (ui-button's\n  // \"button\"), but a data-slot written on the element itself wins.\n  readonly dataSlot: string = inject(ElementRef).nativeElement.getAttribute('data-slot') ?? 'dialog-trigger'\n  readonly dialog = inject(UiDialogComponent)\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  readonly isButton = this.el.tagName === 'BUTTON'\n\n  constructor() {\n    this.dialog.trigger = this.el\n  }\n\n  ngOnDestroy(): void {\n    if (this.dialog.trigger === this.el) this.dialog.trigger = undefined\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/dialog/dialog.component.ts"
    },
    {
      "path": "packages/registry-angular/components/dialog/index.ts",
      "content": "export {\n  UiDialogComponent,\n  UiDialogOverlayComponent,\n  UiDialogContentComponent,\n  UiDialogScrollContentComponent,\n  UiDialogHeaderComponent,\n  UiDialogFooterComponent,\n  UiDialogTitleComponent,\n  UiDialogDescriptionComponent,\n  UiDialogTriggerComponent,\n  UiDialogCloseComponent,\n  DialogContentBase,\n  DIALOG_OVERLAY_CLASS,\n  DIALOG_CONTENT_CLASS,\n  type DialogDismissEvent,\n} from './dialog.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/dialog/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json",
    "https://uipkge.dev/r/angular/button.json"
  ],
  "description": "Free-form modal primitive — composable from `UiDialogComponent`, trigger, content, and friends. Use for forms, info cards, pickers, and any custom modal layout. Radix Dialog behaviour: body portal + overlay, focus trap and restore, scroll lock, Escape / overlay dismiss, aria-labelledby / aria-describedby wiring.",
  "categories": [
    "overlay"
  ]
}