// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useHoverCard.ts * @input Uses useLayer, useTouchTrigger, React hooks * @output Exports useHoverCard hook for hover/focus/tap triggered layers * @position Layer hook; builds on useLayer for hover card behavior * * SYNC: When modified, update: * - /packages/core/src/HoverCard/index.ts */ import { useCallback, useEffect, useRef, type ReactNode, type RefCallback } from 'react'; import * as stylex from "../style-runtime.js"; import { useLayer, type ContextRenderProps, type LayerAlignment, type LayerPlacement } from "../Layer/useLayer.js"; import { useTouchTrigger, type LayerTouchTrigger } from "../Layer/useTouchTrigger.js"; import { layerAnimations } from "../Layer/layerAnimations.stylex.js"; import { useLayerDismissal } from "../Layer/useLayerDismissal.js"; import "../theme/tokens.stylex.js"; import { colorVars, shadowVars, radiusVars, spacingVars } from "../theme/tokens.stylex.js"; import { themeProps } from "../utils/themeProps.js"; const styles = { container: { kWkggS: "x10xzikg", "--_hovercard-radius": "xqwz7mz", kaIpWk: "x7a53xk", kGVxlE: "x14hfi27", $$css: true as const } }; /** * Focus trigger behavior for hover cards */ export type HoverCardFocusTrigger = 'auto' | 'always' | 'never'; /** * Touch trigger behavior for hover cards */ export type HoverCardTouchTrigger = LayerTouchTrigger; export interface HoverCardOptions { /** * Position placement relative to anchor * @default 'above' */ placement?: LayerPlacement; /** * Alignment along the placement axis * @default 'center' */ alignment?: LayerAlignment; /** * Delay before showing on hover (ms) * @default 300 */ delay?: number; /** * Delay before hiding after mouse/focus leave (ms) * @default 200 */ hideDelay?: number; /** * When to trigger on focus: * - `auto`: Only if element is naturally focusable * - `always`: Always attach focus listeners * - `never`: Never attach focus listeners (for composite widgets) * * @default 'auto' */ focusTrigger?: HoverCardFocusTrigger; /** * What a tap does on a touch pointer, where there is no hover: * - `auto`: tap opens the card, unless the trigger performs an action of its * own (a button, a link, a form control) — that tap belongs to the control * - `tap`: tap always opens the card, even on a trigger that acts * - `none`: touch never opens the card * * @default 'auto' */ touchTrigger?: HoverCardTouchTrigger; /** * Whether the hover card is enabled. * When false, hover/focus triggers are disabled. * * @default true */ isEnabled?: boolean; /** * Accessible name for the hover card popup. * * When provided, the popup is exposed to assistive technology as a named * `role="dialog"`. When omitted, the popup falls back to `role="group"` — * a group may validly be unnamed, an unnamed dialog may not. */ label?: string; /** * Controlled open state. When provided, overrides hover/focus triggers: * - `true`: force-show the hover card (hover/focus hide is suppressed) * - `false`: force-hide the hover card * - `undefined`: uncontrolled — hover/focus triggers manage visibility * * A controlled hover card still takes Escape when it is the top-most layer, * and answers by calling `onHide` without hiding itself — closing is your * update's decision, exactly as for a controlled Dialog. Ignore the call and * the card stays, and so does the press: nothing underneath dismisses. */ isOpen?: boolean; /** * Whether the hover card should be shown on mount. * The hover card is still dismissible — this just opens it initially. */ isDefaultOpen?: boolean; /** * Callback fired when hover card is shown. * Wrap in useCallback for stable identity. */ onShow?: () => void; /** * Callback fired when hover card is hidden. * Wrap in useCallback for stable identity. */ onHide?: () => void; } export interface HoverCardReturn { /** * Combined ref that sets both position and interaction on the same element. * Shorthand for calling both positionRef and interactionRef. */ ref: RefCallback; /** * Ref for the positioning anchor element. * Injects anchorName style for CSS anchor positioning. */ positionRef: RefCallback; /** * Ref for the interaction element. * Attaches hover/focus event listeners via addEventListener. * Can be the same element as positionRef or different. */ interactionRef: RefCallback; /** * The CSS anchor name to use for positioning. * Use this when you need to set anchorName manually (e.g., display:contents wrapper). */ anchorId: string; /** * Unique ID for the hover card container. * Useful for `aria-controls` or `aria-owns` on the trigger. */ id: string; /** * Deprecated alias of `id`. Kept for backwards compatibility; prefer `id`. */ describedBy: string; /** * Whether the hover card is currently open. * Useful for driving `aria-expanded` on the trigger — but only when the * trigger's role permits it (`button`, `link`, `combobox`, …). On a role-less * trigger `aria-expanded` is invalid; use `aria-haspopup`/`aria-controls` * alone there. */ isOpen: boolean; /** * Render function for hover card content. * Returns anchor-positioned popover element. * * `positioning` is excluded: the hover card always derives its position * from placement/alignment, so accepting the custom opt-out here would be * a silent no-op. */ renderHoverCard: (children: ReactNode, props?: Omit) => ReactNode; /** * Imperatively show the hover card (bypassing hover delay). */ show: () => void; /** * Imperatively hide the hover card. */ hide: () => void; } /** * Check if an element is naturally focusable */ function isFocusable(element: HTMLElement): boolean { // Elements with explicit tabindex if (element.hasAttribute('tabindex')) { return element.tabIndex >= 0; } // Naturally focusable elements const focusableTags = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; if (focusableTags.includes(element.tagName)) { return !(element as HTMLButtonElement).disabled; } // Elements with contenteditable if (element.isContentEditable) { return true; } return false; } /** * Hook for hover card behavior with hover/focus triggers. * * Builds on useLayer to add: * - Hover triggers with configurable delay * - Focus triggers with auto-detection for focusable elements * - Stay-open behavior when mouse/focus moves into the hover card * * @example * ``` * const hoverCard = useHoverCard({ placement: 'above' }); * * {hoverCard.renderHoverCard()} * ``` */ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn { const { placement = 'above', alignment = 'center', delay = 300, hideDelay = 200, focusTrigger = 'auto', touchTrigger = 'auto', isEnabled = true, label, isOpen, isDefaultOpen = false, onShow, onHide } = options; const layer = useLayer({ mode: 'context', lazyMount: true, onShow, onHide }); const popoverXstyle = styles.container; const showTimeoutRef = useRef | null>(null); const hideTimeoutRef = useRef | null>(null); const triggerRef = useRef(null); const isHoveringContentRef = useRef(false); // Track when we're dismissing via Escape to prevent re-show on refocus const isEscapeDismissingRef = useRef(false); // Clear all timeouts const clearTimeouts = useCallback(() => { if (showTimeoutRef.current) { clearTimeout(showTimeoutRef.current); showTimeoutRef.current = null; } if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } }, []); // Touch resolves immediately: the hover delays exist to filter out a pointer // passing across the trigger, and a tap is never that. const showNow = useCallback(() => { clearTimeouts(); layer.show(); }, [clearTimeouts, layer]); const hideNow = useCallback(() => { clearTimeouts(); isHoveringContentRef.current = false; layer.hide(); }, [clearTimeouts, layer]); const touch = useTouchTrigger({ touchTrigger, isEnabled, isControlled: isOpen !== undefined, isOpen: layer.isOpen, layerId: layer.id, triggerRef, show: showNow, hide: hideNow }); // Schedule show with delay (suppressed when isOpen is false) const scheduleShow = useCallback(() => { if (!isEnabled || isOpen === false) { return; } clearTimeouts(); showTimeoutRef.current = setTimeout(() => { layer.show(); }, delay); }, [isEnabled, isOpen, clearTimeouts, layer, delay]); // Schedule hide with delay (suppressed when isOpen is true) const scheduleHide = useCallback(() => { if (isOpen === true) { return; } clearTimeouts(); hideTimeoutRef.current = setTimeout(() => { // Don't hide if hovering content if (!isHoveringContentRef.current) { layer.hide(); } }, hideDelay); }, [isOpen, clearTimeouts, layer, hideDelay]); // Event handlers const handleMouseEnter = useCallback(() => { // A tap synthesizes mouseenter. On touch the tap path owns the decision, // so hover must not also fire — without this a hover card opens on every // tap of its trigger and has nothing to close it. if (touch.isTouchPointerRef.current) { return; } scheduleShow(); }, [touch, scheduleShow]); const handleMouseLeave = useCallback(() => { // On touch the synthesized mouseleave arrives with the next tap elsewhere, // which the outside-tap dismissal already handles. if (touch.isTouchPointerRef.current) { return; } scheduleHide(); }, [touch, scheduleHide]); // Tap-to-open on touch; on a mouse this does nothing and hover still rules. const handlePointerDown = useCallback((event: PointerEvent) => { touch.handlePointerDown(event); }, [touch]); const handleFocusIn = useCallback(() => { if (!isEnabled) { return; } // A tap focuses the trigger it activates. Opening on that focus would // reinstate exactly the behavior the touch path just decided against — // and on an action trigger it covers the thing the user tapped. if (touch.isTouchInteraction()) { return; } // Skip showing if we're in the middle of an Escape dismiss if (isEscapeDismissingRef.current) { isEscapeDismissingRef.current = false; return; } clearTimeouts(); layer.show(); }, [isEnabled, touch, clearTimeouts, layer]); const handleFocusOut = useCallback((e: FocusEvent) => { // Check if focus is moving to the hover card content const relatedTarget = e.relatedTarget as HTMLElement | null; const popoverElement = document.getElementById(layer.id); if (popoverElement?.contains(relatedTarget)) { // Focus moving into hover card, keep it open return; } scheduleHide(); }, [layer.id, scheduleHide]); // Escape dismissal (WCAG 1.4.13) goes through the shared layer stack: a // visible card is the top-most layer, so it takes the press and consumes it. // // This replaces a keydown listener on the TRIGGER that called // stopPropagation() on any Escape while the trigger merely had focus — open // card or not — so focusing a HoverCard trigger inside a Dialog silently ate // the press that should have closed the Dialog. Presence is now answered from // the DOM, so a closed card never claims a press. // A controlled hover card stays on the stack and takes the press like any // other layer, but answers it by reporting instead of hiding: `isOpen` is // the consumer's value, so only their update may change it. Same contract as // a controlled Dialog. useLayerDismissal({ // Registered for the hook's lifetime rather than gated on `layer.isOpen`: // that state can lag a frame behind the DOM, so a press arriving right after // the layer appears would find nothing registered. Because this layer // CONSUMES the press, a stale registration would be worse than a missed one // — it would silently eat Escapes meant for the dialog underneath — so // presence is answered from the DOM at press time instead of from state. isActive: true, isPresent: () => { const el = typeof document === 'undefined' ? null : document.getElementById(layer.id); if (el == null) { return false; } try { return el.matches(':popover-open'); } catch { // Browsers without the Popover API (and some test environments) cannot // answer the selector; fall back to the hook's own state. return layer.isOpen; } }, onDismiss: () => { clearTimeouts(); touch.clearTapOpen(); // Controlled: report and stop. The close — and the focus restore that // goes with it — happens in the controlled effect if and when the // consumer flips `isOpen`. if (isOpen !== undefined) { onHide?.(); return; } // Only when the card itself held focus, which is the one case the // content-level handler this replaced could run in. Refocusing // unconditionally would drag the caret out of a field the user was // typing in while a hover card happened to be up; arming the re-show // guard unconditionally would swallow their next focus on the trigger, // because a focus() on the already-focused trigger fires no focusin to // clear it. const card = typeof document === 'undefined' ? null : document.getElementById(layer.id); const hadFocus = card?.contains(document.activeElement) ?? false; layer.hide(); if (hadFocus) { isEscapeDismissingRef.current = true; triggerRef.current?.focus(); } } }); // Interaction ref that handles event listeners only const { handlePointerEnter } = touch; const interactionRef: RefCallback = useCallback((el: HTMLElement | null) => { // Cleanup previous element if (triggerRef.current) { triggerRef.current.removeEventListener('mouseenter', handleMouseEnter); triggerRef.current.removeEventListener('mouseleave', handleMouseLeave); triggerRef.current.removeEventListener('focusin', handleFocusIn); triggerRef.current.removeEventListener('focusout', handleFocusOut as EventListener); triggerRef.current.removeEventListener('pointerenter', handlePointerEnter); triggerRef.current.removeEventListener('pointerdown', handlePointerDown); } if (el) { // Attach hover listeners. `pointerenter` runs before the synthesized // `mouseenter` a tap produces, which is what lets the hover path know // it is looking at a finger. el.addEventListener('pointerenter', handlePointerEnter); el.addEventListener('mouseenter', handleMouseEnter); el.addEventListener('mouseleave', handleMouseLeave); el.addEventListener('pointerdown', handlePointerDown); // Attach focus listeners based on focusTrigger option const shouldAttachFocus = focusTrigger === 'always' || focusTrigger === 'auto' && isFocusable(el); if (shouldAttachFocus) { el.addEventListener('focusin', handleFocusIn); el.addEventListener('focusout', handleFocusOut as EventListener); } } triggerRef.current = el; }, [focusTrigger, handleMouseEnter, handleMouseLeave, handleFocusIn, handleFocusOut, handlePointerEnter, handlePointerDown]); // Combined ref - shorthand for calling both positionRef and interactionRef const ref: RefCallback = useCallback((el: HTMLElement | null) => { layer.ref(el); interactionRef(el); }, [layer, interactionRef]); // Cleanup on unmount useEffect(() => { return () => { clearTimeouts(); }; }, [clearTimeouts]); // Show on mount when isDefaultOpen is true useEffect(() => { if (isDefaultOpen) { layer.show(); } // eslint-disable-next-line @eslint-react/exhaustive-deps -- intentionally only on mount }, []); // Controlled open state — overrides hover/focus triggers useEffect(() => { if (isOpen === undefined) { return; } if (isOpen) { clearTimeouts(); layer.show(); } else { clearTimeouts(); // A consumer closing the card while it holds focus would strand focus on // . Arming the re-show guard is what keeps the refocus from // reopening the card through `handleFocusIn`, which shows on any focusin. const card = typeof document === 'undefined' ? null : document.getElementById(layer.id); const hadFocus = card?.contains(document.activeElement) ?? false; layer.hide(); if (hadFocus) { isEscapeDismissingRef.current = true; triggerRef.current?.focus(); } } }, [isOpen, clearTimeouts, layer]); // Render function that wraps layer.render with hover card behavior const renderHoverCard = useCallback((children: ReactNode, props?: Omit): ReactNode => { const renderPlacement = props?.placement ?? placement; const themeClassName = themeProps('hover-card', undefined, { // `hovercard` ran the compound name together; keep it emitted so // existing themes continue to work. legacyNames: ['hovercard'] }).className; const renderProps = { placement: renderPlacement, alignment: props?.alignment ?? alignment, offset: spacingVars['--spacing-1'], // A named dialog when a label is provided; otherwise a group. A group // may validly be unnamed, an unnamed dialog may not — and hover cards // are non-modal, so group is honest semantics without a name. role: label ? 'dialog' : 'group', 'aria-label': label || undefined, // Consumer surface style props land on the layer container — the // themed surface (bg/radius/shadow) where the theme class lives — so // customizing the card targets the same element as the theme. The inner // div keeps `styles.content` for padding. xstyle: [popoverXstyle, layerAnimations[renderPlacement], props?.xstyle], className: props?.className ? `${themeClassName} ${props.className}` : themeClassName, style: props?.style, // useLayer mounts only after it has verified or corrected the parent, // so rich HoverCard content can use block-safe markup. as: 'div' as const }; return layer.render(
{ // Touch synthesizes these over the card too; letting a tap inside // register as "hovering content" would block every later hide. if (touch.isTouchPointerRef.current) { return; } isHoveringContentRef.current = true; clearTimeouts(); }} onMouseLeave={() => { if (touch.isTouchPointerRef.current) { return; } isHoveringContentRef.current = false; scheduleHide(); }} onBlur={e => { // Check if focus is moving back to the trigger or staying within content const relatedTarget = e.relatedTarget as HTMLElement | null; const popoverElement = e.currentTarget; // If focus stays within the hover card, do nothing if (popoverElement.contains(relatedTarget)) { return; } // If focus is moving back to the trigger, do nothing if (triggerRef.current?.contains(relatedTarget)) { return; } // Focus is leaving the hover card entirely scheduleHide(); }}> {children}
, renderProps); }, [layer, placement, alignment, label, clearTimeouts, scheduleHide, popoverXstyle, touch]); return { ref, positionRef: layer.ref, interactionRef, anchorId: layer.anchorId, id: layer.id, describedBy: layer.id, isOpen: layer.isOpen, renderHoverCard, show: layer.show, hide: layer.hide }; }