import { createEffect, createSignal, createUniqueId, Show, type Accessor, type JSX, } from 'solid-js'; import { Portal } from 'solid-js/web'; import { cn } from '../utils/cn'; import { useChatConfig } from '../primitives/chat-config'; import { createPresence } from './overlay'; /** Imperative open controller, handed to a parent (e.g. the kai-dialog facade) * via `controllerRef` so it can drive/observe open state with `wireDisclosure`. */ export interface DialogController { open: Accessor; setOpen: (v: boolean) => void; } export interface DialogProps { /** Dialog body — the default slot / main content. */ children?: JSX.Element; /** Optional header region (e.g. a title). Rendered above the body with a divider. */ header?: JSX.Element; /** Optional footer region (e.g. action buttons). Rendered below the body with a divider. */ footer?: JSX.Element; /** Controlled open state. When set, the component never changes it itself — * drive it from `onOpenChange`. Omit for uncontrolled (internal) state. */ open?: boolean; /** Initial open state when uncontrolled. */ defaultOpen?: boolean; /** Fires whenever the dialog wants to open or close (Escape / backdrop / method). */ onOpenChange?: (open: boolean) => void; /** Receive the open controller (open accessor + setOpen) once mounted. */ controllerRef?: (api: DialogController) => void; /** Receive the focusable panel node so a facade's `focus()` can target it. */ panelRef?: (el: HTMLElement) => void; /** Accessible name for the dialog when no `header` is provided. */ 'aria-label'?: string; /** Extra class applied to the panel (e.g. a wider `max-w-*`). */ class?: string; } /** Focusable-element selector for the Tab focus trap. */ const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'audio[controls]', 'video[controls]', '[contenteditable]:not([contenteditable="false"])', '[tabindex]:not([tabindex="-1"])', ].join(','); /** Roughly "rendered + not visibility:hidden" — skips display:none / hidden nodes. */ function isVisible(el: HTMLElement): boolean { return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) && getComputedStyle(el).visibility !== 'hidden'; } /** * Tabbable descendants of `panel`, in tab order. Crosses `` boundaries: * when used as a web-component facade the panel's real content is light-DOM nodes * assigned to its slots, not panel descendants, so a plain querySelectorAll would * miss them. We walk the tree and expand each slot to its assigned elements. */ function getTabbables(panel: HTMLElement): HTMLElement[] { const out: HTMLElement[] = []; const walk = (node: Element) => { for (const child of Array.from(node.children)) { if (child instanceof HTMLSlotElement) { for (const assigned of child.assignedElements()) { if (assigned instanceof HTMLElement) { if (assigned.matches(FOCUSABLE_SELECTOR)) out.push(assigned); walk(assigned); } } } else if (child instanceof HTMLElement) { if (child.matches(FOCUSABLE_SELECTOR)) out.push(child); walk(child); } } }; walk(panel); return out.filter(isVisible); } /** The deepest active element, drilling through nested shadow roots (so a focused * node inside a shadow tree resolves to the real element, not its host). */ function deepActiveElement(): HTMLElement | null { let el = document.activeElement as HTMLElement | null; while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement as HTMLElement; return el; } /** * Dialog is the presentational centered modal surface. It renders through a Portal * (so it escapes any clipping/stacking ancestor), dims the page with a backdrop, * and centers a panel with a sensible max width/height and internal scroll. It * closes on Escape and on a backdrop click (never on a panel click), moves focus * into the panel on open and restores it on close, and runs a basic Tab focus * trap so keyboard focus cycles within the panel while open. The developer owns * when it opens (drive `open` / `defaultOpen`); this owns being the modal. * * Styleable parts: `backdrop` · `panel` · `header` · `body` · `footer`. */ export function Dialog(props: DialogProps) { const config = useChatConfig(); const [internalOpen, setInternalOpen] = createSignal(props.defaultOpen ?? false); const headerId = `kai-dialog-title-${createUniqueId()}`; const isControlled = () => props.open !== undefined; const isOpen = () => (isControlled() ? !!props.open : internalOpen()); const setOpen = (v: boolean) => { if (!isControlled()) setInternalOpen(v); props.onOpenChange?.(v); }; // Hand the open controller up to a parent (e.g. the kai-dialog facade) so it can // drive/observe open state via wireDisclosure. props.controllerRef?.({ open: isOpen, setOpen }); const presence = createPresence(isOpen); let backdrop: HTMLElement | undefined; let panel: HTMLElement | undefined; // The element that had focus before we opened, restored on close. let restoreFocus: HTMLElement | null = null; // Track where a click started so a drag that ends on the backdrop (e.g. a text // selection begun inside the panel) does not falsely dismiss. let pointerDownOnBackdrop = false; // Move focus into the panel on open; restore it on close. Seeded prev=false so a // `defaultOpen` (open-at-mount) still runs the open branch. createEffect((wasOpen: boolean) => { const open = isOpen(); if (open && !wasOpen) { restoreFocus = deepActiveElement(); queueMicrotask(() => panel?.focus()); } else if (!open && wasOpen) { const target = restoreFocus; restoreFocus = null; if (target && target.isConnected) queueMicrotask(() => target.focus()); } return open; }, false); const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); setOpen(false); return; } if (e.key !== 'Tab' || !panel) return; // Basic focus trap: keep Tab / Shift+Tab cycling within the panel. const items = getTabbables(panel); if (!items.length) { e.preventDefault(); panel.focus(); return; } const active = deepActiveElement(); const idx = active ? items.indexOf(active) : -1; if (e.shiftKey) { if (idx <= 0) { e.preventDefault(); items[items.length - 1].focus(); } } else if (idx === -1 || idx === items.length - 1) { e.preventDefault(); items[0].focus(); } }; const onClick = (e: MouseEvent) => { if (pointerDownOnBackdrop && e.target === backdrop) setOpen(false); pointerDownOnBackdrop = false; }; return (
{ backdrop = el; presence.setRef(el); }} part="backdrop" data-expanded={presence.state() === 'open' ? '' : undefined} data-closed={presence.state() === 'closed' ? '' : undefined} onPointerDown={(e) => { pointerDownOnBackdrop = e.target === e.currentTarget; }} onClick={onClick} onKeyDown={onKeyDown} class={cn( 'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4', 'animate-in fade-in-0 data-[closed]:animate-out data-[closed]:fade-out-0 motion-reduce:animate-none', )} >
{ panel = el; props.panelRef?.(el); }} part="panel" role="dialog" aria-modal="true" aria-labelledby={props.header ? headerId : undefined} aria-label={props.header ? undefined : props['aria-label']} tabindex={-1} data-expanded={presence.state() === 'open' ? '' : undefined} data-closed={presence.state() === 'closed' ? '' : undefined} class={cn( 'flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-xl outline-none', 'animate-in fade-in-0 zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95 motion-reduce:animate-none', props.class, )} >
{props.header}
{props.children}
); }