import type { JSX } from "@solidjs/web"; /** * `useDialogRootInternal` — shared root-body helper consumed by * `` and ``. Mirrors Base UI React's * `useDialogRoot` hook (`packages/react/src/dialog/root/useDialogRoot.ts`) * in spirit: collects the open-state + floating-context + dismiss + role * wiring into one place so the two roots differ only in three knobs. * * Composition: * * - The wrapper builds a headless primitive (`createDialog` / * `createAlertDialog`) and threads its `DialogApi`-shaped surface in * via `options.api`. `useDialogRootInternal` delegates `open`, * `modal`, and `disablePointerDismissal` reads to the primitive * instead of declaring its own. AlertDialog's primitive pins * `modal: true` and `disablePointerDismissal: true` as fixed * invariants, so the previous `modalOverride` / `disablePointerDismissalOverride` * knobs are no longer needed. * * Knobs: * * - `role`: `"dialog"` or `"alertdialog"`. Threaded into `useRole` + * surfaced on the context so `` sets the correct DOM * `role` attribute. * - `api`: the primitive's reactive accessors for `open`, `modal`, and * `disablePointerDismissal`. */ import { type Accessor, createEffect, createMemo, createSignal, onCleanup, untrack, useContext, } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import type { InteractionType } from "../../internals/floating-ui/components/floating-focus-manager"; import type { FloatingRootStore } from "../../internals/floating-ui/components/floating-root-store"; import { useDismiss } from "../../internals/floating-ui/hooks/use-dismiss"; import { useFloatingRootContext } from "../../internals/floating-ui/hooks/use-floating-root-context"; import { type UseInteractionsReturn, useInteractions, } from "../../internals/floating-ui/hooks/use-interactions"; import { useRole } from "../../internals/floating-ui/hooks/use-role"; import type { MutableRef } from "../../internals/floating-ui/types"; import { useOpenChangeComplete } from "../../internals/popups/use-open-change-complete"; import { useOpenInteractionType } from "../../internals/popups/use-open-interaction-type"; import { useScrollLock } from "../../internals/popups/use-scroll-lock"; import { useTransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { type DialogModalKind, type DialogRootChangeEventDetails, type DialogRootChangeEventReason, DialogRootContext, type DialogRootContextValue, IsDrawerContext, useDialogRootContext, } from "../dialog-root-context"; import { type DialogHandle, DialogStore } from "../store/dialog-store"; export interface DialogRootInternalActions { close: () => void; unmount: () => void; } export type DialogPayloadChildRenderFunction = (ctx: { payload: Payload | undefined; }) => JSX.Element; export interface DialogRootInternalProps { onOpenChange?: | ((open: boolean, eventDetails: DialogRootChangeEventDetails) => void) | undefined; onOpenChangeComplete?: ((open: boolean) => void) | undefined; actionsRef?: | ((actions: DialogRootInternalActions | null) => void) | { current: DialogRootInternalActions | null } | undefined; handle?: DialogHandle | undefined; triggerId?: string | null | undefined; children?: JSX.Element | DialogPayloadChildRenderFunction; } /** * Subset of the headless primitive surface that `useDialogRootInternal` * consumes. Both `DialogApi` and `AlertDialogApi` are structurally * assignable. */ export interface DialogRootInternalApi { open: Accessor; modal: Accessor; disablePointerDismissal: Accessor; } export interface UseDialogRootInternalOptions { /** `"dialog"` or `"alertdialog"`. */ role: "dialog" | "alertdialog"; /** * Headless primitive instance — owns `open`, `modal`, and * `disablePointerDismissal`. The wrapper builds it from props (and any * compound-specific invariants — e.g. AlertDialog pins `modal: true`, * `disablePointerDismissal: true`). */ api: DialogRootInternalApi; /** * Writer for the uncontrolled half of the wrapper's * `createControlledState` pair. Called by `setOpen` after the * `onOpenChange` cancel gate — a no-op for reads while the `open` prop * is controlled (the controlled value wins). */ setUncontrolledOpen?: (next: boolean) => void; } /** * Shared `` / `` body. Returns the JSX * element (a `` + `` provider pair * wrapping the resolved children). */ export function useDialogRootInternal( props: DialogRootInternalProps, options: UseDialogRootInternalOptions, ): JSX.Element { const parentDialogContext = useDialogRootContext(true); const isDrawer = useContext(IsDrawerContext); const nested = parentDialogContext != null; // Delegate `modal` + `disablePointerDismissal` to the headless // primitive. AlertDialog's primitive pins both as fixed invariants; // Dialog's primitive resolves them from its props. const modal = options.api.modal; const disablePointerDismissal = options.api.disablePointerDismissal; // Adopt the handle's store if supplied; otherwise own one. const store = (props.handle?.store ?? new DialogStore()) as | DialogStore | DialogStore; const [preventUnmountingOnClose, setPreventUnmountingOnClose] = createSignal( false, { ownedWrite: true }, ); // `open` is owned by the headless primitive (which threads the // wrapper's controlled/uncontrolled resolution in). `setOpenInternal` // mirrors Base UI's `DialogStore.setOpen`: fire `onOpenChange`, honor // `details.cancel()`, then write the uncontrolled state (ignored for // reads while the `open` prop is controlled). const open = options.api.open; const setOpenInternal = ( next: boolean, details: DialogRootChangeEventDetails, ): void => { props.onOpenChange?.(next, details); if (details.isCanceled) return; options.setUncontrolledOpen?.(next); }; const { mounted, transitionStatus, setMounted } = useTransitionStatus( open, () => true, () => false, ); const popupRef: MutableRef = { current: null }; const backdropRef: MutableRef = { current: null }; const internalBackdropRef: MutableRef = { current: null }; const outsidePressEnabledRef: MutableRef = { current: true }; useOpenChangeComplete({ open, ref: popupRef, onComplete: () => { if (!open() && !preventUnmountingOnClose()) { setMounted(false); } props.onOpenChangeComplete?.(open()); }, }); const [titleId, setTitleId] = createSignal(undefined, { ownedWrite: true, }); const [descriptionId, setDescriptionId] = createSignal( undefined, { ownedWrite: true }, ); const [activeTriggerIdInternal, setActiveTriggerIdInternal] = createSignal< string | null >(null, { ownedWrite: true }); const activeTriggerId = createMemo(() => { const controlled = props.triggerId; if (controlled !== undefined) return controlled; return activeTriggerIdInternal(); }); const setActiveTriggerId = (id: string | null): void => { setActiveTriggerIdInternal(id); }; const [triggerElement, setTriggerElementInternal] = createSignal(null, { ownedWrite: true }); const setTriggerElement = (el: HTMLElement | null): void => { setTriggerElementInternal(el); }; createEffect( () => activeTriggerId(), (id) => { if (id === null) return; const el = store.context.triggerElements.getById(id) as | HTMLElement | null | undefined; if (el) setTriggerElementInternal(el); }, ); const [popupElement, setPopupElement] = createSignal( null, { ownedWrite: true }, ); const [viewportElement, setViewportElement] = createSignal(null, { ownedWrite: true }); const [payload, setPayload] = createSignal(undefined, { ownedWrite: true, }); store.attachSetPayload((p) => { setPayload(() => p as Payload | undefined); }); onCleanup(() => store.attachSetPayload(null)); const { openMethod } = useOpenInteractionType(open); const [ownNestedOpenDialogs, setOwnNestedOpenDialogs] = createSignal(0, { ownedWrite: true, }); const [ownNestedOpenDrawers, setOwnNestedOpenDrawers] = createSignal(0, { ownedWrite: true, }); const isTopmost = createMemo(() => ownNestedOpenDialogs() === 0); const floatingRootContext: FloatingRootStore = useFloatingRootContext({ open: untrack(open), onOpenChange: (nextOpen, details) => { setOpenInternal(nextOpen, details as DialogRootChangeEventDetails); }, }); createEffect( () => ({ o: open(), trigger: triggerElement(), popup: popupElement(), // Capture transitionStatus in compute (snapshot pattern) — apply // runs unowned so a direct read would trip // `[STRICT_READ_UNTRACKED]`. status: transitionStatus(), }), ({ o, trigger, popup, status }) => untrack(() => { floatingRootContext.update({ open: o, referenceElement: trigger, domReferenceElement: trigger, floatingElement: popup, transitionStatus: status, }); }), ); createEffect( () => transitionStatus(), (status) => untrack(() => { floatingRootContext.update({ transitionStatus: status }); }), ); // Dismiss — Escape, outside press, focus-out. For dialogs: // - Mouse outside-press: `intentional` (requires unbroken mouse // sequence on the same target) for modal, `sloppy` for trap-focus. // - Touch: `sloppy`. // - Escape: only when topmost. const dismiss = useDismiss(floatingRootContext, { outsidePressEvent: { get mouse() { return untrack(() => modal() === "trap-focus" ? "sloppy" : "intentional", ); }, touch: "sloppy", }, // `outsidePress` may be invoked from useDismiss's effect-apply scope // during synthetic events. Wrap reads in `untrack`. outsidePress(event) { return untrack(() => { if (!outsidePressEnabledRef.current) return false; if (disablePointerDismissal()) return false; if ("button" in event && event.button !== 0) return false; if ("touches" in event && event.touches.length !== 1) return false; if (!isTopmost()) return false; return true; }); }, escapeKey: isTopmost, }); const role = useRole(floatingRootContext, { role: options.role }); const interactions: UseInteractionsReturn = useInteractions([dismiss, role]); useScrollLock({ enabled: () => open() && modal() === true, referenceElement: () => popupElement(), }); const setOpen = ( next: boolean, details: DialogRootChangeEventDetails, ): void => { if (typeof details.preventUnmountOnClose !== "function") { details.preventUnmountOnClose = () => { setPreventUnmountingOnClose(true); }; } else { const original = details.preventUnmountOnClose; details.preventUnmountOnClose = () => { setPreventUnmountingOnClose(true); original(); }; } if (next) setPreventUnmountingOnClose(false); setOpenInternal(next, details); // Base UI returns before trigger-state mirroring when the change // was canceled via `details.cancel()`. if (details.isCanceled) return; if (details.trigger && (details.trigger as HTMLElement).id) { setActiveTriggerIdInternal((details.trigger as HTMLElement).id); setTriggerElementInternal(details.trigger as HTMLElement); } }; store.attachSetOpen((next, details) => { setOpen(next, details); }); onCleanup(() => store.attachSetOpen(null)); createEffect( () => open(), (o) => { store.setOpenMirror(o); }, ); createEffect( () => open(), (o) => untrack(() => { if (!o) return; if (activeTriggerId() !== null) return; if (store.context.triggerElements.size !== 1) return; const entry = store.context.triggerElements.entries().next(); if (!entry.done) { const [id, el] = entry.value; setActiveTriggerIdInternal(id); setTriggerElementInternal(el as HTMLElement); } }), ); createEffect( () => ({ o: open(), dialogs: ownNestedOpenDialogs(), drawers: ownNestedOpenDrawers(), }), ({ o, dialogs, drawers }) => { if (!parentDialogContext) return undefined; if (o) { parentDialogContext.onNestedDialogOpen( dialogs + 1, drawers + (isDrawer ? 1 : 0), ); return () => { parentDialogContext.onNestedDialogClose(); }; } parentDialogContext.onNestedDialogClose(); return undefined; }, ); const makeImperativeDetails = (): DialogRootChangeEventDetails => createChangeEventDetails< DialogRootChangeEventReason, { preventUnmountOnClose: () => void } >( REASONS.imperativeAction as DialogRootChangeEventReason, undefined, undefined, { preventUnmountOnClose: () => {} }, ) as DialogRootChangeEventDetails; const actions: DialogRootInternalActions = { close: () => setOpen(false, makeImperativeDetails()), unmount: () => { setPreventUnmountingOnClose(false); setOpen(false, makeImperativeDetails()); setMounted(false); }, }; const ref = props.actionsRef; if (typeof ref === "function") { ref(actions); onCleanup(() => ref(null)); } else if (ref && typeof ref === "object") { ref.current = actions; onCleanup(() => { ref.current = null; }); } const openMethodAccessor = (): InteractionType | null => openMethod(); const onNestedDialogOpen = ( dialogCount: number, drawerCount: number, ): void => { setOwnNestedOpenDialogs(dialogCount); setOwnNestedOpenDrawers(drawerCount); }; const onNestedDialogClose = (): void => { setOwnNestedOpenDialogs(0); setOwnNestedOpenDrawers(0); }; const contextValue: DialogRootContextValue = { open, mounted, transitionStatus, modal, disablePointerDismissal, role: () => options.role, nested: () => nested, nestedOpenDialogCount: ownNestedOpenDialogs, nestedOpenDrawerCount: ownNestedOpenDrawers, openMethod: openMethodAccessor, titleId, setTitleId, descriptionId, setDescriptionId, floatingRootContext, interactions, setOpen, popupRef, backdropRef, internalBackdropRef, outsidePressEnabledRef, popupElement, setPopupElement, viewportElement, setViewportElement, triggerElement, setTriggerElement, activeTriggerId, setActiveTriggerId, store: store as DialogStore, payload, setPayload: (p) => setPayload(() => p), onNestedDialogOpen, onNestedDialogClose, }; const resolveChildren = (): JSX.Element => { const ch = props.children; if (typeof ch === "function") { return (ch as DialogPayloadChildRenderFunction)({ payload: payload(), }); } return ch as JSX.Element; }; return ( } > {resolveChildren()} ); }