import type { JSX, ValidComponent } from "@solidjs/web"; /** * Solid 2 port of Base UI React's `NavigationMenuTrigger` * (`packages/react/src/navigation-menu/trigger/NavigationMenuTrigger.tsx`). * * The interactive trigger for a single navigation-menu item. Opens the * popup on hover (with the restMs delay from the root) and toggles it on * click. When this trigger's item becomes active, it also: * * - Publishes its own floating-root context into the root so list / * positioner / popup can read it. * - Tracks the popup's previous size so size transitions between items * are smooth. * - Renders focus-guard sentinels after itself so `Tab` / `Shift+Tab` * from the trigger jumps into the popup (and vice-versa). * * ### Solid deviations vs. React source * * - The elaborate popup auto-size reset state machine (MutationObserver + * resize observer + animation-frame chains that shrink-then-measure * between active items) is *ported in shape* but effectively inert in * happy-dom because `getBoundingClientRect()` returns zero for every * element. The React code survives and runs with no * measurable effect — browser coverage is required to validate real * transition behavior. * - `ReactDOM.flushSync` calls are dropped — Solid's writes settle on * microtasks (MIGRATION.md) and consumers rely on `flush()` in tests. * For the "hover-open" path we still write open state synchronously, * which is observable after `flush()` per popover.test's pattern. * - `applySafePolygonPointerEventsMutation` / pointer-events mutation * lives on the hook's shared-state; we call it in the matching * `mouseenter` path but the Solid port's `use-hover-interaction-shared-state` * already exports these as module functions, so this is ported 1:1. */ import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, createUniqueId, flush, omit, onCleanup, Show, untrack, } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { useFloatingParentNodeId, useFloatingTree, } from "../../internals/floating-ui/components/floating-tree"; import { useClick } from "../../internals/floating-ui/hooks/use-click"; import { useDismiss } from "../../internals/floating-ui/hooks/use-dismiss"; import { useFloatingRootContext } from "../../internals/floating-ui/hooks/use-floating-root-context"; import { useHoverInteractionSharedState } from "../../internals/floating-ui/hooks/use-hover-interaction-shared-state"; import { useHoverReferenceInteraction } from "../../internals/floating-ui/hooks/use-hover-reference-interaction"; import { useInteractions } from "../../internals/floating-ui/hooks/use-interactions"; import { safePolygon } from "../../internals/floating-ui/safe-polygon"; import { contains } from "../../internals/floating-ui/utils/element"; import { stopEvent } from "../../internals/floating-ui/utils/event"; import { getNextTabbable, getPreviousTabbable, getTabbableAfterElement, isOutsideEvent, } from "../../internals/floating-ui/utils/tabbable"; import { mergeRootProps } from "../../internals/merge-root-props"; import { FocusGuard } from "../../internals/popups/focus-guard"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps, NativeButtonProps, } from "../../internals/types"; import { useButton } from "../../internals/use-button/use-button"; import { VISUALLY_HIDDEN_STYLE } from "../../internals/visually-hidden-style"; import { useNavigationMenuItemContext } from "../item/navigation-menu-item-context"; import { useNavigationMenuDismissContext } from "../list/navigation-menu-dismiss-context"; import { type NavigationMenuActivationDirection, type NavigationMenuRootChangeEventReason, useNavigationMenuRootContext, useNavigationMenuTreeContext, } from "../root/navigation-menu-root-context"; import { NAVIGATION_MENU_TRIGGER_IDENTIFIER } from "../utils/constants"; import { isOutsideMenuEvent } from "../utils/is-outside-menu-event"; const PATIENT_CLICK_THRESHOLD = 500; export interface NavigationMenuTriggerState { /** Whether the popup is open AND this trigger's item is the active one. */ open: boolean; } export interface NavigationMenuTriggerProps extends NativeButtonProps, BaseUIComponentProps<"button", NavigationMenuTriggerState> { /** * Tag/component to render. Defaults to `"button"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; } export function NavigationMenuTrigger( props: NavigationMenuTriggerProps, ): JSX.Element { const root = useNavigationMenuRootContext(); const item = useNavigationMenuItemContext(); const nodeId = useNavigationMenuTreeContext(); const tree = useFloatingTree(); const dismissRef = useNavigationMenuDismissContext; const nested = useFloatingParentNodeId() != null || root.nested; const nativeButton = (): boolean => props.nativeButton ?? true; const disabled = (): boolean => Boolean(props.disabled); const [triggerElement, setTriggerElementSignal] = createSignal(null, { ownedWrite: true }); const [stickIfOpen, setStickIfOpen] = createSignal(true, { ownedWrite: true, }); const [pointerType, setPointerType] = createSignal< "mouse" | "touch" | "pen" | "" >("", { ownedWrite: true }); const triggerElementRef: { current: HTMLElement | null } = { current: null }; const allowFocusRef: { current: boolean } = { current: false }; const isActiveItem = createMemo( () => root.open() && root.value() === item.value(), ); const interactionsEnabled = (): boolean => { const positioner = root.positionerElement(); if (positioner) return true; return root.value() == null; }; const hoverFloatingElement = (): HTMLElement | null => root.positionerElement() ?? root.viewportElement(); const hoverInteractionsEnabled = (): boolean => { if (hoverFloatingElement()) return true; return root.value() == null; }; const handleTriggerElement = (element: HTMLElement | null): void => { triggerElementRef.current = element; setTriggerElementSignal(element); }; // --------------------------------------------------------------------- // Open-change handler // --------------------------------------------------------------------- // Patient-click timer: when the popup opens via hover, ignore click-to-close // for `PATIENT_CLICK_THRESHOLD` ms. let stickTimeoutId: ReturnType | undefined; onCleanup(() => { if (stickTimeoutId !== undefined) clearTimeout(stickTimeoutId); }); // Invoked from `useFloatingRootContext`'s `onOpenChange` (effect-apply // scope). Reads here are snapshots — wrap in `untrack` to silence // `[STRICT_READ_UNTRACKED]` on `interactionsEnabled()` / `pointerType()` // / `root.value()` / `item.value()`. function handleOpenChange( nextOpen: boolean, details: { reason: string; event?: Event; }, ): void { untrack(() => { const isHover = details.reason === REASONS.triggerHover; if (!interactionsEnabled()) return; if (pointerType() === "touch" && isHover) return; if (!nextOpen && root.value() !== item.value()) return; if (isHover) { setStickIfOpen(true); if (stickTimeoutId !== undefined) clearTimeout(stickTimeoutId); stickTimeoutId = setTimeout(() => { setStickIfOpen(false); }, PATIENT_CLICK_THRESHOLD); } const eventDetails = createChangeEventDetails( details.reason as NavigationMenuRootChangeEventReason, details.event as Event, ); if (nextOpen) { root.setValue(item.value(), eventDetails); } else { root.setValue(null, eventDetails); setPointerType(""); } // React uses `flushSync` on hover so `value` updates before the // floating-root `open` sync effect runs (see `createEffect` below). if (isHover) { flush(); } }); } const floatingRootContext = useFloatingRootContext({ open: untrack(() => root.open()), onOpenChange: (nextOpen, details) => handleOpenChange(nextOpen, details as { reason: string; event?: Event }), }); // Sync root-open flag + trigger/floating elements into the floating // context. `update()` no longer flushes per-writer (writes batch to the // next microtask), but the hover path above calls `flush()` synchronously, // which can drain these writes and their downstream effects within this // apply's stack while the strict-read scope is still armed. `untrack` // keeps the apply body's writes (and the `triggerElement()` snapshot read // in the second effect) from re-tripping `[STRICT_READ_UNTRACKED]`. createEffect( () => ({ // Match React: sync menu-wide `open` (value != null), not per-item // `isActiveItem`, so hover-open isn't reset before `value` updates. open: root.open(), trigger: triggerElement(), float: hoverFloatingElement(), }), ({ open, trigger, float }) => untrack(() => { floatingRootContext.update({ open, referenceElement: trigger, domReferenceElement: trigger, floatingElement: float, }); }), ); const hoverInteractionState = useHoverInteractionSharedState(floatingRootContext); // Publish this floating context to the root when this item becomes active. createEffect( () => ({ active: isActiveItem(), ctx: floatingRootContext }), ({ active, ctx }) => untrack(() => { if (active) { root.setFloatingRootContext(ctx); root.prevTriggerElementRef.current = triggerElement(); } }), ); // Clear stale openEvent / rest timers when this item stops being active. createEffect( () => root.open(), (open) => { if (!open) { floatingRootContext.context.dataRef.current.openEvent = undefined; hoverInteractionState.pointerType = undefined; hoverInteractionState.interactedInside = false; } }, ); // --------------------------------------------------------------------- // Interaction hooks // --------------------------------------------------------------------- const getScope = (): HTMLElement | SVGSVGElement | null => { if (!nested || !root.positionerElement()) { return (triggerElementRef.current?.closest("ul") ?? null) as HTMLElement | null; } return null; }; const hoverProps = useHoverReferenceInteraction(floatingRootContext, { enabled: () => hoverInteractionsEnabled(), move: false, handleClose: safePolygon({ // `safePolygon` destructures `blockPointerEvents` once at body time // (see safe-polygon.ts line 112), so a getter would only fire // `[STRICT_READ_UNTRACKED]` and never re-read on `pointerType` // changes anyway. Snapshot through `untrack`. blockPointerEvents: untrack(() => pointerType() !== "touch"), getScope, }), // `useHoverReferenceInteraction` accepts `restMs` / `delay` as // `number | (() => number)` / `Delay | (() => Delay)` and destructures // each once at body time (see use-hover-reference-interaction.ts lines // 116, 119) — passing accessors instead of getters preserves // reactivity through `getDelay()` / `getRestMs()` (which call the // function every read inside the hook's tracked effects) AND avoids // the body-time getter-read that fires `[STRICT_READ_UNTRACKED]` // from the hook's untrack() scope. restMs: () => root.mounted() && root.positionerElement() ? 0 : root.delay(), delay: () => ({ open: root.delay(), close: root.closeDelay() }), triggerElementRef, triggerElement, }); // React parity: `toggle={isActiveItem}` so only the currently-active // trigger can close by re-clicking. Reactive accessor (P3). const clickProps = useClick(floatingRootContext, { // `useClick` accepts `enabled` / `toggle` as `MaybeAccessor` // and reads them lazily through `readOption(...)` (use-click.ts lines // 113-115). Pass accessors instead of getters to keep reactivity // while sidestepping the body-time getter-read. `stickIfOpen` is // destructured once (line 105), so a getter would only fire the // body-time read — snapshot via `untrack`. enabled: () => interactionsEnabled(), stickIfOpen: untrack(() => stickIfOpen()), toggle: () => isActiveItem(), }); // Document-level Escape handler. Historically a workaround // (list's `useDismiss` installed against an undefined root context and // never re-bound). Post-P3 `useDismiss` accepts an accessor for its // context and re-installs listeners when the active root swaps, so the // list's dismiss now handles Escape too. Kept as belt-and-suspenders // against the `interactionsEnabled()` gate flip on the list (which // disables the list handler while the trigger is the active item). // `outsidePress` is disabled here — the list owns that path via its // `NAVIGATION_MENU_TRIGGER_IDENTIFIER` predicate. useDismiss(floatingRootContext, { // `useDismiss` accepts `enabled` as `MaybeAccessor` and // re-installs listeners when it changes — pass an accessor. enabled: () => isActiveItem(), escapeKey: true, outsidePress: false, }); // The dismiss props from the enclosing list context — merge them onto the // trigger so the list's escape-key handler attaches to the reference. const { getReferenceProps } = useInteractions([ hoverProps ? { reference: hoverProps } : undefined, clickProps, { reference: dismissRef()?.reference }, ]); const { props: buttonProps, ref: buttonRef } = useButton({ disabled, focusableWhenDisabled: () => true, native: nativeButton, }); function handleActivation(event: MouseEvent | KeyboardEvent): void { const prevRect = root.prevTriggerElementRef.current?.getBoundingClientRect?.(); const triggerEl = triggerElement(); if (root.mounted() && prevRect && triggerEl) { const nextRect = triggerEl.getBoundingClientRect(); const isMovingRight = nextRect.left > prevRect.left; const isMovingDown = nextRect.top > prevRect.top; const orientation = root.orientation(); let dir: NavigationMenuActivationDirection = null; if (orientation === "horizontal" && nextRect.left !== prevRect.left) { dir = isMovingRight ? "right" : "left"; } else if (orientation === "vertical" && nextRect.top !== prevRect.top) { dir = isMovingDown ? "down" : "up"; } if (dir !== null) root.setActivationDirection(dir); } // Reset `openEvent` when switching items so hover→hover back doesn't // leave stale state. if (event.type !== "click" && root.value() != null) { floatingRootContext.context.dataRef.current.openEvent = undefined; } if (pointerType() === "touch" && event.type !== "click") return; if (root.value() != null) { root.setValue( item.value(), createChangeEventDetails( event.type === "mouseenter" ? REASONS.triggerHover : REASONS.triggerPress, event, ), ); } } function handleOpenEvent(event: MouseEvent | KeyboardEvent): void { handleActivation(event); } function handleSetPointerType(event: PointerEvent): void { setPointerType(event.pointerType as "mouse" | "touch" | "pen"); } const triggerId = createUniqueId(); const defaultProps = { tabIndex: 0, onMouseEnter: handleOpenEvent as (ev: MouseEvent) => void, onClick: handleOpenEvent as (ev: MouseEvent) => void, onPointerEnter: handleSetPointerType as (ev: PointerEvent) => void, onPointerDown: handleSetPointerType as (ev: PointerEvent) => void, get "aria-expanded"() { // Return explicit string values so Solid's attribute binding doesn't // collapse `true` to an empty-string presence attr. return isActiveItem() ? "true" : "false"; }, get "aria-controls"() { // Seed with "" so the DOM spread key stays subscribed. return isActiveItem() ? (root.popupElement()?.id ?? "") : ""; }, [NAVIGATION_MENU_TRIGGER_IDENTIFIER]: "", onFocus(): void { if (!isActiveItem()) return; root.setViewportInert(false); }, onMouseMove(): void { allowFocusRef.current = false; }, onKeyDown(event: KeyboardEvent): void { allowFocusRef.current = true; if (nested) return; const orientation = root.orientation(); const openHorizontal = orientation === "horizontal" && event.key === "ArrowDown"; const openVertical = orientation === "vertical" && event.key === "ArrowRight"; if (openHorizontal || openVertical) { root.setValue( item.value(), createChangeEventDetails( REASONS.listNavigation, event, ), ); handleOpenEvent(event); stopEvent(event); } }, onBlur(event: FocusEvent): void { const positioner = root.positionerElement(); const popup = root.popupElement(); if ( positioner && popup && isOutsideMenuEvent( { currentTarget: event.currentTarget as HTMLElement | null, relatedTarget: event.relatedTarget as HTMLElement | null, }, { popupElement: popup, rootRef: root.rootRef, tree, nodeId, }, ) ) { root.setValue( null, createChangeEventDetails( REASONS.focusOut, event, ), ); } }, }; const state = createMemo(() => ({ open: isActiveItem(), })); const dataAttrs = { get "data-popup-open"() { return state().open ? "" : undefined; }, get "data-pressed"() { return state().open ? "" : undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "nativeButton", "disabled", ); let merged: Record = mergeRootProps( getReferenceProps(), defaultProps, ); merged = mergeRootProps(merged, buttonProps); merged = mergeRootProps(merged, { id: triggerId }); merged = mergeRootProps(merged, dataAttrs); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); // Primary trigger element. const Tag = dynamic(() => props.as ?? "button"); const element = ( ); // Focus guards attached when this trigger is active. return ( <> {element} { root.beforeOutsideRef.current = el; }} onFocus={(event: FocusEvent) => { const referenceElement = hoverFloatingElement(); if ( referenceElement && isOutsideEvent(event as FocusEvent, referenceElement) ) { root.beforeInsideRef.current?.focus(); } else { const prev = getPreviousTabbable(triggerElement()); prev?.focus(); } }} /> { root.afterOutsideRef.current = el; }} onFocus={(event: FocusEvent) => { const referenceElement = hoverFloatingElement(); if ( referenceElement && isOutsideEvent(event as FocusEvent, referenceElement) ) { root.setViewportInert(false); const elementToFocus = root.afterInsideRef.current ?? triggerElement(); elementToFocus?.focus(); } else { let nextTabbable = getNextTabbable(triggerElement()); if ( nested && !root.positionerElement() && referenceElement && nextTabbable && contains(referenceElement, nextTabbable) ) { nextTabbable = getTabbableAfterElement( root.afterInsideRef.current, ); } nextTabbable?.focus(); if ( (!nested || root.positionerElement()) && !contains(root.rootRef.current, nextTabbable) ) { root.setValue( null, createChangeEventDetails( REASONS.focusOut, event, ), ); } } }} /> ); }