/** * Port of Base UI React's `useDismiss` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useDismiss.ts` * * Dismisses the floating element on: * - Escape key (gated by `escapeKey`). * - Outside press (gated by `outsidePress`) — driven by an * `intentional`/`sloppy` press-type state machine: * * `intentional` — mouse fires on `pointerup`; touch requires a * minimal movement or `touchend` within a short window. * * `sloppy` — mouse fires on `pointerdown`; touch fires on * `touchend` (within 1 s) or a scroll-away gesture. * - Reference press (gated by `referencePress`) — the bubble handler * fires on `onPointerDown` when `sloppy` and `onClick` when * `intentional`; a fallback `onClick` also fires in `sloppy` mode so * keyboard activation keeps working. * * ### React → Solid deviations * * 1. `useEffect` with deps → `createEffect` in split compute/apply form. * The apply runs whenever any tracked read changes. We purposely * track a memoised options-snapshot getter to match React's dep * array: the effect re-installs listeners when `open`, `enabled`, * `escapeKey`, `outsidePress` (only boolean/fn swap), or `doc` * identity changes. * * 2. `useRef` → `MutableRef` (keeps the `.current` read/write shape * callers elsewhere in the port already use). * * 3. `useStableCallback` → `createStableCallback` from * `src/internals/stable-callback.ts`. Used to stabilise listener * identity so `addEventListener` / `removeEventListener` pair up. * * 4. React's `isReactEvent` branch (unwrap `.nativeEvent`) is dropped — * Solid event handlers receive native events directly, so the * keydown handler takes a plain `KeyboardEvent`. * * 5. React's `useTimeout` (from `@base-ui/utils`) maps to the * `useTimeout` helper at `src/internals/use-timeout.ts`. The `Timeout` * class ships `start(delay, fn)` + `clear()` with the same * semantics. * * 6. Shadow-DOM-safe `contains()` from `src/internals/dom/contains.ts` * replaces raw `node.contains(target)`. * * 7. Tree-aware dismissal (parent popover not dismissing when click * lands inside a child floating element, gated by `bubbles.*`) * follows React 1:1 — the parent reads `__escapeKeyBubbles` / * `__outsidePressBubbles` latches stamped on each child's * `dataRef` during its own `useDismiss` effect. */ import { getComputedStyle, getParentNode, isElement, isHTMLElement, isLastTraversableNode, isShadowRoot, isWebKit, } from "@floating-ui/utils/dom"; import { createEffect, untrack } from "solid-js"; import { type BaseUIChangeEventDetails, createChangeEventDetails, REASONS, } from "../../change-event-details"; import { contains } from "../../dom/contains"; import { ownerDocument } from "../../dom/owner-document"; import { type MaybeAccessor, readOption } from "../../maybe-accessor"; import { createStableCallback } from "../../stable-callback"; import { Timeout, useTimeout } from "../../use-timeout"; import { isInsideNestedPortal } from "../components/floating-portal"; import { useFloatingTree } from "../components/floating-tree"; import type { FloatingTreeStore } from "../components/floating-tree-store"; import type { ElementProps, ElementPropsBag, FloatingContext, FloatingRootContext, MutableRef, } from "../types"; import { createAttribute } from "../utils/create-attribute"; import { getTarget, isEventTargetWithin, isRootElement, } from "../utils/element"; import { getNodeChildren } from "../utils/nodes"; type PressType = "intentional" | "sloppy"; const bubbleHandlerKeys = { intentional: "onClick", sloppy: "onPointerDown", } as const; function alwaysFalse() { return false; } /** * Normalise `bubbles` into an explicit `{ escapeKey, outsidePress }` * pair. Shorthand boolean applies to both; object form fills defaults. * Accessors on either the outer `bubbles` prop or the nested fields are * resolved at the call site. Defaults (matching React): * `escapeKey: false`, `outsidePress: true`. */ function normalizeProp( normalizable?: | MaybeAccessor< | boolean | { escapeKey?: MaybeAccessor | undefined; outsidePress?: MaybeAccessor | undefined; } > | undefined, ): { escapeKey: boolean; outsidePress: boolean } { const resolved = typeof normalizable === "function" ? (normalizable as () => unknown)() : normalizable; if (typeof resolved === "boolean") { return { escapeKey: resolved, outsidePress: resolved }; } const obj = resolved as | { escapeKey?: MaybeAccessor | undefined; outsidePress?: MaybeAccessor | undefined; } | undefined; return { escapeKey: obj?.escapeKey === undefined ? false : readOption(obj.escapeKey), outsidePress: obj?.outsidePress === undefined ? true : readOption(obj.outsidePress), }; } export interface UseDismissProps { /** * Whether the Hook is enabled, including all internal Effects and event * handlers. Accepts an accessor for reactive flips (solidify.md P3). * @default true */ enabled?: MaybeAccessor | undefined; /** * Whether to dismiss the floating element upon pressing the `esc` key. * Accepts an accessor for reactive flips. * @default true */ escapeKey?: MaybeAccessor | undefined; /** * Whether to dismiss the floating element upon pressing the reference * element. You likely want to ensure the `move` option in the `useHover()` * Hook has been disabled when this is in use. * * A lazy getter invoked when handling reference press events. * @default false */ referencePress?: (() => boolean) | undefined; /** * The type of event to use to determine a "press". * - `sloppy` is `pointerdown` on mouse input, but special touch handling * on touch input. * - `intentional` is lazy on both mouse + touch input (equivalent to * `click`). * @default 'sloppy' */ referencePressEvent?: PressType | undefined; /** * Whether to dismiss the floating element upon pressing outside of the * floating element. * If you have another element, like a toast, that is rendered outside the * floating element's tree and don't want the floating element to close * when pressing it, you can guard the check like so: * ```jsx * useDismiss(context, { * outsidePress: (event) => !event.target.closest('.toast'), * }); * ``` * @default true */ outsidePress?: | boolean | ((event: MouseEvent | TouchEvent) => boolean) | undefined; /** * The type of event to use to determine an outside "press". * - `intentional` requires an intentional click — mouse fires on * `pointerup`, touch requires minimal `touchmove`s. * - `sloppy` fires on `pointerdown` for mouse; touch fires on * `touchend` (within 1 s) or on scroll-away after `touchstart`. * * Can be a per-input record or a lazy getter returning either form. * @default 'sloppy' */ outsidePressEvent?: | PressType | { mouse: PressType; touch: PressType } | (() => PressType | { mouse: PressType; touch: PressType }) | undefined; /** * Determines whether event listeners bubble upwards through a tree of * floating elements. Accepts an accessor — including accessors on the * nested `escapeKey` / `outsidePress` fields — so reactive callers can * flip submenu escape-bubbling (solidify.md P3). */ bubbles?: | MaybeAccessor< | boolean | { escapeKey?: MaybeAccessor | undefined; outsidePress?: MaybeAccessor | undefined; } > | undefined; /** * External `FloatingTreeStore` to use when the context-provided one is * not reachable. */ externalTree?: FloatingTreeStore | undefined; } /** * Closes the floating element when a dismissal is requested — by default, * when the user presses `Escape` or outside of the floating element. * @see https://floating-ui.com/docs/useDismiss */ export function useDismiss( context: | FloatingRootContext | FloatingContext | (() => FloatingRootContext | FloatingContext), props: UseDismissProps = {}, ): ElementProps { /** * Resolve the active floating root store. Accepts either a bare * context or an accessor so navigation-menu-style compounds can swap * the root context without re-installing the hook. */ const resolveStore = () => { const ctx = typeof context === "function" ? (context as () => FloatingRootContext | FloatingContext)() : context; return "rootStore" in ctx ? ctx.rootStore : ctx; }; // The initial store snapshot — handlers that don't re-run on each // event (e.g. capture-phase listeners) use the live `resolveStore()` // at call time instead of this binding. `dataRef` is read eagerly // from the initial snapshot; when the context swaps later, outside // handlers resolve the live store but still look at the caller's // original dataRef for the short-lived latches. // One-shot snapshot (see comment above). `resolveStore` reads the reactive // `floatingRootContext` accessor (navigation-menu List passes an accessor), // so untrack the body-time read to avoid `[STRICT_READ_UNTRACKED]`. const initialStore = untrack(resolveStore); const { dataRef } = initialStore.context; // Untrack the body-time destructure: parents may pass signal-derived // values via JSX, making each `props.foo` access fire // `[STRICT_READ_UNTRACKED]` under the strict-read scope set up by // `devComponent(...)`. These fields are not `MaybeAccessor` shapes — // they're snapshot values — so a one-shot read matches the React // fork's semantics. const { outsidePress: outsidePressProp = true, outsidePressEvent = "sloppy", referencePress = alwaysFalse, referencePressEvent = "sloppy", externalTree, } = untrack(() => ({ outsidePress: props.outsidePress, outsidePressEvent: props.outsidePressEvent, referencePress: props.referencePress, referencePressEvent: props.referencePressEvent, externalTree: props.externalTree, })); const getEnabled = (): boolean => props.enabled === undefined ? true : readOption(props.enabled); const getEscapeKey = (): boolean => props.escapeKey === undefined ? true : readOption(props.escapeKey); const tree = useFloatingTree(externalTree); // React: `useStableCallback(typeof fn === 'function' ? fn : () => false)`. // We mirror by wrapping in a stable callback that routes to the latest // predicate (or a constant `false` for non-fn values). const outsidePressFn = createStableCallback< (event: MouseEvent | TouchEvent) => boolean >(() => typeof outsidePressProp === "function" ? outsidePressProp : alwaysFalse, ); const outsidePress: boolean | ((event: MouseEvent | TouchEvent) => boolean) = typeof outsidePressProp === "function" ? outsidePressFn : outsidePressProp; const outsidePressEnabled = outsidePress !== false; const getOutsidePressEventProp = createStableCallback< () => | PressType | { mouse: PressType; touch: PressType } | (() => PressType | { mouse: PressType; touch: PressType }) >(() => () => outsidePressEvent); // State machine refs (React `useRef(false)` equivalents). const pressStartedInsideRef: MutableRef = { current: false }; const pressStartPreventedRef: MutableRef = { current: false }; // Ignore only the very next outside click after dragging from inside to outside. const suppressNextOutsideClickRef: MutableRef = { current: false }; // Lazy readers for bubbles — each call re-reads the reactive source // so flips (e.g. closeParentOnEsc in menu submenus) take // effect without recomposing the consumer. const getBubblesPair = (): { escapeKey: boolean; outsidePress: boolean } => normalizeProp(props.bubbles); const getEscapeKeyBubbles = (): boolean => getBubblesPair().escapeKey; const touchStateRef: MutableRef<{ startTime: number; startX: number; startY: number; dismissOnTouchEnd: boolean; dismissOnMouseDown: boolean; } | null> = { current: null }; const cancelDismissOnEndTimeout = useTimeout(); const clearInsideReactTreeTimeout = useTimeout(); const clearInsideReactTree = createStableCallback<() => void>(() => () => { clearInsideReactTreeTimeout.clear(); dataRef.current.insideReactTree = false; }); const isComposingRef: MutableRef = { current: false }; const currentPointerTypeRef: MutableRef = { current: "" }; const isReferencePressEnabled = createStableCallback<() => boolean>( () => referencePress, ); const closeOnEscapeKeyDown = createStableCallback< (event: KeyboardEvent) => void >(() => (event: KeyboardEvent) => { const liveStore = resolveStore(); if ( !liveStore.state.open || !getEnabled() || !getEscapeKey() || event.key !== "Escape" ) { return; } // Wait until IME is settled. Pressing `Escape` while composing should // close the compose menu, but not the floating element. if (isComposingRef.current) { return; } const liveDataRef = liveStore.context.dataRef; const nodeId = liveDataRef.current.floatingContext?.nodeId as | string | undefined; const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; const escapeKeyBubbles = getEscapeKeyBubbles(); if (!escapeKeyBubbles) { if (children.length > 0) { let shouldDismiss = true; for (const child of children) { if ( child.context?.open && !child.context.dataRef.current.__escapeKeyBubbles ) { shouldDismiss = false; } } if (!shouldDismiss) { return; } } } const eventDetails = createChangeEventDetails(REASONS.escapeKey, event); liveStore.setOpen(false, eventDetails); if (!escapeKeyBubbles && !eventDetails.isPropagationAllowed) { event.stopPropagation(); } }); const markInsideReactTree = createStableCallback<() => void>(() => () => { dataRef.current.insideReactTree = true; clearInsideReactTreeTimeout.start(0, clearInsideReactTree); }); // Main effect: install document listeners when `open && enabled`. // React uses `useEffect` with a big dep array; Solid's `createEffect` // re-runs whenever any of the tracked reads below change. We read // `store.useState('open')` through the reactive accessor so the // effect re-runs on open toggles. createEffect( () => { // Track the resolved store so a context swap re-installs // listeners on the new one. `useState` tracks open / // floating so open flips also re-run. const liveStore = resolveStore(); // Track bubbles so the dataRef latch is rewritten when the // underlying accessor flips (solidify.md P3). const bubbles = getBubblesPair(); return { liveStore, open: liveStore.state.open, floatingElement: liveStore.state.floatingElement, enabled: getEnabled(), escapeKey: getEscapeKey(), escapeKeyBubbles: bubbles.escapeKey, outsidePressBubbles: bubbles.outsidePress, }; }, (snapshot) => { const store = snapshot.liveStore; const dataRef = store.context.dataRef; const { open, enabled, escapeKey, escapeKeyBubbles, outsidePressBubbles, } = snapshot; if (!open || !enabled) { return; } dataRef.current.__escapeKeyBubbles = escapeKeyBubbles; dataRef.current.__outsidePressBubbles = outsidePressBubbles; const compositionTimeout = new Timeout(); const preventedPressSuppressionTimeout = new Timeout(); function handleCompositionStart() { compositionTimeout.clear(); isComposingRef.current = true; } function handleCompositionEnd() { // Safari fires `compositionend` before `keydown`, so we need to // wait until the next tick to set `isComposing` to `false`. // https://bugs.webkit.org/show_bug.cgi?id=165004 compositionTimeout.start( // 0 ms / 1 ms don't work in Safari. 5 ms is consistent. isWebKit() ? 5 : 0, () => { isComposingRef.current = false; }, ); } function suppressImmediateOutsideClickAfterPreventedStart() { suppressNextOutsideClickRef.current = true; // Firefox can emit the synthetic outside click in a later task // after pointer-lock exit, so microtask clearing is too early. preventedPressSuppressionTimeout.start(0, () => { suppressNextOutsideClickRef.current = false; }); } function resetPressStartState() { pressStartedInsideRef.current = false; pressStartPreventedRef.current = false; } function getOutsidePressEvent(): PressType { const type = currentPointerTypeRef.current as | "pen" | "mouse" | "touch" | ""; const computedType = type === "pen" || !type ? "mouse" : type; const outsidePressEventValue = getOutsidePressEventProp(); const resolved = typeof outsidePressEventValue === "function" ? outsidePressEventValue() : outsidePressEventValue; if (typeof resolved === "string") { return resolved; } return resolved[computedType]; } function shouldIgnoreEvent(event: Event): boolean { const computedOutsidePressEvent = getOutsidePressEvent(); return ( (computedOutsidePressEvent === "intentional" && event.type !== "click") || (computedOutsidePressEvent === "sloppy" && event.type === "click") ); } function isEventWithinFloatingTree(event: Event): boolean { const nodeId = dataRef.current.floatingContext?.nodeId as | string | undefined; const targetIsInsideChildren = !!tree && getNodeChildren(tree.nodesRef.current, nodeId).some((node) => isEventTargetWithin(event, node.context?.elements.floating), ); return ( isEventTargetWithin(event, store.state.floatingElement) || isEventTargetWithin(event, store.state.domReferenceElement) || targetIsInsideChildren || // Presses inside popups portaled out of our DOM subtree but // logically nested under us (a Select popup inside a Dialog, // say) are not outside presses. React covers this via portal // synthetic-event propagation (`insideReactTree`); Solid // events follow the DOM tree, so we walk the logical portal // ancestry instead. See `isInsideNestedPortal`. isInsideNestedPortal(getTarget(event), store.state.floatingElement) ); } // A press landed on a third-party element injected (e.g. a // `[data-inert]` overlay) after the floating element rendered, so // none of the recorded markers sit on the target's ancestor chain. // Walks the target up to its last-traversable ancestor and checks // that against the marker set. function isInjectedThirdPartyPress( target: EventTarget | null, markers: Element[], floatingElement: Element | null, ): boolean { if ( !markers.length || !isElement(target) || isRootElement(target as Element) || // Clicked on a direct ancestor (e.g. FloatingOverlay). contains(target as Element, floatingElement) ) { return false; } let ancestor: Element | null = target as Element; while (ancestor && !isLastTraversableNode(ancestor)) { const nextParent = getParentNode(ancestor); if (isLastTraversableNode(nextParent) || !isElement(nextParent)) { break; } ancestor = nextParent as Element; } // If the target root contains none of the markers, the element // was injected after the floating element rendered. return markers.every( (marker) => !contains(ancestor, marker as Element), ); } // A press on the target element's scrollbar gutter (rather than its // content). Skipped for touch — scrollbars don't receive touch // events on most platforms. function isPressOnScrollbar( event: MouseEvent | PointerEvent | TouchEvent, target: EventTarget | null, ): boolean { if (!isHTMLElement(target) || "touches" in event) { return false; } const el = target as HTMLElement; const lastTraversableNode = isLastTraversableNode(el); const style = getComputedStyle(el); const scrollRe = /auto|scroll/; const isScrollableX = lastTraversableNode || scrollRe.test(style.overflowX); const isScrollableY = lastTraversableNode || scrollRe.test(style.overflowY); const canScrollX = isScrollableX && el.clientWidth > 0 && el.scrollWidth > el.clientWidth; const canScrollY = isScrollableY && el.clientHeight > 0 && el.scrollHeight > el.clientHeight; const isRTL = style.direction === "rtl"; const mouseEvent = event as MouseEvent; const pressedVerticalScrollbar = canScrollY && (isRTL ? mouseEvent.offsetX <= el.offsetWidth - el.clientWidth : mouseEvent.offsetX > el.clientWidth); const pressedHorizontalScrollbar = canScrollX && mouseEvent.offsetY > el.clientHeight; return pressedVerticalScrollbar || pressedHorizontalScrollbar; } function closeOnPressOutside( event: MouseEvent | PointerEvent | TouchEvent, ) { if (shouldIgnoreEvent(event)) { clearInsideReactTree(); return; } if (dataRef.current.insideReactTree) { clearInsideReactTree(); return; } const target = getTarget(event); const inertSelector = `[${createAttribute("inert")}]`; const targetRoot = isElement(target) ? (target as Element).getRootNode() : null; const markers = Array.from( (isShadowRoot(targetRoot) ? (targetRoot as ShadowRoot) : ownerDocument(store.state.floatingElement) ).querySelectorAll(inertSelector), ); const triggers = store.context.triggerElements; // If another trigger is clicked, don't close the floating element. if ( target && (triggers.hasElement(target as Element) || triggers.hasMatchingElement((trigger) => contains(trigger, target as Element), )) ) { return; } // Check if the click occurred on a third-party element injected // after the floating element rendered. if ( isInjectedThirdPartyPress( target, markers, store.state.floatingElement, ) ) { return; } // Check if the click occurred on the scrollbar. if (isPressOnScrollbar(event, target)) { return; } if (isEventWithinFloatingTree(event)) { return; } // In intentional mode, a press that starts inside and ends // outside gets one suppressed outside click. Run this after // inside-target checks so inside clicks don't consume the // one-shot suppression. if ( getOutsidePressEvent() === "intentional" && suppressNextOutsideClickRef.current ) { preventedPressSuppressionTimeout.clear(); suppressNextOutsideClickRef.current = false; return; } if ( typeof outsidePress === "function" && !outsidePress(event as MouseEvent | TouchEvent) ) { return; } const nodeId = dataRef.current.floatingContext?.nodeId as | string | undefined; const children = tree ? getNodeChildren(tree.nodesRef.current, nodeId) : []; if (children.length > 0) { let shouldDismiss = true; for (const child of children) { if ( child.context?.open && !child.context.dataRef.current.__outsidePressBubbles ) { shouldDismiss = false; } } if (!shouldDismiss) { return; } } store.setOpen( false, createChangeEventDetails( REASONS.outsidePress, event as MouseEvent | PointerEvent | TouchEvent, ), ); clearInsideReactTree(); } function handlePointerDown(event: PointerEvent) { if ( getOutsidePressEvent() !== "sloppy" || event.pointerType === "touch" || !store.state.open || !enabled || isEventTargetWithin(event, store.state.floatingElement) || isEventTargetWithin(event, store.state.domReferenceElement) ) { return; } closeOnPressOutside(event); } function handleTouchStart(event: TouchEvent) { if ( getOutsidePressEvent() !== "sloppy" || !store.state.open || !enabled || isEventTargetWithin(event, store.state.floatingElement) || isEventTargetWithin(event, store.state.domReferenceElement) ) { return; } const touch = event.touches[0]; if (touch) { touchStateRef.current = { startTime: Date.now(), startX: touch.clientX, startY: touch.clientY, dismissOnTouchEnd: false, dismissOnMouseDown: true, }; cancelDismissOnEndTimeout.start(1000, () => { if (touchStateRef.current) { touchStateRef.current.dismissOnTouchEnd = false; touchStateRef.current.dismissOnMouseDown = false; } }); } } function addTargetEventListenerOnce( event: EventType, listener: (event: EventType) => void, ) { const target = getTarget(event); if (!target) { return; } const handler = () => { listener(event); (target as EventTarget).removeEventListener(event.type, handler); }; (target as EventTarget).addEventListener(event.type, handler); } function handleTouchStartCapture(event: TouchEvent) { currentPointerTypeRef.current = "touch"; addTargetEventListenerOnce(event, handleTouchStart); } function closeOnPressOutsideCapture(event: PointerEvent | MouseEvent) { cancelDismissOnEndTimeout.clear(); if (event.type === "pointerdown") { currentPointerTypeRef.current = (event as PointerEvent).pointerType; } if ( event.type === "mousedown" && touchStateRef.current && !touchStateRef.current.dismissOnMouseDown ) { return; } addTargetEventListenerOnce(event, (targetEvent) => { if (targetEvent.type === "pointerdown") { handlePointerDown(targetEvent as PointerEvent); } else { closeOnPressOutside(targetEvent as MouseEvent); } }); } function handlePressEndCapture(event: PointerEvent | MouseEvent) { if (!pressStartedInsideRef.current) { return; } const pressStartedInsideDefaultPrevented = pressStartPreventedRef.current; resetPressStartState(); if (getOutsidePressEvent() !== "intentional") { return; } if (event.type === "pointercancel") { if (pressStartedInsideDefaultPrevented) { suppressImmediateOutsideClickAfterPreventedStart(); } return; } if (isEventWithinFloatingTree(event)) { return; } // If pointerdown was prevented, no click may be generated for // that interaction. However, Firefox may still emit an // immediate click after pointerup (e.g. NumberField scrub with // pointer lock), so suppress for one tick. if (pressStartedInsideDefaultPrevented) { suppressImmediateOutsideClickAfterPreventedStart(); return; } // Avoid suppressing when outsidePress explicitly ignores this // target. if ( typeof outsidePress === "function" && !outsidePress(event as MouseEvent) ) { return; } preventedPressSuppressionTimeout.clear(); suppressNextOutsideClickRef.current = true; clearInsideReactTree(); } function handleTouchMove(event: TouchEvent) { if ( getOutsidePressEvent() !== "sloppy" || !touchStateRef.current || isEventTargetWithin(event, store.state.floatingElement) || isEventTargetWithin(event, store.state.domReferenceElement) ) { return; } const touch = event.touches[0]; if (!touch) { return; } const deltaX = Math.abs(touch.clientX - touchStateRef.current.startX); const deltaY = Math.abs(touch.clientY - touchStateRef.current.startY); const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (distance > 5) { touchStateRef.current.dismissOnTouchEnd = true; } if (distance > 10) { closeOnPressOutside(event); cancelDismissOnEndTimeout.clear(); touchStateRef.current = null; } } function handleTouchMoveCapture(event: TouchEvent) { addTargetEventListenerOnce(event, handleTouchMove); } function handleTouchEnd(event: TouchEvent) { if ( getOutsidePressEvent() !== "sloppy" || !touchStateRef.current || isEventTargetWithin(event, store.state.floatingElement) || isEventTargetWithin(event, store.state.domReferenceElement) ) { return; } if (touchStateRef.current.dismissOnTouchEnd) { closeOnPressOutside(event); } cancelDismissOnEndTimeout.clear(); touchStateRef.current = null; } function handleTouchEndCapture(event: TouchEvent) { addTargetEventListenerOnce(event, handleTouchEnd); } // Solid analogue of React's synthetic capture propagation across // portals for the `insideReactTree` latch. The floating bag's // `on*Capture: markInsideReactTree` handlers (attached to the // floating element) only fire for DOM descendants; in React they // also fire for presses inside popups PORTALED out of the DOM // subtree but logically nested under us, because synthetic events // climb the component tree across portals. Without this, a press // that starts on a nested trigger and ends inside the nested // popup fires its `click` on the nearest common DOM ancestor — // our own portal wrapper — which fails every within-tree check // and dismisses the ancestor overlay (Select inside a Sheet: // press trigger, release over an item, sheet closes). function markNestedPortalPressCapture(event: Event) { if ( isInsideNestedPortal(getTarget(event), store.state.floatingElement) ) { markInsideReactTree(); } } // Same press-phase events the floating bag latches on. const nestedPortalLatchEvents = [ "click", "mousedown", "pointerdown", "mouseup", "touchend", "touchmove", ] as const; const doc = ownerDocument(snapshot.floatingElement); const cleanups: Array<() => void> = []; if (escapeKey) { doc.addEventListener("keydown", closeOnEscapeKeyDown); doc.addEventListener("compositionstart", handleCompositionStart); doc.addEventListener("compositionend", handleCompositionEnd); cleanups.push(() => { doc.removeEventListener("keydown", closeOnEscapeKeyDown); doc.removeEventListener("compositionstart", handleCompositionStart); doc.removeEventListener("compositionend", handleCompositionEnd); }); } if (outsidePressEnabled) { for (const type of nestedPortalLatchEvents) { doc.addEventListener(type, markNestedPortalPressCapture, true); } cleanups.push(() => { for (const type of nestedPortalLatchEvents) { doc.removeEventListener(type, markNestedPortalPressCapture, true); } }); doc.addEventListener( "click", closeOnPressOutsideCapture as EventListener, true, ); doc.addEventListener( "pointerdown", closeOnPressOutsideCapture as EventListener, true, ); doc.addEventListener( "pointerup", handlePressEndCapture as EventListener, true, ); doc.addEventListener( "pointercancel", handlePressEndCapture as EventListener, true, ); doc.addEventListener( "mousedown", closeOnPressOutsideCapture as EventListener, true, ); doc.addEventListener( "mouseup", handlePressEndCapture as EventListener, true, ); doc.addEventListener( "touchstart", handleTouchStartCapture as EventListener, true, ); doc.addEventListener( "touchmove", handleTouchMoveCapture as EventListener, true, ); doc.addEventListener( "touchend", handleTouchEndCapture as EventListener, true, ); cleanups.push(() => { doc.removeEventListener( "click", closeOnPressOutsideCapture as EventListener, true, ); doc.removeEventListener( "pointerdown", closeOnPressOutsideCapture as EventListener, true, ); doc.removeEventListener( "pointerup", handlePressEndCapture as EventListener, true, ); doc.removeEventListener( "pointercancel", handlePressEndCapture as EventListener, true, ); doc.removeEventListener( "mousedown", closeOnPressOutsideCapture as EventListener, true, ); doc.removeEventListener( "mouseup", handlePressEndCapture as EventListener, true, ); doc.removeEventListener( "touchstart", handleTouchStartCapture as EventListener, true, ); doc.removeEventListener( "touchmove", handleTouchMoveCapture as EventListener, true, ); doc.removeEventListener( "touchend", handleTouchEndCapture as EventListener, true, ); }); } return () => { for (const cleanup of cleanups) { cleanup(); } compositionTimeout.clear(); preventedPressSuppressionTimeout.clear(); resetPressStartState(); suppressNextOutsideClickRef.current = false; }; }, ); // React runs `useEffect(clearInsideReactTree, [outsidePress, clearInsideReactTree])` // to reset the latch whenever `outsidePress` identity changes. In Solid, // the effect fires once per mount — `outsidePress` identity is stable // for the lifetime of the hook instance (we wrap fn form in a stable // callback), so a single reset suffices. createEffect( () => undefined, () => { clearInsideReactTree(); }, ); // Build reference bag: bubble-phase handler keyed by press type, plus // an always-on `onKeyDown` for escape. When press type is not // `intentional`, also wire `onClick` so keyboard activation still // dismisses. function buildReference(): ElementPropsBag { const handlePress = (event: PointerEvent | MouseEvent) => { if (!isReferencePressEnabled() || !getEnabled()) { return; } resolveStore().setOpen( false, createChangeEventDetails( REASONS.triggerPress, event, ) as BaseUIChangeEventDetails, ); }; const bag: ElementPropsBag = { onKeyDown: closeOnEscapeKeyDown, }; bag[bubbleHandlerKeys[referencePressEvent]] = handlePress; if (referencePressEvent !== "intentional") { bag.onClick = (event: MouseEvent) => { if (!isReferencePressEnabled() || !getEnabled()) { return; } resolveStore().setOpen( false, createChangeEventDetails( REASONS.triggerPress, event, ) as BaseUIChangeEventDetails, ); }; } return bag; } const markPressStartedInsideReactTree = createStableCallback< (event: PointerEvent | MouseEvent) => void >(() => (event: PointerEvent | MouseEvent) => { const liveStore = resolveStore(); if (!liveStore.state.open || !getEnabled() || event.button !== 0) { return; } const target = getTarget(event) as Element | null; // Only treat presses that start within the floating DOM subtree as // inside. This avoids suppressing parent dismissal when interacting // with nested portals. if (!contains(liveStore.state.floatingElement, target)) { return; } if (!pressStartedInsideRef.current) { pressStartedInsideRef.current = true; pressStartPreventedRef.current = false; } }); const markInsidePressStartPrevented = createStableCallback< (event: PointerEvent | MouseEvent) => void >(() => (event: PointerEvent | MouseEvent) => { const liveStore = resolveStore(); if (!liveStore.state.open || !getEnabled()) { return; } if (!event.defaultPrevented) { return; } if (pressStartedInsideRef.current) { pressStartPreventedRef.current = true; } }); const floating: ElementPropsBag = { onKeyDown: closeOnEscapeKeyDown, // `onMouseDown` may be blocked if `event.preventDefault()` is // called in `onPointerDown`, such as with ``. onPointerDown: markInsidePressStartPrevented, onMouseDown: markInsidePressStartPrevented, onClickCapture: markInsideReactTree, onMouseDownCapture(event: MouseEvent) { markInsideReactTree(); markPressStartedInsideReactTree(event); }, onPointerDownCapture(event: PointerEvent) { markInsideReactTree(); markPressStartedInsideReactTree(event); }, onMouseUpCapture: markInsideReactTree, onTouchEndCapture: markInsideReactTree, onTouchMoveCapture: markInsideReactTree, }; // When `enabled` is a literal `false`, mirror React and return an // empty bag. When it's an accessor, always return the bag so handlers // can reactively short-circuit via `getEnabled()`. The body-time // `props.enabled` access is wrapped in `untrack` so the strict-read // scope established by `devComponent(...)` does not warn — accessor // reads happen later inside already-tracked event handlers / effects. const isStaticallyDisabled = untrack( () => typeof props.enabled !== "function" && !getEnabled(), ); if (isStaticallyDisabled) { return {}; } const reference = buildReference(); return { reference, floating, trigger: reference }; }