// biome-ignore-all lint/style/noExcessiveLinesPerFile: cross-platform hover menu — web DOM helpers, positioning math, and dual-platform render collocated in one module import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { type LayoutChangeEvent, Modal, Platform, Pressable, useWindowDimensions, View, type ViewStyle } from 'react-native'; import { useHoverCapable } from '../../../hooks/use-hover-capable'; import { useModalRender } from '../../../hooks/use-modal-render'; import { useMountEffect } from '../../../hooks/use-mount-effect'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import type { SurfaceElevation } from '../../../lib/elevated'; import { GLASS_SURFACE } from '../../../lib/glass'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { type MenuMotion, menuTransformOrigin, resolveMenuMotion } from '../../../theme/motion'; import { Surface } from '../../display/Surface/surface'; import { OverlayOutlet } from '../Overlay/overlay-portal'; import { OverlayScrim } from '../Overlay/overlay-scrim'; import type { OverlayType } from '../Overlay/overlay-type'; import type { PanelSize, Rect } from './hover-menu-geometry'; import { computePanelLayout, DEFAULT_WIDTH } from './hover-menu-geometry'; const DEFAULT_OFFSET = 4; const DEFAULT_OPEN_DELAY = 100; const DEFAULT_CLOSE_DELAY = 150; const OPENING_GUARD_MS = 1000; type TriggerRenderProps = { open: boolean; toggle: () => void }; type MenuContentRenderProps = { close: () => void }; // biome-ignore lint/style/useExportsLast: type collocated with the render-prop types above; the component export follows at the end of the file export type HoverMenuProps = { /** * The element that opens the menu. On web it opens on hover; on every platform * it toggles on press. A plain node is wrapped in a Pressable that toggles it. * Pass a function to receive `{ open, toggle }` — use this when the trigger is * itself pressable (e.g. a `Button`): wire its `onPress` to `toggle`, since the * inner pressable claims the press and the wrapper's own toggle never fires. * Hover still reaches the wrapper either way, so web hover-open is unaffected. */ trigger: ReactNode | ((props: TriggerRenderProps) => ReactNode); /** * Set when the trigger is pressable in its own right (a `Button`, a * `Pressable`). The wrapper then drops its own button semantics — role and * `aria-expanded` — so the two don't nest, which is invalid DOM on web and a * doubled-up accessibility node on native. Hover still lives on the wrapper, * so web hover-open is unaffected; wire the trigger's `onPress` to the * render prop's `toggle` to keep press working. @default false */ triggerIsPressable?: boolean; /** Content rendered inside the floating panel. As a render prop receives `{ close }`. */ children: ReactNode | ((props: MenuContentRenderProps) => ReactNode); /** Controls visibility from outside. If omitted, the menu manages its own open state. */ open?: boolean; onOpenChange?: (open: boolean) => void; triggerAccessibilityLabel?: string; /** Which edge of the trigger the panel is aligned to. @default 'start' */ align?: 'start' | 'end'; /** Panel width in pixels, or 'trigger' to use the trigger's measured width as a minimum. @default 200 */ width?: number | 'trigger'; /** Vertical gap between the trigger and the panel. @default 4 */ offset?: number; /** Hover-open delay in ms (web only). @default 100 */ openDelay?: number; /** Hover-close delay in ms (web only). @default 150 */ closeDelay?: number; contentClassName?: string; /** * Swap the panel's ladder shadow for the input field's large, diffuse halo * (`shadow-floating`). It replaces the `shadow-elevated-N` rung rather than * adding to it, so the panel keeps its `elevation` tint but trades the * layered drop for the halo. @default false */ floating?: boolean; /** Use the shared lightweight blur and glass rim on the panel. @default false */ glass?: boolean; /** Float level for the panel — picks the `shadow-elevated-N` recipe (drop + dark rim). `0` is the flat resting surface (no shadow or border). @default 3 */ elevation?: SurfaceElevation; /** * Overrides the shared open/close animation — the same `motion` prop * `AdaptiveDropdown` and `Popover` take, so a consumer can * retune every menu in an app from one object. Partial: name one field and the * rest of the preset stands. * * Reduced motion overrides all of it: the panel cross-fades in place. * * @example * motion={{ enter: { stiffness: 420 }, offset: 16 }} */ motion?: MenuMotion; testID?: string; /** * The scrim behind the panel (native only — a web hover menu has no overlay by * design, since one would cover the trigger and break hover continuity): * `"blur"`, `"opacity"`, or `"none"`. Defaults to `"none"`. */ overlay?: OverlayType; /** When false, pressing outside the panel will not close it. Defaults to true. */ closeOnOutsidePress?: boolean; /** * Fires after the menu has fully presented on native (iOS `Modal.onShow`) — the * moment it is safe to request keyboard focus on content inside it. No-op on * web, where the panel is a fixed-position element in the same document and * `autoFocus` already works. */ onShow?: () => void; }; // Minimal web-only DOM types — the RN package tsconfig omits the DOM lib, so the // browser pointer/keyboard globals aren't declared here. Mirrors the WebNode // approach in WheelPicker/SwipeableList (Reflect.get + a typeof guard, no cast). type WebNode = { contains: (node: unknown) => boolean }; type WebPointerEvent = { target: unknown }; type WebKeyEvent = { key: string }; type WebFocusEvent = { target: unknown; relatedTarget: unknown }; type WebDocument = { addEventListener: { (type: 'pointerdown', listener: (event: WebPointerEvent) => void): void; (type: 'keydown', listener: (event: WebKeyEvent) => void): void; (type: 'focusout', listener: (event: WebFocusEvent) => void): void; }; removeEventListener: { (type: 'pointerdown', listener: (event: WebPointerEvent) => void): void; (type: 'keydown', listener: (event: WebKeyEvent) => void): void; (type: 'focusout', listener: (event: WebFocusEvent) => void): void; }; }; function isWebNode(node: unknown): node is WebNode { return node !== null && typeof node === 'object' && typeof Reflect.get(node, 'contains') === 'function'; } /** True when `target` sits inside the DOM node `node` — the trigger or panel. */ function containsNode(node: unknown, target: unknown): boolean { return isWebNode(node) && node.contains(target); } function isWebDocument(value: unknown): value is WebDocument { return ( value !== null && typeof value === 'object' && typeof Reflect.get(value, 'addEventListener') === 'function' && typeof Reflect.get(value, 'removeEventListener') === 'function' ); } function getWebDocument(): WebDocument | undefined { if (Platform.OS !== 'web') return; const doc = Reflect.get(globalThis, 'document'); return isWebDocument(doc) ? doc : undefined; } const OVERLAY_STYLE = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } as const; const POSITION_ABSOLUTE = { position: 'absolute' as const }; // react-native-web maps `position: 'fixed'` to CSS fixed so the panel escapes // overflow ancestors without a portal; the value isn't in RN's LayoutPosition // union, so the web-only style is cast. (Modal can't be used on web here: its // fixed full-viewport container would cover the trigger and break hover.) // biome-ignore lint/plugin: 'fixed' is honoured by react-native-web but absent from RN's LayoutPosition union, so the web-only style is cast const WEB_PANEL_POSITION = { position: 'fixed' } as unknown as ViewStyle; const handlePanelPress = () => undefined; type UseWebOutsideDismissOptions = { enabled: boolean; triggerRef: { current: View | null }; panelRef: { current: View | null }; close: () => void; }; // Web outside-dismiss: no transparent overlay (it would cover the trigger and // break hover continuity), so listen on document for pointerdown outside the // trigger+panel, for Escape, and for focus leaving the group (blur). Native // dismisses via the overlay + hardware back (Modal `onRequestClose`). function useWebOutsideDismiss({ enabled, triggerRef, panelRef, close }: UseWebOutsideDismissOptions) { // biome-ignore lint/plugin: document-level pointerdown/keydown/focusout listeners can't be expressed as RN event handlers or derived state useEffect(() => { if (!enabled) return; const doc = getWebDocument(); if (!doc) return; const onPointerDown = (event: WebPointerEvent) => { if (containsNode(triggerRef.current, event.target) || containsNode(panelRef.current, event.target)) return; close(); }; const onKeyDown = (event: WebKeyEvent) => { if (event.key === 'Escape') close(); }; // Close on blur — but only when focus actually leaves the menu. `focusout` // bubbles and carries `relatedTarget` (the next focus target, null when focus // leaves the page), so a within-menu move (trigger → item) keeps // `relatedTarget` inside the group and is ignored; a real blur (Tab away, or // focus moving outside) closes. No timer needed. const onFocusOut = (event: WebFocusEvent) => { const targetInside = containsNode(triggerRef.current, event.target) || containsNode(panelRef.current, event.target); if (!targetInside) return; const relatedInside = containsNode(triggerRef.current, event.relatedTarget) || containsNode(panelRef.current, event.relatedTarget); if (relatedInside) return; close(); }; doc.addEventListener('pointerdown', onPointerDown); doc.addEventListener('keydown', onKeyDown); doc.addEventListener('focusout', onFocusOut); return () => { doc.removeEventListener('pointerdown', onPointerDown); doc.removeEventListener('keydown', onKeyDown); doc.removeEventListener('focusout', onFocusOut); }; }, [enabled, triggerRef, panelRef, close]); } // The wrapper only claims button semantics when it is the thing being pressed. // A trigger that is pressable in its own right already carries the role, the // label, the expanded state and the tab stop, so the wrapper steps back to a // plain hover/measure box — otherwise web gets a