// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file usePopover.tsx * @input Uses useLayer, useFocusTrap, React hooks * @output Exports the public usePopover hook with derived focus and guarded opening. * @position Higher-level layer utility; used by DatePicker, Combobox, etc. * * Combines popover layer behavior with focus trap for dialog-like popovers. * Use this for interactive popover content that should trap focus. * * SYNC: When modified, update: * - /packages/core/src/Popover/index.ts */ import React, { useCallback, useEffect, useRef, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { useLayer, type ContextRenderProps } from "../Layer/useLayer.js"; import { useFocusTrap } from "../hooks/useFocusTrap.js"; import { LayerDepthProvider } from "../Layer/LayerDepthContext.js"; import type { StyleXStyles } from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, shadowVars } from "../theme/tokens.stylex.js"; import { Button } from "../Button/index.js"; import { FOCUSABLE_SELECTOR } from "../hooks/focusableSelector.js"; import { rtlStyles } from "../utils/index.js"; import { useTranslator } from "../i18n/index.js"; import { useDevWarning } from "../hooks/useDevWarning.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { mergeProps } from "../utils/mergeProps.js"; import { themeProps } from "../utils/themeProps.js"; import { stableClassName } from "../naming.js"; const FALLBACK_CLOSE_SELECTOR = '[data-astryx-popover-fallback-close]'; function attemptFocus(element: HTMLElement): boolean { try { element.focus(); } catch { // Ignore non-focusable elements; the caller will try the next target. } return document.activeElement === element; } function focusFirstContentControl(container: HTMLElement): boolean { const focusable = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(element => element.closest(FALLBACK_CLOSE_SELECTOR) == null); for (const element of focusable) { if (attemptFocus(element)) { return true; } } return false; } const styles = { surface: { kWkggS: "x1prclbq", "--_popover-radius": "xiki222", kaIpWk: "x11m9jtl", kGVxlE: "x1i5ehqx", $$css: true as const }, contentWrapper: { kVAEAm: "x1n2onr6", $$css: true as const }, closeButtonWrapper: { kVAEAm: "x10l6tqk", krVfgx: "x1ey2m1c", kY2c9j: "x1vjfegm", kzqmXN: "x1i1rx1s x10okhzq", kZKoxP: "xjm9jq1 x132qfvm", kVQacm: "xb3r6kr x1dordxg", kz4h6p: "x1hyvwdk x10wafsz", kfzvcC: "x47corl xbt4iw", kLKAdn: "xexx8yu x1kw28su", $$css: true as const } }; /** * Options for usePopover */ export interface UsePopoverOptions { /** * Callback fired when popover is shown. * Wrap in useCallback for stable identity. */ onShow?: () => void; /** * Callback fired when popover is hidden. * Use this to return focus to the trigger element. * Wrap in useCallback for stable identity. */ onHide?: () => void; /** * StyleX styles applied to the popover's content wrapper. * Merges after the surface styles (when hasSurface is true), so these * can override background, radius, etc. * * For styles on the layer's positioned element (e.g., animations using * `:popover-open`), pass `xstyle` via the `render()` call's props instead. */ xstyle?: StyleXStyles; /** * Additional class name applied to the painted popover surface. */ className?: string; /** * Inline styles applied to the painted popover surface. */ style?: React.CSSProperties; /** * Whether clicking outside should dismiss the popover. * @default true */ hasLightDismiss?: boolean; /** * Whether pressing Escape dismisses the popover. * * Takes effect together with `hasLightDismiss: false`: with light dismiss * on, the native popover uses `popover="auto"`, whose browser-level light * dismiss also closes on Escape, so Escape handling stays registered to * keep topmost-only dismissal intact. Set both to `false` for * explicit-dismiss-only surfaces like onboarding coachmarks. * * @default true */ hasEscapeDismiss?: boolean; /** * Whether to automatically focus the first focusable element when opened. * @default true */ hasAutoFocus?: boolean; /** * Whether to include a hidden close button for accessibility. * The button appears when keyboard users tab past the last element. * @default true */ hasCloseButton?: boolean; /** * Label for the hidden close button. * @default "Close popover" */ closeButtonLabel?: string; /** * Accessible label for the dialog. * Required for screen readers to announce the dialog purpose * (only applies when `role` is `'dialog'`). */ dialogLabel?: string; /** * ARIA role stamped on the popover content wrapper. * * - `'dialog'` (default): the wrapper is a `role="dialog"` and, when * `isModal` is true, carries `aria-modal`. Use for genuine dialog content. * - `'none'`: the wrapper carries no role or `aria-modal`, so the popup's own * content role (e.g. a child `role="listbox"` or `role="menu"`) is the * exposed semantics. Use for comboboxes, listboxes, and menus — their * trigger keeps DOM focus, so announcing an unnamed modal dialog around * them is incorrect. * * @default 'dialog' */ role?: 'dialog' | 'none'; /** * Whether the dialog is modal (`aria-modal`). Only applies when `role` is * `'dialog'`. Set to `false` for non-modal dialogs that do not inert the rest * of the page. * * @default true */ isModal?: boolean; /** * Whether to apply the default popover surface (background, border-radius, * box-shadow) to the content wrapper. * * Set to false when the popover content provides its own surface styling * (e.g., mega menus with custom layouts). If you find yourself opting out, * consider whether useLayer is a better fit. * * @default true */ hasSurface?: boolean; /** * Theme-target name stamped on the popup SURFACE — the element that paints * the background, radius and elevation — without the `astryx-` prefix * (e.g. `'complex-selector-popup'`). * * The surface is created here, not by the calling component, so a component * that wants its popup themeable cannot reach it on its own: a target it * renders itself lands on its content INSIDE the surface, where a background * or radius rule paints the wrong box. Name the surface through this option * and document the class in the component's `theming.targets`. * * The canonical `astryx-popover` target and deprecated * `astryx-popover-surface` compatibility alias are always present alongside * any component-owned refinement target. */ surfaceTarget?: string; } /** * Return type for usePopover */ export interface UsePopoverReturn { /** * Ref callback to attach to the trigger element. * Sets up CSS anchor positioning. */ triggerRef: (el: HTMLElement | null) => void; /** * Ref for the popover content container (used internally for focus trapping). * You typically don't need to use this directly - the render function * automatically wraps content in a focus trap container. */ contentRef: React.RefObject; /** * The CSS anchor name to use for positioning. * Use when you need to set anchorName manually (e.g., display:contents wrapper). */ anchorId: string; /** * Show the popover. * @param options.skipAutoFocus - If true, don't auto-focus the first element. * Useful when triggered by mouse click on an input that should retain focus. */ show: (options?: { skipAutoFocus?: boolean; }) => void; /** * Hide the popover */ hide: () => void; /** * Toggle the popover open/closed */ toggle: () => void; /** * Whether the popover is currently open */ isOpen: boolean; /** * Unique ID for aria-describedby or aria-controls */ id: string; /** * Render function for popover content. * Automatically wraps content in a focus trap container with a hidden close button. * * @example * ``` * {popover.render( * , * { placement: 'below', alignment: 'start' } * )} * ``` */ render: (children: ReactNode, props?: ContextRenderProps) => ReactNode; /** * ARIA attributes to spread on the trigger element */ triggerProps: { 'aria-haspopup': 'dialog' | 'true'; 'aria-expanded': boolean; 'aria-controls': string; }; } /** * Hook for creating popover dialogs with focus trapping. * * Combines: * - `useLayer` for popover positioning using CSS anchor positioning * - `useFocusTrap` for trapping focus within the popover content * - Auto-focus first element on open * - Escape key to close (configurable via hasEscapeDismiss) * - Hidden close button that reveals on focus for accessibility * * The render function automatically wraps your content in a focus trap container * and appends a hidden close button. The button appears at the end of the popover, * is visually hidden until focused, then shows a tooltip-like message (default: "Close popover"). * * @example * ``` * function DatePickerExample() { * const inputRef = useRef(null); * const popover = usePopover({ * onHide: () => inputRef.current?.focus(), * closeButtonLabel: 'Close calendar', * }); * return ( * <> * * * {popover.render( * , * { placement: 'below', alignment: 'start' } * )} * * ); * } * ``` */ function usePopoverImplementation(options: UsePopoverOptions = {}): UsePopoverReturn { const { onShow, onHide, xstyle, className, style, hasLightDismiss = true, hasEscapeDismiss = true, hasAutoFocus = true, hasSurface = true, surfaceTarget, hasCloseButton = true, closeButtonLabel: closeButtonLabelFromProps, dialogLabel, role = 'dialog', isModal = true } = options; const t = useTranslator(); const closeButtonLabel = closeButtonLabelFromProps ?? t('@astryx.popover.close'); // Track the trigger element for returning focus const triggerElementRef = useRef(null); // Track whether to skip auto-focus for the current open event const skipAutoFocusRef = useRef(false); // Core layer for popover positioning const layer = useLayer({ mode: 'context', lightDismiss: hasLightDismiss, onShow, onHide }); // Focus trap for the popover content. Escape stays registered while light // dismiss is on (native popover="auto" closes on Escape regardless), so a // host Dialog keeps deferring to this trap instead of double-dismissing. const { containerRef: contentRef, focusFirst } = useFocusTrap({ isActive: layer.isOpen, onEscape: hasEscapeDismiss || hasLightDismiss ? layer.hide : undefined }); const focusInitialTarget = useCallback(() => { const container = contentRef.current; if (!container) { return; } if (focusFirstContentControl(container)) { return; } if (role === 'dialog') { attemptFocus(container); return; } focusFirst(); }, [contentRef, focusFirst, role]); // Auto-focus first element when popover opens (unless skipped) useEffect(() => { if (layer.isOpen && hasAutoFocus && !skipAutoFocusRef.current) { // Use requestAnimationFrame to ensure DOM is ready requestAnimationFrame(focusInitialTarget); } // Reset the per-open focus preference after the popover closes. if (!layer.isOpen) { skipAutoFocusRef.current = false; } }, [layer.isOpen, hasAutoFocus, focusInitialTarget]); // Combined ref for trigger element (layer anchor + our ref) const triggerRef = useCallback((el: HTMLElement | null) => { triggerElementRef.current = el; layer.ref(el); }, [layer]); // Show function with optional focus preservation const show = useCallback((showOptions?: { skipAutoFocus?: boolean; }) => { skipAutoFocusRef.current = showOptions?.skipAutoFocus ?? false; layer.show(); }, [layer]); // Toggle function. Opening delegates to show so every route inherits the // same-gesture reopen guard owned by Layer. const toggle = useCallback(() => { if (layer.isOpen) { layer.hide(); } else { show(); } }, [layer, show]); // ARIA attributes for the trigger const triggerProps = { 'aria-haspopup': role === 'dialog' ? 'dialog' as const : 'true' as const, 'aria-expanded': layer.isOpen, 'aria-controls': layer.id }; // Dev-time guardrail: a dialog popover should always be labeled. useDevWarning('usePopover', 'role="dialog" without a `dialogLabel` renders an unnamed ' + 'dialog. Pass `dialogLabel`, or use `role: "none"` for listbox/menu ' + 'popups whose content already carries its own role.', role === 'dialog' && !dialogLabel); // Wrapped render function that includes surface styles and optional hidden close button const render = useCallback((children: ReactNode, props?: ContextRenderProps): ReactNode => { // `mergeProps` is positional — a third OBJECT argument is read as // `style`, not as more props — so the surface's classes are composed // into one props object before merging with the StyleX result. const surfaceProps = themeProps('popover', undefined, { legacyNames: ['popover-surface'] }); const surfaceClassName = surfaceTarget != null && surfaceTarget !== 'popover' ? `${surfaceProps.className} ${stableClassName(surfaceTarget)}` : surfaceProps.className; return layer.render(
{children} {hasCloseButton &&
}
, { ...props, xstyle: props?.xstyle }); }, [layer, hasCloseButton, hasSurface, surfaceTarget, className, style, closeButtonLabel, contentRef, dialogLabel, role, isModal, xstyle]); return { triggerRef, contentRef, anchorId: layer.anchorId, show, hide: layer.hide, toggle, isOpen: layer.isOpen, id: layer.id, render, triggerProps }; } export function usePopover(options: UsePopoverOptions = {}): UsePopoverReturn { return usePopoverImplementation(options); }