import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — container for the drawer contents. * * Mirrors Base UI React's `DrawerPopup` * (`packages/react/src/drawer/popup/DrawerPopup.tsx`). Wraps the rendered * element in `` when modal/trap-focus; exposes * `initialFocus` / `finalFocus` overrides with the same union shape as * ``. * * Responsibilities beyond dialog's popup: * * - Measures the popup `offsetHeight` on mount + via `ResizeObserver`, * piping the result into ``'s `onPopupHeightChange`. * - Subscribes to the drawer root's `nestedSwipeProgressStore` and pushes * `--drawer-swipe-progress` onto the element (non-reactive bridge). * - Emits drawer-specific data-attrs: `data-expanded`, * `data-nested-drawer-open`, `data-nested-drawer-swiping`, * `data-swipe-direction`, `data-swiping`. * - Applies snap-point offset + consumer swipe drag styles. * - Forwards frontmost-height / nested-presence to the parent drawer * (for stacked drawer layouts). * * happy-dom deferrals: * - `offsetHeight` is 0 / `ResizeObserver` may be unavailable; the * measurement paths are inert in the test env. * - `CSS.registerProperty` is skipped when unavailable — same path as * React. */ import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, omit, onCleanup, untrack, } from "solid-js"; import { useDialogRootContext } from "../../dialog/dialog-root-context"; import { useDialogPortalContext } from "../../dialog/portal/dialog-portal-context"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { FloatingFocusManager, type InteractionType, } from "../../internals/floating-ui/components/floating-focus-manager"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { useIsoLayoutEffect } from "../../internals/use-iso-layout-effect"; import type { TransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { DrawerBackdropCssVars } from "../backdrop/drawer-backdrop-css-vars"; import { type DrawerSwipeDirection, useDrawerRootContext, } from "../root/drawer-root-context"; import { useDrawerSnapPoints } from "../root/use-drawer-snap-points"; import { useDrawerViewportContext } from "../viewport/drawer-viewport-context"; import { DrawerPopupCssVars } from "./drawer-popup-css-vars"; import { DrawerPopupDataAttributes } from "./drawer-popup-data-attributes"; /** Keys that the popup intercepts so they don't escape composite parents. */ const COMPOSITE_KEYS = new Set([ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown", ]); // Module-level guard mirroring React's `drawerSwipeVarsRegistered`. let drawerSwipeVarsRegistered = false; function registerSwipeCSSProperties(): void { if (drawerSwipeVarsRegistered) return; const anyCSS = (globalThis as { CSS?: unknown }).CSS as | (typeof CSS & { registerProperty?: (desc: { name: string; syntax: string; inherits: boolean; initialValue: string; }) => void; }) | undefined; if (anyCSS && typeof anyCSS.registerProperty === "function") { for (const name of [ DrawerPopupCssVars.swipeMovementX, DrawerPopupCssVars.swipeMovementY, DrawerPopupCssVars.snapPointOffset, ]) { try { anyCSS.registerProperty?.({ name, syntax: "", inherits: false, initialValue: "0px", }); } catch { /* ignore */ } } for (const { name, initialValue } of [ { name: DrawerBackdropCssVars.swipeProgress, initialValue: "0" }, { name: DrawerPopupCssVars.swipeStrength, initialValue: "1" }, ]) { try { anyCSS.registerProperty?.({ name, syntax: "", inherits: false, initialValue, }); } catch { /* ignore */ } } } drawerSwipeVarsRegistered = true; } export interface DrawerPopupState { open: boolean; transitionStatus: TransitionStatus; expanded: boolean; nested: boolean; nestedDrawerOpen: boolean; nestedDrawerSwiping: boolean; swipeDirection: DrawerSwipeDirection; swiping: boolean; } export type DrawerFocusHandle = | boolean | HTMLElement | { current: HTMLElement | null } | ((t: InteractionType) => boolean | HTMLElement | null | undefined); export interface DrawerPopupProps extends BaseUIComponentProps<"div", DrawerPopupState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; initialFocus?: DrawerFocusHandle | undefined; finalFocus?: DrawerFocusHandle | undefined; } /** * A container for the drawer contents. Renders a `
` element. */ export function DrawerPopup(props: DrawerPopupProps): JSX.Element { const root = useDialogRootContext(); const drawer = useDrawerRootContext(); useDialogPortalContext(); const swipeCtx = useDrawerViewportContext(true); const { snapPoints, activeSnapPoint, activeSnapPointOffset } = useDrawerSnapPoints(); const nestedDrawerOpen = (): boolean => root.nestedOpenDrawerCount() > 0; // `nestedOpenDrawerCount` is wired on the dialog context; drawers // increment on open (via `IsDrawerContext`). We project it to a // drawer-specific attr. const swiping = (): boolean => swipeCtx?.swiping() ?? false; const swipeStrength = (): number | null => swipeCtx?.swipeStrength() ?? null; const [popupHeight, setPopupHeightSig] = createSignal(0, { ownedWrite: true, }); let popupHeightRef = 0; const setPopupRef = (el: HTMLElement | null): void => { root.popupRef.current = el; root.setPopupElement(el); }; // Measure height + observe resizes, forwarding to drawer.onPopupHeightChange. useIsoLayoutEffect( () => ({ mounted: root.mounted(), popupEl: root.popupElement(), nestedDrawerOpenFlag: nestedDrawerOpen(), }), ({ mounted, popupEl }) => { if (!mounted) { popupHeightRef = 0; setPopupHeightSig(0); drawer.onPopupHeightChange(0); return undefined; } if (!popupEl) return undefined; registerSwipeCSSProperties(); // `measure` is a side-effect helper: it reads the latest snapshot // of `drawer.frontmostHeight()` / `drawer.hasNestedDrawer()` to // decide whether to update `popupHeightRef`. Re-runs are driven // by the compute object above (and ResizeObserver), so the reads // are snapshots, not subscriptions. Wrap in `untrack` to avoid // `[STRICT_READ_UNTRACKED]` when invoked from this apply scope // or from the ResizeObserver callback (which also lands outside // any tracking owner). const measure = (): void => untrack(() => { const offsetHeight = popupEl.offsetHeight; const frontmost = drawer.frontmostHeight(); const hasNested = drawer.hasNestedDrawer(); if ( popupHeightRef > 0 && frontmost > popupHeightRef && offsetHeight > popupHeightRef ) { return; } const keepHeightWhileNested = popupHeightRef > 0 && hasNested; if (keepHeightWhileNested) { const oldHeight = popupHeightRef; setPopupHeightSig(oldHeight); drawer.onPopupHeightChange(oldHeight); return; } const next = offsetHeight; if (next === popupHeightRef) return; popupHeightRef = next; setPopupHeightSig(next); drawer.onPopupHeightChange(next); }); measure(); if (typeof ResizeObserver !== "function") return undefined; const ro = new ResizeObserver(measure); ro.observe(popupEl); return () => { ro.disconnect(); }; }, ); // Subscribe to nested-swipe-progress store, push backdrop swipe-progress // CSS var onto the popup. Non-reactive bridge matching React. useIsoLayoutEffect( () => root.popupElement(), (popupEl) => { if (!popupEl) return undefined; const sync = (): void => { const progress = drawer.nestedSwipeProgressStore.getSnapshot(); popupEl.style.setProperty( DrawerBackdropCssVars.swipeProgress, progress > 0 ? `${progress}` : "0", ); }; sync(); const unsubscribe = drawer.nestedSwipeProgressStore.subscribe(sync); return () => { unsubscribe(); popupEl.style.setProperty(DrawerBackdropCssVars.swipeProgress, "0"); }; }, ); // Forward frontmost height upward when open. // `notifyParentFrontmostHeight` may itself read parent reactive state in // its body — we untrack the side-effect call so those reads don't trip // `[STRICT_READ_UNTRACKED]` from this unowned apply scope. The compute // object already drives re-runs. createEffect( () => ({ o: root.open(), h: drawer.frontmostHeight() }), ({ o, h }) => { if (!o) return undefined; untrack(() => drawer.notifyParentFrontmostHeight?.(h)); return () => { untrack(() => drawer.notifyParentFrontmostHeight?.(0)); }; }, ); // Forward nested-drawer presence upward. createEffect( () => ({ o: root.open(), t: root.transitionStatus() }), ({ o, t }) => { if (!drawer.notifyParentHasNestedDrawer) return undefined; const present = o || t === "ending"; untrack(() => drawer.notifyParentHasNestedDrawer?.(present)); return () => { untrack(() => drawer.notifyParentHasNestedDrawer?.(false)); }; }, ); onCleanup(() => { drawer.notifyParentHasNestedDrawer?.(false); }); const state = createMemo(() => ({ open: root.open(), transitionStatus: root.transitionStatus(), expanded: activeSnapPoint() === 1, nested: root.nested(), nestedDrawerOpen: nestedDrawerOpen(), nestedDrawerSwiping: drawer.nestedSwiping(), swipeDirection: drawer.swipeDirection(), swiping: swiping(), })); const defaultInitialFocus = (t: InteractionType) => { if (t === "touch") return root.popupRef.current; return true; }; const resolvedInitialFocus = () => props.initialFocus ?? defaultInitialFocus; const resolvedFinalFocus = () => props.finalFocus ?? true; const internal = { role: "dialog", tabIndex: -1, get hidden() { return !root.mounted(); }, get "aria-labelledby"() { return root.titleId() ?? ""; }, get "aria-describedby"() { return root.descriptionId() ?? ""; }, onKeyDown(event: KeyboardEvent) { if (COMPOSITE_KEYS.has(event.key)) event.stopPropagation(); }, get style(): JSX.CSSProperties { const pts = snapPoints(); const swipeDir = drawer.swipeDirection(); const offset = activeSnapPointOffset(); const shouldApplySnapPoints = pts && pts.length > 0 && (swipeDir === "down" || swipeDir === "up"); let snapPointOffsetValue: number | null = null; if (shouldApplySnapPoints && offset !== null) { snapPointOffsetValue = swipeDir === "up" ? -offset : offset; } const frontmost = drawer.frontmostHeight(); const ph = popupHeight(); const hasNested = drawer.hasNestedDrawer(); const isEnding = root.transitionStatus() === "ending"; const shouldUseAutoHeight = !hasNested && !isEnding; const heightVal = ph && !shouldUseAutoHeight ? `${ph}px` : undefined; const dragStyles = swipeCtx ? swipeCtx.getDragStyles() : {}; const strength = swipeStrength(); const strengthVal = typeof strength === "number" && Number.isFinite(strength) && strength > 0 ? `${strength}` : "1"; return { ...(dragStyles as JSX.CSSProperties), [DrawerBackdropCssVars.swipeProgress]: "0", [DrawerPopupCssVars.nestedDrawers]: String( root.nestedOpenDialogCount(), ), [DrawerPopupCssVars.height]: heightVal as string | undefined, [DrawerPopupCssVars.snapPointOffset]: typeof snapPointOffsetValue === "number" ? `${snapPointOffsetValue}px` : "0px", [DrawerPopupCssVars.frontmostHeight]: frontmost ? `${frontmost}px` : undefined, [DrawerPopupCssVars.swipeStrength]: strengthVal, } as JSX.CSSProperties; }, get "data-open"() { return state().open ? "" : undefined; }, get "data-closed"() { return state().open ? undefined : ""; }, get "data-starting-style"() { return state().transitionStatus === "starting" ? "" : undefined; }, get "data-ending-style"() { return state().transitionStatus === "ending" ? "" : undefined; }, get [DrawerPopupDataAttributes.expanded]() { return state().expanded ? "" : undefined; }, get [DrawerPopupDataAttributes.nestedDrawerOpen]() { return state().nestedDrawerOpen ? "" : undefined; }, get [DrawerPopupDataAttributes.nestedDrawerSwiping]() { return state().nestedDrawerSwiping ? "" : undefined; }, get [DrawerPopupDataAttributes.swipeDirection]() { const v = state().swipeDirection; return v ? v : undefined; }, get [DrawerPopupDataAttributes.swiping]() { return state().swiping ? "" : undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "initialFocus", "finalFocus", ); let merged: Record = mergeRootProps( root.interactions.getFloatingProps(), internal, ); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); const popupEl = ( ); const modalValue = (): boolean => root.modal() !== false; const closeOnFocusOut = (): boolean => !root.disablePointerDismissal(); return ( {popupEl} ); }