diff --git a/libs/react/src/components/index.ts b/libs/react/src/components/index.ts index e31f8a5..4c7c671 100644 --- a/libs/react/src/components/index.ts +++ b/libs/react/src/components/index.ts @@ -8,6 +8,7 @@ export * from "./icon"; export * from "./input"; export * from "./multi-select"; export * from "./pagination"; +export * from "./popover"; export * from "./progress"; export * from "./radio-group"; export * from "./select"; diff --git a/libs/react/src/components/popover/index.ts b/libs/react/src/components/popover/index.ts new file mode 100644 index 0000000..beb5ecd --- /dev/null +++ b/libs/react/src/components/popover/index.ts @@ -0,0 +1,2 @@ +export * from "./popover"; +export * from "./use-popover"; diff --git a/libs/react/src/components/popover/popover.test.tsx b/libs/react/src/components/popover/popover.test.tsx new file mode 100644 index 0000000..c64cf15 --- /dev/null +++ b/libs/react/src/components/popover/popover.test.tsx @@ -0,0 +1,677 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { Popover } from "./popover"; + +describe("Popover Component", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("Basic Rendering", () => { + it("renders the trigger child", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Trigger" })).toBeInTheDocument(); + }); + + it("does not render popover content when closed", () => { + render( + + + , + ); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders popover content when defaultOpen is true", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toHaveTextContent("Popover text"); + }); + + it("renders with data-ck='popover' attribute", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toBeInTheDocument(); + }); + + it("renders with data-variant attribute", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveAttribute("data-variant", "primary"); + }); + + it("sets data-state='open' when open", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveAttribute("data-state", "open"); + }); + + it("has correct displayName", () => { + expect( + (Popover as unknown as { displayName?: string }).displayName, + ).toBe("Popover"); + }); + }); + + describe("Click Interaction", () => { + it("opens on click", async () => { + const { baseElement } = render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.click(trigger); + vi.advanceTimersByTime(100); + }); + + await waitFor(() => { + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toBeInTheDocument(); + expect(popover).toHaveAttribute("data-state", "open"); + }); + }); + + it("closes on second click (toggle)", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.click(trigger); + }); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + it("closes on Escape key", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await act(async () => { + await userEvent.keyboard("{Escape}"); + }); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + it("does not open when disabled", async () => { + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.click(trigger); + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("does not close on Escape when closeOnEscape is false", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await act(async () => { + await userEvent.keyboard("{Escape}"); + }); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toBeInTheDocument(); + }); + }); + + describe("Controlled Mode", () => { + it("opens when open prop is true", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("stays closed when open prop is false", async () => { + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.click(trigger); + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("calls onOpenChange when click triggers open", async () => { + const onOpenChange = vi.fn(); + + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + + await act(async () => { + await userEvent.click(trigger); + vi.advanceTimersByTime(100); + }); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe("Content", () => { + it("renders string content", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("dialog")).toHaveTextContent("Simple text"); + }); + + it("renders ReactNode content", async () => { + render( + + Bold content + + } + defaultOpen + portal={false} + > + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByTestId("rich-content")).toBeInTheDocument(); + expect(screen.getByText("Bold")).toBeInTheDocument(); + }); + + it("renders arrow when showArrow is true", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const arrow = baseElement.querySelector('[data-ck="popover-arrow"]'); + expect(arrow).toBeInTheDocument(); + }); + + it("does not render arrow when showArrow is false", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const arrow = baseElement.querySelector('[data-ck="popover-arrow"]'); + expect(arrow).not.toBeInTheDocument(); + }); + }); + + describe("Placement & Data Attributes", () => { + it("sets data-side attribute based on resolved placement", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveAttribute("data-side", "top"); + }); + + it("sets data-align attribute based on resolved alignment", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveAttribute("data-align", "start"); + }); + + it("defaults data-align to 'center' for simple placements", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveAttribute("data-align", "center"); + }); + + it("sets --ck-popover-transform-origin CSS custom property", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveStyle({ + "--ck-popover-transform-origin": "bottom center", + }); + }); + }); + + describe("Portal", () => { + it("renders inline when portal is false", async () => { + const { container } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = container.querySelector('[data-ck="popover"]'); + expect(popover).toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("popover content has role='dialog'", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("trigger has aria-expanded when popover is open", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + it("trigger has aria-haspopup attribute", () => { + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Trigger" }); + expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + }); + + it("uses auto-generated id when not provided", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = screen.getByRole("dialog"); + expect(popover).toHaveAttribute("id"); + expect(popover.id).toMatch(/^ck-popover-/); + }); + + it("uses provided id", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = screen.getByRole("dialog"); + expect(popover).toHaveAttribute("id", "my-popover"); + }); + }); + + describe("Modal Mode", () => { + it("renders with aria-modal when modal is true", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = screen.getByRole("dialog"); + expect(popover).toHaveAttribute("aria-modal", "true"); + }); + + it("does not have aria-modal when modal is false", async () => { + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = screen.getByRole("dialog"); + expect(popover).not.toHaveAttribute("aria-modal"); + }); + }); + + describe("forceMount", () => { + it("keeps content mounted when closed (forceMount=true)", () => { + const { baseElement } = render( + + + , + ); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toBeInTheDocument(); + expect(popover).toHaveAttribute("data-state", "closed"); + }); + + it("applies visibility:hidden when closed and forceMount", () => { + const { baseElement } = render( + + + , + ); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveStyle({ visibility: "hidden" }); + }); + }); + + describe("Ref Forwarding", () => { + it("forwards ref to the popover content element", async () => { + const ref = React.createRef(); + + render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current?.getAttribute("data-ck")).toBe("popover"); + }); + }); + + describe("className", () => { + it("applies className to the popover content", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveClass("custom-popover"); + }); + }); + + describe("maxWidth", () => { + it("applies maxWidth as number (px)", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveStyle({ maxWidth: "200px" }); + }); + + it("applies maxWidth as string", async () => { + const { baseElement } = render( + + + , + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + const popover = baseElement.querySelector('[data-ck="popover"]'); + expect(popover).toHaveStyle({ maxWidth: "50vw" }); + }); + }); +}); diff --git a/libs/react/src/components/popover/popover.tsx b/libs/react/src/components/popover/popover.tsx new file mode 100644 index 0000000..b56f3cc --- /dev/null +++ b/libs/react/src/components/popover/popover.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { + FloatingArrow, + FloatingFocusManager, + FloatingPortal, + type Placement, + type Strategy, +} from "@floating-ui/react"; +import { + cloneElement, + type CSSProperties, + forwardRef, + isValidElement, + type ReactElement, + type ReactNode, + type RefObject, + useId, + useRef, +} from "react"; + +import type { VariantFor } from "../../types/register"; + +import { useExitTransition } from "../../hooks"; +import { mergeRefs } from "../../utils/merge-refs"; +import { usePopover } from "./use-popover"; + +// --------------------------------------------------------------------------- +// Transform-origin map: placement → CSS transform-origin value +// --------------------------------------------------------------------------- + +const TRANSFORM_ORIGIN: Record = { + bottom: "top center", + "bottom-end": "top right", + "bottom-start": "top left", + left: "center right", + "left-end": "bottom right", + "left-start": "top right", + right: "center left", + "right-end": "bottom left", + "right-start": "top left", + top: "bottom center", + "top-end": "bottom right", + "top-start": "bottom left", +}; + +// --------------------------------------------------------------------------- +// Popover +// --------------------------------------------------------------------------- + +/** + * Props for the Popover component. + */ +export interface PopoverProps { + // --- Arrow --- + + /** + * Arrow height in px. + * @default 7 + */ + arrowHeight?: number; + + /** + * Arrow width in px. + * @default 14 + */ + arrowWidth?: number; + + // --- Content --- + + /** + * The trigger element. Must accept a ref. + */ + children: ReactElement; + + /** + * Additional className on the popover content element. + */ + className?: string; + + /** + * Close on Escape key press. + * @default true + */ + closeOnEscape?: boolean; + + /** + * Close when clicking outside the popover. + * @default true + */ + closeOnOutsideClick?: boolean; + + /** + * Popover content. Can be a string or ReactNode for rich content. + */ + content: ReactNode; + + /** + * Default open state for uncontrolled mode. + * @default false + */ + defaultOpen?: boolean; + + /** + * Whether the popover is disabled (never opens). + * @default false + */ + disabled?: boolean; + + /** + * Exit animation duration in ms (fallback timeout for CSS animations). + * @default 150 + */ + exitDuration?: number; + + /** + * Keep content mounted when closed. Useful for animation libraries + * like Framer Motion that manage their own enter/exit animations. + * Content is rendered with `data-state="closed"` and `visibility: hidden`. + * @default false + */ + forceMount?: boolean; + + /** + * ID for the popover element. Auto-generated if not provided. + */ + id?: string; + + /** + * Initial focus target when the popover opens. + * - `number`: index of the tabbable element (0 = first, -1 = floating element itself) + * - `RefObject`: ref to the element to focus + * @default 0 + */ + initialFocus?: number | RefObject; + + /** + * Max width as a CSS value. Prevents overly wide popovers. + */ + maxWidth?: number | string; + + /** + * Whether the popover is modal (traps focus and hides outside from screen readers). + * @default false + */ + modal?: boolean; + + /** + * Offset distance from the trigger in px. + * @default 8 + */ + offset?: number; + + /** + * Callback when open state changes. Used with `open` for controlled mode. + */ + onOpenChange?: (open: boolean) => void; + + /** + * Controlled open state. Use with `onOpenChange`. + */ + open?: boolean; + + /** + * Placement of the popover relative to the trigger. + * @default 'bottom' + */ + placement?: Placement; + + /** + * Whether to render the popover in a portal. Prevents overflow clipping. + * @default true + */ + portal?: boolean; + + /** + * Explicit portal root element. Defaults to `document.body`. + */ + portalRoot?: HTMLElement | null; + + /** + * Whether to return focus to the trigger when the popover closes. + * @default true + */ + returnFocus?: boolean; + + /** + * Show a pointing arrow from the popover to the trigger. + * @default false + */ + showArrow?: boolean; + + /** + * CSS positioning strategy. + * @default 'absolute' + */ + strategy?: Strategy; + + /** + * Variant name for the `data-variant` attribute. + */ + variantName?: VariantFor<"popover">; + + /** + * Viewport padding in px for collision detection. + * @default 8 + */ + viewportPadding?: number; +} + +/** + * A headless, accessible Popover component. + * + * @description + * Provides a floating panel that appears on click, powered by + * `@floating-ui/react` for positioning and interaction management. + * + * ## Features + * - Props-driven API — no compound components needed + * - 12 placement options with collision-aware flip/shift + * - Controlled and uncontrolled modes + * - Optional modal mode with focus trapping + * - Optional pointing arrow + * - Portal rendering (on by default) + * - CSS exit animations via `data-state` attribute + * - CSS custom property `--ck-popover-transform-origin` for scale animations + * + * ## Keyboard Support + * + * | Key | Action | + * | --- | --- | + * | `Enter` / `Space` | Toggle popover | + * | `Escape` | Close popover | + * | `Tab` | Navigate within popover (trapped in modal mode) | + * + * ## Accessibility + * + * Follows the WAI-ARIA Dialog (non-modal) pattern: + * - `role="dialog"` on the popover element + * - `aria-haspopup="dialog"` on the trigger + * - `aria-expanded` on the trigger + * - `aria-controls` linking trigger to popover `id` + * - Focus management via `FloatingFocusManager` + * - Optional `modal` mode for focus trapping and screen reader isolation + * + * ## Data Attributes + * + * | Attribute | Values | Description | + * | --- | --- | --- | + * | `data-ck` | `"popover"` | Component identifier | + * | `data-state` | `"open"` \| `"closed"` | Animation state | + * | `data-side` | `"top"` \| `"bottom"` \| `"left"` \| `"right"` | Resolved side | + * | `data-align` | `"start"` \| `"center"` \| `"end"` | Resolved alignment | + * | `data-variant` | string | User-defined variant | + * + * @example + * ```tsx + * // Basic usage + * Panel content}> + * + * + * ``` + * + * @example + * ```tsx + * // Modal mode with focus trap + * ...} modal> + * + * + * ``` + * + * @example + * ```tsx + * // With arrow and placement + * + * + * + * ``` + */ +const Popover = forwardRef( + ( + { + arrowHeight = 7, + arrowWidth = 14, + children, + className, + closeOnEscape, + closeOnOutsideClick, + content, + defaultOpen, + disabled, + exitDuration = 150, + forceMount = false, + id: externalId, + initialFocus = 0, + maxWidth, + modal, + offset: offsetDistance, + onOpenChange, + open, + placement, + portal = true, + portalRoot, + returnFocus = true, + showArrow, + strategy, + variantName, + viewportPadding, + }, + ref, + ) => { + const autoId = useId(); + const popoverId = externalId ?? `ck-popover-${autoId}`; + const contentNodeRef = useRef(null); + + const popover = usePopover({ + closeOnEscape, + closeOnOutsideClick, + defaultOpen, + disabled, + modal, + offsetDistance, + onOpenChange, + open, + placement, + showArrow, + strategy, + viewportPadding, + }); + + const { dataState, isMounted } = useExitTransition({ + animateOnMount: true, + duration: exitDuration, + isOpen: popover.isOpen, + nodeRef: contentNodeRef, + }); + + const [side, align = "center"] = popover.resolvedPlacement.split("-") as [ + string, + string | undefined, + ]; + + const shouldRender = forceMount || isMounted; + + // Clone the trigger child and merge interaction props + ref + const trigger = isValidElement(children) + ? cloneElement( + children as ReactElement>, + popover.getReferenceProps({ + ref: mergeRefs( + popover.referenceRef, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as any).ref, + ), + }), + ) + : children; + + const popoverContent = shouldRender ? ( +
( + popover.floatingRef, + contentNodeRef, + ref, + )} + > + {content} + {showArrow && ( + + )} +
+ ) : null; + + const wrappedContent = + popoverContent !== null && isMounted ? ( + + {popoverContent} + + ) : ( + popoverContent + ); + + return ( + <> + {trigger} + {portal ? ( + + {wrappedContent} + + ) : ( + wrappedContent + )} + + ); + }, +); + +Popover.displayName = "Popover"; + +export { Popover }; diff --git a/libs/react/src/components/popover/use-popover.ts b/libs/react/src/components/popover/use-popover.ts new file mode 100644 index 0000000..328bdd2 --- /dev/null +++ b/libs/react/src/components/popover/use-popover.ts @@ -0,0 +1,273 @@ +"use client"; + +import { + arrow, + autoUpdate, + flip, + hide, + offset, + type Placement, + shift, + type Strategy, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from "@floating-ui/react"; +import { useCallback, useMemo, useRef, useState } from "react"; + +/** + * Configuration options for the Popover positioning and interaction hook. + * + * @remarks + * This hook wraps `@floating-ui/react` directly (not via the generic `useFloating` wrapper) + * because popover interaction hooks (`useClick`, `useDismiss`, `useRole`) + * require the full `FloatingContext` with `open`/`onOpenChange` wired in. + */ +export interface UsePopoverOptions { + /** + * Close on Escape key press. + * @default true + */ + closeOnEscape?: boolean; + + /** + * Close when clicking outside the popover. + * @default true + */ + closeOnOutsideClick?: boolean; + + /** + * Default open state for uncontrolled mode. + * @default false + */ + defaultOpen?: boolean; + + /** + * Whether the popover is disabled (never opens). + * @default false + */ + disabled?: boolean; + + /** + * Whether the popover is modal (traps focus and hides outside from screen readers). + * @default false + */ + modal?: boolean; + + /** + * Offset distance in pixels between trigger and popover. + * @default 8 + */ + offsetDistance?: number; + + /** + * Callback when open state changes. Used for controlled mode. + */ + onOpenChange?: (open: boolean) => void; + + /** + * Controlled open state. + */ + open?: boolean; + + /** + * Placement of the popover relative to the trigger. + * @default 'bottom' + */ + placement?: Placement; + + /** + * Whether to show an arrow pointing from the popover to the trigger. + * @default false + */ + showArrow?: boolean; + + /** + * CSS positioning strategy. + * @default 'absolute' + */ + strategy?: Strategy; + + /** + * Padding from viewport edges in pixels. + * @default 8 + */ + viewportPadding?: number; +} + +/** + * Return type for the usePopover hook. + */ +export interface UsePopoverReturn { + /** + * Ref for the arrow SVG element. Pass to `FloatingArrow`. + */ + arrowRef: React.RefObject; + + /** + * Floating UI context. Required by `FloatingArrow` and `FloatingFocusManager`. + */ + context: ReturnType["context"]; + + /** + * Ref setter for the floating (popover content) element. + */ + floatingRef: (node: HTMLElement | null) => void; + + /** + * Inline styles for the floating element. + */ + floatingStyles: React.CSSProperties; + + /** + * Returns props to spread on the floating (popover) element. + */ + getFloatingProps: ( + userProps?: React.HTMLProps, + ) => Record; + + /** + * Returns props to spread on the reference (trigger) element. + */ + getReferenceProps: ( + userProps?: React.HTMLProps, + ) => Record; + + /** + * Whether the popover is currently open. + */ + isOpen: boolean; + + /** + * Whether the popover is modal. + */ + modal: boolean; + + /** + * Ref setter for the reference (trigger) element. + */ + referenceRef: (node: HTMLElement | null) => void; + + /** + * Resolved placement after middleware (may differ from requested placement after flip). + */ + resolvedPlacement: Placement; + + /** + * Set the open state programmatically. + */ + setIsOpen: (open: boolean) => void; +} + +/** + * Core hook for Popover positioning and interaction. + * + * @description + * Wraps `@floating-ui/react` with popover-specific interactions: + * - Click trigger to toggle open/close + * - Dismiss on Escape key and outside click + * - Dialog ARIA role with `aria-haspopup` and `aria-expanded` + * - Optional modal mode for focus trapping + * - Optional arrow positioning + * + * @remarks + * Uses `@floating-ui/react`'s `useFloating` directly because the interaction hooks + * require the full `FloatingContext` with `open`/`onOpenChange`. This follows the same + * pattern as `useTooltip` and `useFloatingSelect`. + * + * @see {@link UsePopoverOptions} for configuration + * @see {@link https://floating-ui.com/docs/popover | Floating UI Popover docs} + */ +export function usePopover( + options: UsePopoverOptions = {}, +): UsePopoverReturn { + const { + closeOnEscape = true, + closeOnOutsideClick = true, + defaultOpen = false, + disabled = false, + modal = false, + offsetDistance = 8, + onOpenChange: controlledOnOpenChange, + open: controlledOpen, + placement = "bottom", + showArrow = false, + strategy = "absolute", + viewportPadding = 8, + } = options; + + const isControlled = controlledOpen !== undefined; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const setIsOpen = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + controlledOnOpenChange?.(nextOpen); + }, + [controlledOnOpenChange, isControlled], + ); + + const arrowRef = useRef(null); + + const middleware = useMemo( + () => [ + offset(offsetDistance), + flip({ fallbackAxisSideDirection: "start", padding: viewportPadding }), + shift({ padding: viewportPadding }), + ...(showArrow ? [arrow({ element: arrowRef })] : []), + hide({ strategy: "referenceHidden" }), + ], + [offsetDistance, showArrow, viewportPadding], + ); + + const floating = useFloating({ + middleware, + onOpenChange: disabled ? undefined : setIsOpen, + open: isOpen, + placement, + strategy, + whileElementsMounted: autoUpdate, + }); + + const { context } = floating; + + const clickInteraction = useClick(context, { + enabled: !disabled, + }); + + const dismissInteraction = useDismiss(context, { + enabled: !disabled, + escapeKey: closeOnEscape, + outsidePress: closeOnOutsideClick, + }); + + const roleInteraction = useRole(context, { + enabled: !disabled, + role: "dialog", + }); + + const { getFloatingProps, getReferenceProps } = useInteractions([ + clickInteraction, + dismissInteraction, + roleInteraction, + ]); + + return { + arrowRef, + context, + floatingRef: floating.refs.setFloating, + floatingStyles: floating.floatingStyles, + getFloatingProps, + getReferenceProps, + isOpen, + modal, + referenceRef: floating.refs.setReference, + resolvedPlacement: floating.placement, + setIsOpen, + }; +}