{"version":3,"sources":["../src/dialog.tsx"],"sourcesContent":["// In-app dialog primitives (interaction_standards R-IX-1; the dialog half of the\n// standards' delivery vehicle, R3-613).\n//\n// The APG dialog contract as imports: Escape dismisses the TOPMOST open dialog,\n// focus moves into the dialog on open and back to the invoker on close, and Tab\n// wraps inside it. Generalized from the host's own pair (site-main\n// `useHostDialogDismiss`/`useHostDialogFocus`, R3-592) — the same decisions,\n// renamed for an app context; site-main holds the two to identical behaviour\n// with a parity test, so \"generalized\" is a fact and an app can swap between\n// the primitives without re-litigating the contract.\n//\n// TRUST BOUNDARY (same rule as `./loading`): these render INSIDE the app's\n// iframe, under the app's principal — presentational only, NO capability, NO\n// host round-trip, no reserved landmark or host wordmark. Ordinary app a11y,\n// never trusted-chrome framing; adds nothing to a grant set.\n//\n// The dismiss listener is a capture-phase listener on the app's OWN document,\n// installed while the stack is non-empty. Capture is what makes the contract\n// hold on both sides: a keystroke the app dialog claims (Escape → stopPropagation)\n// never reaches an app-level bubble handler behind the dialog — and the host's\n// top-document listener lives in another document entirely, so neither side's\n// dismissal can suppress the other's, with no protocol between them.\nimport { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';\n\n/** The focusable-selector constant. Deliberately NOT exported from `index.ts`:\n *  it is an implementation detail of the trap, not author surface. Includes\n *  `iframe` because a dialog whose body hosts a frame must let Tab reach it. */\nconst FOCUSABLE = 'button:not([disabled]), [href], input, select, textarea, iframe, [tabindex]:not([tabindex=\"-1\"])';\n\ntype Entry = { onDismiss: () => void };\n\n// A module-level stack is the whole registry: the ONE capture-phase document\n// listener is installed when the first dialog mounts and removed when the last\n// unmounts. Reading `stack` fresh on each keypress makes removal-of-a-non-top-\n// entry safe.\nconst stack: Entry[] = [];\n\nconst onDocumentKeyDown = (e: KeyboardEvent): void => {\n  if (e.key !== 'Escape') return;\n  const top = stack[stack.length - 1];\n  if (!top) return;\n  e.stopPropagation();\n  top.onDismiss();\n};\n\n/**\n * Register `onDismiss` as this dialog's Escape handler. The topmost mounted\n * dialog wins: only its handler runs, and only on Escape.\n *\n * `onDismiss` is read through a ref so a new inline lambda on each render (the\n * near-universal shape of an `onClose` prop) does NOT re-register the dialog —\n * the entry is torn down and re-added only when `enabled` changes.\n */\nexport function useDialogDismiss(onDismiss: () => void, { enabled = true }: { enabled?: boolean } = {}): void {\n  const ref = useRef(onDismiss);\n  ref.current = onDismiss;\n\n  useEffect(() => {\n    if (!enabled) return;\n    const entry: Entry = { onDismiss: () => ref.current() };\n    stack.push(entry);\n    if (stack.length === 1) document.addEventListener('keydown', onDocumentKeyDown, true);\n    return () => {\n      // Unmounting removes THIS dialog's entry, which may not be the top — a\n      // dialog closed out of stack order must not leak its entry. Splice by\n      // identity, never by \"pop\".\n      const i = stack.indexOf(entry);\n      if (i !== -1) stack.splice(i, 1);\n      if (stack.length === 0) document.removeEventListener('keydown', onDocumentKeyDown, true);\n    };\n  }, [enabled]);\n}\n\n/**\n * Manage focus for one dialog rooted at `ref`: on open, record the invoking\n * element and focus the first focusable descendant (falling back to `ref`\n * itself — give the root `tabIndex={-1}` for that case); on close, return\n * focus to the invoker (guarded — it may itself have unmounted); Tab /\n * Shift-Tab wrap at the ends of the focusable list, read at keydown time\n * rather than cached on mount (dialog contents change).\n */\nexport function useDialogFocus(\n  ref: { current: HTMLElement | null },\n  { enabled = true }: { enabled?: boolean } = {},\n): void {\n  useEffect(() => {\n    const node = ref.current;\n    if (!enabled || !node) return;\n\n    // Record the invoker BEFORE stealing focus — the element that opened the dialog.\n    const invoker = document.activeElement as HTMLElement | null;\n    const first = node.querySelector<HTMLElement>(FOCUSABLE) ?? node;\n    first.focus();\n\n    const onKeyDown = (e: KeyboardEvent): void => {\n      if (e.key !== 'Tab') return;\n      const list = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));\n      if (list.length === 0) return;\n      const firstEl = list[0];\n      const lastEl = list[list.length - 1];\n      const active = document.activeElement;\n      if (e.shiftKey && active === firstEl) {\n        e.preventDefault();\n        lastEl.focus();\n      } else if (!e.shiftKey && active === lastEl) {\n        e.preventDefault();\n        firstEl.focus();\n      }\n    };\n    node.addEventListener('keydown', onKeyDown);\n\n    return () => {\n      node.removeEventListener('keydown', onKeyDown);\n      // The invoker may itself have unmounted by the time the dialog closes; the\n      // optional calls are why this stays safe (and why it can never throw on teardown).\n      invoker?.focus?.();\n    };\n  }, [enabled, ref]);\n}\n\n/** Props for {@link Dialog}. Styling is the app's — the primitive is structural. */\nexport interface DialogProps {\n  children: ReactNode;\n  /** Called on Escape (the topmost dialog wins). Click-outside/scrim handling is\n   *  the app's: the scrim is app chrome, and its click handler calls the same\n   *  `onDismiss`. */\n  onDismiss: () => void;\n  /** The accessible name — a dialog without one is unnamed to AT (4.1.2). */\n  'aria-label'?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/**\n * A dialog with the contract wired: `role=\"dialog\" aria-modal=\"true\"`, Escape\n * dismiss (topmost wins), focus in on open / back to the invoker on close, Tab\n * trapped inside. Render it when the dialog is open, with the invoker still\n * focused — the invoker is recorded at mount.\n */\nexport function Dialog({ children, onDismiss, className, style, ...aria }: DialogProps): ReactNode {\n  const ref = useRef<HTMLDivElement>(null);\n  useDialogDismiss(onDismiss);\n  useDialogFocus(ref);\n  return (\n    <div ref={ref} role=\"dialog\" aria-modal=\"true\" className={className} style={style} {...aria}>\n      {children}\n    </div>\n  );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAgJI;AA1HJ,mBAAsE;AAKtE,MAAM,YAAY;AAQlB,MAAM,QAAiB,CAAC;AAExB,MAAM,oBAAoB,CAAC,MAA2B;AACpD,MAAI,EAAE,QAAQ,SAAU;AACxB,QAAM,MAAM,MAAM,MAAM,SAAS,CAAC;AAClC,MAAI,CAAC,IAAK;AACV,IAAE,gBAAgB;AAClB,MAAI,UAAU;AAChB;AAUO,SAAS,iBAAiB,WAAuB,EAAE,UAAU,KAAK,IAA2B,CAAC,GAAS;AAC5G,QAAM,UAAM,qBAAO,SAAS;AAC5B,MAAI,UAAU;AAEd,8BAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,QAAe,EAAE,WAAW,MAAM,IAAI,QAAQ,EAAE;AACtD,UAAM,KAAK,KAAK;AAChB,QAAI,MAAM,WAAW,EAAG,UAAS,iBAAiB,WAAW,mBAAmB,IAAI;AACpF,WAAO,MAAM;AAIX,YAAM,IAAI,MAAM,QAAQ,KAAK;AAC7B,UAAI,MAAM,GAAI,OAAM,OAAO,GAAG,CAAC;AAC/B,UAAI,MAAM,WAAW,EAAG,UAAS,oBAAoB,WAAW,mBAAmB,IAAI;AAAA,IACzF;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AACd;AAUO,SAAS,eACd,KACA,EAAE,UAAU,KAAK,IAA2B,CAAC,GACvC;AACN,8BAAU,MAAM;AACd,UAAM,OAAO,IAAI;AACjB,QAAI,CAAC,WAAW,CAAC,KAAM;AAGvB,UAAM,UAAU,SAAS;AACzB,UAAM,QAAQ,KAAK,cAA2B,SAAS,KAAK;AAC5D,UAAM,MAAM;AAEZ,UAAM,YAAY,CAAC,MAA2B;AAC5C,UAAI,EAAE,QAAQ,MAAO;AACrB,YAAM,OAAO,MAAM,KAAK,KAAK,iBAA8B,SAAS,CAAC;AACrE,UAAI,KAAK,WAAW,EAAG;AACvB,YAAM,UAAU,KAAK,CAAC;AACtB,YAAM,SAAS,KAAK,KAAK,SAAS,CAAC;AACnC,YAAM,SAAS,SAAS;AACxB,UAAI,EAAE,YAAY,WAAW,SAAS;AACpC,UAAE,eAAe;AACjB,eAAO,MAAM;AAAA,MACf,WAAW,CAAC,EAAE,YAAY,WAAW,QAAQ;AAC3C,UAAE,eAAe;AACjB,gBAAQ,MAAM;AAAA,MAChB;AAAA,IACF;AACA,SAAK,iBAAiB,WAAW,SAAS;AAE1C,WAAO,MAAM;AACX,WAAK,oBAAoB,WAAW,SAAS;AAG7C,eAAS,QAAQ;AAAA,IACnB;AAAA,EACF,GAAG,CAAC,SAAS,GAAG,CAAC;AACnB;AAqBO,SAAS,OAAO,EAAE,UAAU,WAAW,WAAW,OAAO,GAAG,KAAK,GAA2B;AACjG,QAAM,UAAM,qBAAuB,IAAI;AACvC,mBAAiB,SAAS;AAC1B,iBAAe,GAAG;AAClB,SACE,4CAAC,SAAI,KAAU,MAAK,UAAS,cAAW,QAAO,WAAsB,OAAe,GAAG,MACpF,UACH;AAEJ;","names":[]}