import type { JSX } from "@solidjs/web"; /** * `` — Solid 2 port of Base UI React's `DrawerRoot` * (`packages/react/src/drawer/root/DrawerRoot.tsx`). * * Composes over `` + `IsDrawerContext(true)` to reuse * dialog's open/close/focus-trap/scroll-lock machinery. Adds drawer- * specific state: * * - `swipeDirection` — dismissal direction (defaults `'down'`). * - `snapPoints` / `snapPoint` / `defaultSnapPoint` — snap-point control. * - Nested-drawer bookkeeping (`popupHeight`, `frontmostHeight`, * `hasNestedDrawer`, `nestedSwiping`, `nestedSwipeProgressStore`). * - `DrawerProvider` reporter — auto-registers the drawer's id with the * enclosing `` and wires an Android `CloseWatcher` (if * available) for back-gesture support. * * Papercuts avoided: * * - P4: the `` wraps children via ``'s * children, which already evaluates under the dialog's provider at * the right time. * - P11: `DialogRoot` accepts `children` by value; we thread the drawer * provider + reporter through its children JSX (no `omit` needed here * because we never enter the `renderElement` path). */ import { createEffect, createSignal, onCleanup, untrack } from "solid-js"; import { IsDrawerContext, useDialogRootContext, } from "../../dialog/dialog-root-context"; import { DialogRoot, type DialogRootActions, } from "../../dialog/root/dialog-root"; import type { DialogHandle } from "../../dialog/store/dialog-store"; import { useBaseUiId } from "../../internals/base-ui-id"; import { type BaseUIChangeEventDetails, createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { createControlledState } from "../../internals/create-controlled-state"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { createDrawer } from "../create-drawer"; import { useDrawerProviderContext } from "../provider/drawer-provider-context"; import { type DrawerNestedSwipeProgressStore, type DrawerRootChangeEventReason, DrawerRootContext, type DrawerRootContextValue, type DrawerRootSnapPointChangeEventDetails, type DrawerRootSnapPointChangeEventReason, type DrawerSnapPoint, type DrawerSwipeDirection, useDrawerRootContext, } from "./drawer-root-context"; export type { DrawerRootChangeEventReason, DrawerRootSnapPointChangeEventDetails, DrawerRootSnapPointChangeEventReason, }; export type DrawerRootChangeEventDetails = BaseUIChangeEventDetails< DrawerRootChangeEventReason, { preventUnmountOnClose: () => void } >; export type DrawerPayloadChildRenderFunction = (ctx: { payload: Payload | undefined; }) => JSX.Element; export interface DrawerRootActions extends DialogRootActions {} export interface DrawerRootProps { /** * Controlled open state. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onOpenChange}. When omitted, the drawer manages its own open * state (seeded from {@link defaultOpen}). */ open?: MaybeAccessor; /** * Initial open state when uncontrolled. Read once at mount. * @default false */ defaultOpen?: MaybeAccessor; modal?: MaybeAccessor; onOpenChange?: | ((open: boolean, details: DrawerRootChangeEventDetails) => void) | undefined; onOpenChangeComplete?: ((open: boolean) => void) | undefined; disablePointerDismissal?: boolean | undefined; actionsRef?: | ((actions: DrawerRootActions | null) => void) | { current: DrawerRootActions | null } | undefined; handle?: DialogHandle | undefined; triggerId?: string | null | undefined; children?: JSX.Element | DrawerPayloadChildRenderFunction; /** Swipe dismissal direction. @default "down" */ swipeDirection?: DrawerSwipeDirection | undefined; /** Snap points list (fractions / px / CSS length strings). */ snapPoints?: DrawerSnapPoint[] | undefined; /** Disable velocity-based snap skipping. @default false */ snapToSequentialPoints?: boolean | undefined; /** Active snap point. Consumer-owned — pair with {@link onSnapPointChange}. */ snapPoint?: DrawerSnapPoint | null | undefined; /** Fires on every snap-point change request. */ onSnapPointChange?: | (( snapPoint: DrawerSnapPoint | null, details: DrawerRootSnapPointChangeEventDetails, ) => void) | undefined; } // biome-ignore lint/complexity/noBannedTypes: state is empty — matches React. export type DrawerRootState = {}; interface NestedSwipeProgressStoreImpl extends DrawerNestedSwipeProgressStore { set: (progress: number) => void; } function createNestedSwipeProgressStore(): NestedSwipeProgressStoreImpl { let progress = 0; const listeners = new Set<() => void>(); return { getSnapshot: () => progress, set(nextProgress) { const resolved = Number.isFinite(nextProgress) ? nextProgress : 0; if (resolved === progress) return; progress = resolved; for (const l of listeners) l(); }, subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; }, }; } /** * Groups all parts of the drawer. Doesn't render its own HTML element. */ export function DrawerRoot( props: DrawerRootProps, ): JSX.Element { const parentDrawer = useDrawerRootContext(true); const notifyParentSwipeProgressChange = parentDrawer?.onNestedSwipeProgressChange; const notifyParentFrontmostHeight = parentDrawer?.onNestedFrontmostHeightChange; const notifyParentSwipingChange = parentDrawer?.onNestedSwipingChange; const notifyParentHasNestedDrawer = parentDrawer?.onNestedDrawerPresenceChange; // Controlled / uncontrolled open state (Base UI `open` / `defaultOpen` // contract): the `open` prop wins while its resolved value is defined; // otherwise an internal signal (seeded once from `defaultOpen`) backs // the state and `handleOpenChange` (below) writes it. In React Base UI // this resolution lives in `Dialog.Root` (Drawer passes `open` / // `defaultOpen` straight through); here the drawer owns it so its // headless primitive sees the resolved open and the inner `` // stays controlled by it. const [resolvedOpen, setUncontrolledOpen] = createControlledState({ controlled: () => { const controlled = readOption(props.open); return controlled === undefined ? undefined : Boolean(controlled); }, default: () => readOption(props.defaultOpen), fallback: false, name: "", state: "open", }); // Compose the headless Drawer primitive — owns logical popup state (open, // disabled, modal). DOM machinery (DialogRoot's floating tree, swipe // gesture, snap-points, transitions, portal, dismiss) stays in this // wrapper. `onOpenChange` is NOT wired here — `handleOpenChange` (below) // owns the open-change pipeline so it can run snap-point reset bookkeeping. const drawerPrimitive = createDrawer({ open: resolvedOpen, modal: () => readOption(props.modal) ?? true, }); const swipeDirection = (): DrawerSwipeDirection => props.swipeDirection ?? "down"; const snapToSequentialPoints = (): boolean => props.snapToSequentialPoints ?? false; const snapPoints = (): DrawerSnapPoint[] | undefined => props.snapPoints; // Consumer-owned snap-point accessor. Falls back to first snap point when // the consumer has not passed a value. No internal signal. const activeSnapPointAccessor = (): DrawerSnapPoint | null => { if (props.snapPoint !== undefined) return props.snapPoint ?? null; return props.snapPoints?.[0] ?? null; }; const [popupHeight, setPopupHeight] = createSignal(0, { ownedWrite: true }); const [frontmostHeight, setFrontmostHeight] = createSignal(0, { ownedWrite: true, }); const [hasNestedDrawer, setHasNestedDrawer] = createSignal(false, { ownedWrite: true, }); const [nestedSwiping, setNestedSwiping] = createSignal(false, { ownedWrite: true, }); const nestedSwipeProgressStore = createNestedSwipeProgressStore(); // Track whether a nested drawer is currently open (for frontmost-height // bookkeeping) — a plain `let` matches React's `useRef(false)`. let isNestedDrawerOpenRef = false; const setActiveSnapPoint = ( nextSnapPoint: DrawerSnapPoint | null, details?: DrawerRootSnapPointChangeEventDetails, ): void => { const eventDetails = details ?? createChangeEventDetails(REASONS.none); props.onSnapPointChange?.(nextSnapPoint, eventDetails); // Consumer's onSnapPointChange handler is the only writer — no internal // write. `eventDetails.isCanceled` check preserved for the cancel path. }; // Read through the consumer's accessor — no internal copy. const resolvedActiveSnapPoint = (): DrawerSnapPoint | null | undefined => activeSnapPointAccessor(); const onPopupHeightChange = (height: number): void => { setPopupHeight(height); if (!isNestedDrawerOpenRef && height > 0) setFrontmostHeight(height); }; // Invoked from a child drawer's effect-apply (`drawer-popup` notifies on // frontmost-height change). The `popupHeight()` read is a snapshot — // wrap in `untrack` so it doesn't trip `[STRICT_READ_UNTRACKED]`. const onNestedFrontmostHeightChange = (height: number): void => untrack(() => { if (height > 0) { isNestedDrawerOpenRef = true; setFrontmostHeight(height); return; } isNestedDrawerOpenRef = false; if (popupHeight() > 0) setFrontmostHeight(popupHeight()); }); const onNestedDrawerPresenceChange = (present: boolean): void => { setHasNestedDrawer(present); }; const onNestedSwipeProgressChange = (progress: number): void => { nestedSwipeProgressStore.set(progress); notifyParentSwipeProgressChange?.(progress); }; const onNestedSwipingChange = (swiping: boolean): void => { setNestedSwiping(swiping); notifyParentSwipingChange?.(swiping); }; // Every open writer (trigger press, Escape, outside press, close button, // swipe dismissal, CloseWatcher, imperative handle) routes through the // inner dialog's `setOpen` → `setOpenInternal` → this callback (wired as // ``). Mirrors Base UI's drawer pipeline: fire // the consumer's `onOpenChange`, honor `details.cancel()`, run the // snap-point reset, then write the uncontrolled open state — a no-op // for reads while the `open` prop is controlled. const handleOpenChange = ( nextOpen: boolean, details: DrawerRootChangeEventDetails, ): void => { props.onOpenChange?.(nextOpen, details); if (details.isCanceled) return; const pts = snapPoints(); if (!nextOpen && pts && pts.length > 0) { // On close, reset to first snap point by notifying the consumer. setActiveSnapPoint( pts[0] ?? null, createChangeEventDetails( details.reason, details.event, details.trigger as HTMLElement | undefined, ) as DrawerRootSnapPointChangeEventDetails, ); } setUncontrolledOpen(nextOpen); }; const contextValue: DrawerRootContextValue = { swipeDirection, snapToSequentialPoints, snapPoints, activeSnapPoint: resolvedActiveSnapPoint, setOpen: handleOpenChange, setActiveSnapPoint, frontmostHeight, popupHeight, hasNestedDrawer, nestedSwiping, nestedSwipeProgressStore, onPopupHeightChange, onNestedFrontmostHeightChange, onNestedDrawerPresenceChange, onNestedSwipingChange, onNestedSwipeProgressChange, notifyParentFrontmostHeight, notifyParentSwipingChange, notifyParentSwipeProgressChange, notifyParentHasNestedDrawer, }; return ( open={drawerPrimitive.open()} onOpenChange={ handleOpenChange as ( open: boolean, details: Parameters< NonNullable< Parameters>[0]["onOpenChange"] > >[1], ) => void } onOpenChangeComplete={props.onOpenChangeComplete} disablePointerDismissal={props.disablePointerDismissal} modal={drawerPrimitive.modal()} actionsRef={props.actionsRef} handle={props.handle} triggerId={props.triggerId} > { // If the consumer passed a payload render-function, wrap it so // we can inject the provider reporter alongside the resolved // JSX. Otherwise render children + reporter directly. typeof props.children === "function" ? ( (((ctx: { payload: Payload | undefined }) => ( <> {( props.children as DrawerPayloadChildRenderFunction )(ctx)} )) as DrawerPayloadChildRenderFunction) ) : ( <> {props.children as JSX.Element} ) } ); } /** * Reports the drawer's open state to the enclosing `` * (if any) and installs an Android `CloseWatcher` for the back gesture. * * 1:1 port of the React `DrawerProviderReporter`. Skips the `CloseWatcher` * wiring when not on Android — the React source scopes it the same way * to avoid interfering with `useDismiss`'s Escape + nesting semantics. */ function DrawerProviderReporter(): JSX.Element | null { const generatedId = useBaseUiId(); const drawerId = (): string | undefined => generatedId; const providerContext = useDrawerProviderContext(true); const dialogRootContext = useDialogRootContext(); const open = dialogRootContext.open; const nestedOpenDialogCount = dialogRootContext.nestedOpenDialogCount; const popupElement = dialogRootContext.popupElement; const isTopmost = (): boolean => nestedOpenDialogCount() === 0; // Cleanup registration on unmount. onCleanup(() => { const id = drawerId(); if (providerContext && id) providerContext.removeDrawer(id); }); // Forward open-state to the enclosing provider. createEffect( () => ({ o: open(), id: drawerId() }), ({ o, id }) => { if (!id) return; providerContext?.setDrawerOpen(id, o); }, ); // Android `CloseWatcher` wiring. Chromium-only; kept Android-only to // avoid interfering with Escape handling on desktop. createEffect( () => ({ o: open(), topmost: isTopmost(), popup: popupElement(), }), ({ o, topmost, popup }) => { if (!o || !topmost) return undefined; const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; const isAndroid = /Android/i.test(ua); if (!isAndroid) return undefined; const win = popup?.ownerDocument?.defaultView ?? globalThis.window; const CloseWatcherCtor = ( win as Window & { CloseWatcher?: new () => EventTarget & { destroy: () => void }; } ).CloseWatcher; if (!CloseWatcherCtor) return undefined; const handleCloseWatcher = (event: Event): void => { if (!dialogRootContext.open()) return; dialogRootContext.setOpen( false, createChangeEventDetails(REASONS.closeWatcher, event) as Parameters< typeof dialogRootContext.setOpen >[1], ); }; const closeWatcher = new CloseWatcherCtor(); closeWatcher.addEventListener("close", handleCloseWatcher); return () => { closeWatcher.removeEventListener("close", handleCloseWatcher); closeWatcher.destroy?.(); }; }, ); return null; }