// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file HoverCard.tsx * @input Uses React, useHoverCard hook * @output Exports HoverCard component for hover/focus triggered layers * @position Layer component; inline-safe trigger wrapper, floating layer hosted by useLayer * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/HoverCard/HoverCard.test.tsx * - /packages/core/src/HoverCard/index.ts * - /apps/storybook/stories/HoverCard.stories.tsx * - /packages/cli/assets/templates/blocks/components/HoverCard/ (showcase blocks) */ import { useCallback, useRef, type ReactElement, type ReactNode } from 'react'; import { useIsomorphicLayoutEffect } from "../hooks/useIsomorphicLayoutEffect.js"; import * as stylex from "../style-runtime.js"; import { useHoverCard, type HoverCardFocusTrigger, type HoverCardTouchTrigger } from "./useHoverCard.js"; import type { LayerAlignment, LayerPlacement } from "../Layer/useLayer.js"; import type { BaseProps } from "../BaseProps.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars } from "../theme/tokens.stylex.js"; export type { HoverCardFocusTrigger, HoverCardTouchTrigger } from "./useHoverCard.js"; // `aria-haspopup` and `aria-controls` are global, so any trigger may carry // them. `aria-expanded` is not. This is the ARIA 1.2 "Supported States and // Properties" list for it — deliberately the narrow reading: axe accepts the // attribute on rather more roles, since it also allows every subclass of these, // so a role outside this set is not necessarily a violation. Widening it is // safe; the set is a floor, not the spec's ceiling. The same rule is applied in // Chat/useTriggerMenu.tsx, which only emits the combobox attributes once the // element is actually a combobox. const EXPANDABLE_ROLES = new Set(['application', 'button', 'checkbox', 'columnheader', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'row', 'rowheader', 'switch', 'tab', 'treeitem']); const BUTTON_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'image']); // Deliberately partial, and unlisted elements are read as role-less: dropping // aria-expanded where it might have been legal costs an AT user a state they // can still infer, whereas emitting it where it is illegal is a critical // aria-allowed-attr defect. function supportsAriaExpanded(el: HTMLElement): boolean { const explicit = el.getAttribute('role')?.trim().split(/\s+/)[0]; if (explicit) { return EXPANDABLE_ROLES.has(explicit); } switch (el.tagName) { case 'BUTTON': case 'SUMMARY': return true; case 'A': case 'AREA': return el.hasAttribute('href'); case 'INPUT': // The button-flavoured input types map to role="button"; every other // type maps to a textbox-family role, which does not take it. return BUTTON_INPUT_TYPES.has((el as HTMLInputElement).type?.toLowerCase()); case 'SELECT': // combobox when it is a single-line picker, listbox otherwise; both // support aria-expanded. return true; default: return false; } } export interface HoverCardProps extends Pick { /** * The trigger element(s). Children refs are preserved. */ children: ReactNode; /** * Content to display in the hover card. */ content: ReactNode; /** * 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; /** * Callback fired when hover card visibility changes. * Called with `true` when shown and `false` when hidden. */ onOpenChange?: (isOpen: boolean) => void; /** * Whether to show hover indication (dashed underline) on the trigger. * - `'auto'`: Show for text-only children * - `true`: Always show * - `false`: Never show * * @default 'auto' */ hasHoverIndication?: 'auto' | boolean; /** * 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 `onOpenChange(false)` 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; } /** * Check if children are text-only (no React elements) */ function isTextOnly(children: ReactNode): boolean { return typeof children === 'string' || typeof children === 'number'; } /** * Utility to merge ARIA ID strings */ function mergeIds(...ids: (string | undefined | null)[]): string | undefined { const filtered = ids.filter(Boolean); return filtered.length > 0 ? filtered.join(' ') : undefined; } /** * HoverCard component for displaying interactive content on hover/focus. * * Uses a display:contents wrapper so children refs are preserved. * Uses CSS anchor positioning and the Popover API for optimal performance. * * @example * ``` * } * placement="above"> * * * ``` */ export function HoverCard({ children, content, placement = 'above', alignment = 'center', delay = 300, hideDelay = 200, focusTrigger = 'auto', touchTrigger = 'auto', isEnabled = true, label, onOpenChange, hasHoverIndication = 'auto', isOpen, isDefaultOpen, xstyle, className, style }: HoverCardProps): ReactElement { const wrapperRef = useRef(null); const textOnly = isTextOnly(children); // Determine if hover indication should be shown const showHoverIndication = hasHoverIndication === true || hasHoverIndication === 'auto' && textOnly; const handleShow = useCallback(() => { onOpenChange?.(true); }, [onOpenChange]); const handleHide = useCallback(() => { onOpenChange?.(false); }, [onOpenChange]); // Use the hook for all hover card behavior const hoverCard = useHoverCard({ placement, alignment, delay, hideDelay, focusTrigger, touchTrigger, isEnabled, label, isOpen, isDefaultOpen, onShow: handleShow, onHide: handleHide }); // For element children with display:contents, attach ref to first child useIsomorphicLayoutEffect(() => { if (textOnly) { return; } // Skip for text-only (ref is on wrapper) const wrapper = wrapperRef.current; if (!wrapper) { return; } const firstChild = wrapper.firstElementChild as HTMLElement | null; if (!firstChild) { return; } // Use combined ref for position + interaction hoverCard.ref(firstChild); if (label) { // When named, the hover card is a dialog. The trigger should advertise the // popup relationship with aria-haspopup/aria-expanded, not describe the // trigger with the dialog's content (aria-describedby is for plain-text // descriptions, not navigable regions). See #5049. // // aria-expanded only goes on a trigger whose role permits it. A role-less // trigger (Timestamp's