diff --git a/.changeset/headless-primitives-batch.md b/.changeset/headless-primitives-batch.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/headless-primitives-batch.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/package.json b/packages/headless/package.json index 96f93e4b4d3..7d45e877bba 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -9,6 +9,34 @@ "import": "./dist/primitives/accordion/index.js", "types": "./dist/primitives/accordion/index.d.ts" }, + "./tabs": { + "import": "./dist/primitives/tabs/index.js", + "types": "./dist/primitives/tabs/index.d.ts" + }, + "./tooltip": { + "import": "./dist/primitives/tooltip/index.js", + "types": "./dist/primitives/tooltip/index.d.ts" + }, + "./popover": { + "import": "./dist/primitives/popover/index.js", + "types": "./dist/primitives/popover/index.d.ts" + }, + "./select": { + "import": "./dist/primitives/select/index.js", + "types": "./dist/primitives/select/index.d.ts" + }, + "./menu": { + "import": "./dist/primitives/menu/index.js", + "types": "./dist/primitives/menu/index.d.ts" + }, + "./autocomplete": { + "import": "./dist/primitives/autocomplete/index.js", + "types": "./dist/primitives/autocomplete/index.d.ts" + }, + "./collapsible": { + "import": "./dist/primitives/collapsible/index.js", + "types": "./dist/primitives/collapsible/index.d.ts" + }, "./dialog": { "import": "./dist/primitives/dialog/index.js", "types": "./dist/primitives/dialog/index.d.ts" @@ -42,6 +70,7 @@ "happy-dom": "^18.0.1", "react": "catalog:react", "react-dom": "catalog:react", + "rollup-plugin-preserve-directives": "^0.4.0", "typescript": "catalog:repo", "vite": "6.4.2", "vite-plugin-dts": "^4.5.4", diff --git a/packages/headless/src/__tests__/floating-tree.test.tsx b/packages/headless/src/__tests__/floating-tree.test.tsx new file mode 100644 index 00000000000..f0731c67c7d --- /dev/null +++ b/packages/headless/src/__tests__/floating-tree.test.tsx @@ -0,0 +1,236 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Dialog } from '../primitives/dialog/index'; +import { Popover } from '../primitives/popover/index'; +import { Select } from '../primitives/select/index'; +import { Tooltip } from '../primitives/tooltip/index'; + +afterEach(() => { + cleanup(); +}); + +const fruits = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, +]; + +describe('FloatingTree integration', () => { + describe('Select inside Popover', () => { + function SelectInPopover() { + return ( + + Open Popover + + + Pick a fruit + + + + + + + {fruits.map(f => ( + + {f.label} + + ))} + + + + + + + ); + } + + it('popover stays open when select dropdown opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Open Popover')); + expect(screen.getByText('Pick a fruit')).toBeInTheDocument(); + + await user.click(screen.getByText('Choose...')); + + // Popover should still be open + expect(screen.getByText('Pick a fruit')).toBeInTheDocument(); + // Select dropdown should be visible + expect(screen.getByText('Apple')).toBeInTheDocument(); + }); + + it('popover stays open when clicking select option', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Open Popover')); + await user.click(screen.getByText('Choose...')); + await user.click(screen.getByText('Banana')); + + // Popover should still be open after selecting + expect(screen.getByText('Pick a fruit')).toBeInTheDocument(); + }); + }); + + describe('Select inside Dialog', () => { + function SelectInDialog() { + return ( + + Open Dialog + + + Select a fruit + + + + + + + {fruits.map(f => ( + + {f.label} + + ))} + + + + + + + ); + } + + it('dialog stays open when select dropdown opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Open Dialog')); + expect(screen.getByText('Select a fruit')).toBeInTheDocument(); + + await user.click(screen.getByText('Choose...')); + + // Dialog should still be open + expect(screen.getByText('Select a fruit')).toBeInTheDocument(); + // Select options visible + expect(screen.getByText('Apple')).toBeInTheDocument(); + }); + }); + + describe('Popover inside Popover', () => { + function NestedPopover() { + return ( + + Outer + + + Outer Content + + Inner + + + Inner Content + + + + + + + ); + } + + it('outer popover stays open when inner popover opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Outer')); + expect(screen.getByText('Outer Content')).toBeInTheDocument(); + + await user.click(screen.getByText('Inner')); + + // Both should be visible + expect(screen.getByText('Outer Content')).toBeInTheDocument(); + expect(screen.getByText('Inner Content')).toBeInTheDocument(); + }); + }); + + describe('Tooltip inside Popover', () => { + function TooltipInPopover() { + return ( + + Open Popover + + + Content + + Hover me + + Tooltip text + + + + + + ); + } + + it('popover stays open when tooltip trigger is hovered', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Open Popover')); + expect(screen.getByText('Content')).toBeInTheDocument(); + + await user.hover(screen.getByText('Hover me')); + + // Popover should remain open + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + }); + + describe('Popover inside Dialog', () => { + function PopoverInDialog() { + return ( + + Open Dialog + + + Dialog Content + + Open Popover + + + Popover Content + + + + + + + ); + } + + it('dialog stays open when popover opens inside it', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Open Dialog')); + expect(screen.getByText('Dialog Content')).toBeInTheDocument(); + + await user.click(screen.getByText('Open Popover')); + + // Both should be visible + expect(screen.getByText('Dialog Content')).toBeInTheDocument(); + expect(screen.getByText('Popover Content')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/headless/src/primitives/accordion/accordion-panel.tsx b/packages/headless/src/primitives/accordion/accordion-panel.tsx index 6f065aa946c..23eed6f955a 100644 --- a/packages/headless/src/primitives/accordion/accordion-panel.tsx +++ b/packages/headless/src/primitives/accordion/accordion-panel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMergeRefs } from '@floating-ui/react'; -import { type RefObject, useLayoutEffect, useRef, useState } from 'react'; +import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react'; import { useTransition } from '../../hooks/use-transition'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; @@ -9,90 +9,92 @@ import { useAccordionItemContext } from './accordion-context'; export type AccordionPanelProps = ComponentProps<'div'>; -export function AccordionPanel(props: AccordionPanelProps) { - const { render, ref: consumerRef, ...otherProps } = props; - const { open, triggerId, panelId } = useAccordionItemContext(); - - const panelRef = useRef(null); - // Merge the consumer ref with the internal panelRef so passing a ref does not - // clobber the ref the panel relies on for height measurement. - const combinedRef = useMergeRefs([panelRef, consumerRef]); - const [height, setHeight] = useState(undefined); - - // Track whether open has ever transitioned from true→false. - // Until that happens, skip enter animations (prevents animate-on-load). - const hasBeenClosed = useRef(false); - if (!open) { - hasBeenClosed.current = true; - } - - const { mounted, transitionProps } = useTransition({ - open, - ref: panelRef as RefObject, - }); - - // Measure the content height and keep it in sync via ResizeObserver - useLayoutEffect(() => { - if (!mounted) { - return; +export const AccordionPanel = React.forwardRef( + function AccordionPanel(props, ref) { + const { render, ...otherProps } = props; + const { open, triggerId, panelId } = useAccordionItemContext(); + + const panelRef = useRef(null); + // Merge the consumer ref with the internal panelRef so passing a ref does not + // clobber the ref the panel relies on for height measurement. + const combinedRef = useMergeRefs([panelRef, ref]); + const [height, setHeight] = useState(undefined); + + // Track whether open has ever transitioned from true→false. + // Until that happens, skip enter animations (prevents animate-on-load). + const hasBeenClosed = useRef(false); + if (!open) { + hasBeenClosed.current = true; } - const panel = panelRef.current; - if (!panel) { - return; - } - - // Measure scrollHeight of the panel's content - const measure = () => { - setHeight(panel.scrollHeight); - }; - - measure(); + const { mounted, transitionProps } = useTransition({ + open, + ref: panelRef as RefObject, + }); - const ro = new ResizeObserver(measure); - // Observe children mutations that affect height - ro.observe(panel, { box: 'border-box' }); + // Measure the content height and keep it in sync via ResizeObserver + useLayoutEffect(() => { + if (!mounted) { + return; + } - return () => ro.disconnect(); - }, [mounted]); + const panel = panelRef.current; + if (!panel) { + return; + } - const state = { open }; + // Measure scrollHeight of the panel's content + const measure = () => { + setHeight(panel.scrollHeight); + }; + + measure(); + + const ro = new ResizeObserver(measure); + // Observe children mutations that affect height + ro.observe(panel, { box: 'border-box' }); + + return () => ro.disconnect(); + }, [mounted]); + + const state = { open }; + + // Skip enter animation for panels that have never been closed + const effectiveTransitionProps = !hasBeenClosed.current + ? { + ...transitionProps, + 'data-cl-starting-style': undefined, + style: undefined, + } + : transitionProps; + + const defaultProps: Record = { + 'data-cl-slot': 'accordion-panel', + id: panelId, + role: 'region' as const, + 'aria-labelledby': triggerId, + ref: combinedRef, + ...effectiveTransitionProps, + style: { + '--cl-accordion-panel-height': height != null ? `${height}px` : undefined, + ...effectiveTransitionProps.style, + }, + }; - // Skip enter animation for panels that have never been closed - const effectiveTransitionProps = !hasBeenClosed.current - ? { - ...transitionProps, - 'data-cl-starting-style': undefined, - style: undefined, - } - : transitionProps; - - const defaultProps: Record = { - 'data-cl-slot': 'accordion-panel', - id: panelId, - role: 'region' as const, - 'aria-labelledby': triggerId, - ref: combinedRef, - ...effectiveTransitionProps, - style: { - '--cl-accordion-panel-height': height != null ? `${height}px` : undefined, - ...effectiveTransitionProps.style, - }, - }; - - const merged = mergeProps<'div'>(defaultProps, otherProps); - // The wired id is owned by the primitive: a consumer-supplied id must not - // override it, or the trigger/panel aria pairing would silently break. - merged.id = panelId; - - return renderElement({ - defaultTagName: 'div', - render, - enabled: mounted, - state, - stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - }, - props: merged, - }); -} + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the trigger/panel aria pairing would silently break. + merged.id = panelId; + + return renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: merged, + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/README.md b/packages/headless/src/primitives/autocomplete/README.md new file mode 100644 index 00000000000..46e45ce37f3 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/README.md @@ -0,0 +1,152 @@ +# Autocomplete + +A combobox input with a filterable dropdown list. Supports virtual focus (focus stays on the input), keyboard navigation, and controlled/uncontrolled input and selection values. + +## When to Use + +- Search inputs with suggestions, tag pickers, or any input that filters a list of options. +- When the user needs to type to narrow down choices, unlike `Select` which is for picking from a static list. +- When you need `aria-autocomplete` behavior with `aria-activedescendant` virtual focus. + +## Usage + +```tsx +import { Autocomplete } from '@/primitives/autocomplete'; + +const fruits = ['Apple', 'Banana', 'Cherry', 'Date']; + +function MyAutocomplete() { + const [inputValue, setInputValue] = useState(''); + const filtered = fruits.filter(f => f.toLowerCase().includes(inputValue.toLowerCase())); + + return ( + + + + + {filtered.map(fruit => ( + + ))} + + + + ); +} +``` + +### Inline List (inside another floating element) + +Use `Autocomplete.List` when the autocomplete input lives inside an outer floating surface such as a Popover or Dialog. In this mode, the outer primitive owns placement and dismissal for the overall panel, while `Autocomplete` still owns the combobox/listbox semantics between the input and the results list. + +```tsx + + Pick a country + + + + + + + + + + + +``` + +In this pattern, keep the outer `Popover` or `Dialog` as the source of truth for whether the panel is visible. `Autocomplete` should render the input and inline listbox inside that surface, and selecting an option can close the outer shell if desired. + +## Parts + +| Part | Default Element | Description | +| ------------------------- | --------------- | ---------------------------------------- | +| `Autocomplete.Root` | — | Root context provider | +| `Autocomplete.Input` | `` | Text input that drives filtering | +| `Autocomplete.Portal` | — | Portals children (accepts `root` prop) | +| `Autocomplete.Positioner` | `` | Floating positioned container | +| `Autocomplete.Popup` | `` | Visual wrapper for the option list | +| `Autocomplete.List` | `` | Inline alternative to Positioner + Popup | +| `Autocomplete.Option` | `` | A selectable option | +| `Autocomplete.Arrow` | `` | Optional floating arrow | + +## Props + +### `Autocomplete.Root` + +| Prop | Type | Default | Description | +| -------------------- | ------------------------- | ---------------- | ------------------------------------- | +| `inputValue` | `string` | — | Controlled input text | +| `defaultInputValue` | `string` | `""` | Initial input text (uncontrolled) | +| `onInputValueChange` | `(value: string) => void` | — | Called when input text changes | +| `value` | `string` | — | Controlled selected value | +| `defaultValue` | `string` | — | Initial selected value (uncontrolled) | +| `onValueChange` | `(value: string) => void` | — | Called when an option is selected | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"bottom-start"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between input and popup (px) | + +### `Autocomplete.Option` + +| Prop | Type | Default | Description | +| ---------- | --------- | --------------------- | ---------------------------------------------------- | +| `value` | `string` | **required** | The option's value | +| `label` | `string` | falls back to `value` | Display label, also used for input text on selection | +| `disabled` | `boolean` | — | Prevents selection | + +### `Autocomplete.Input`, `Autocomplete.Positioner`, `Autocomplete.Popup`, `Autocomplete.List` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Autocomplete.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard Navigation + +| Key | Action | +| ----------- | ------------------------------------- | +| `ArrowDown` | Move to next option | +| `ArrowUp` | Move to previous option | +| `Enter` | Select the active option, close popup | +| `Escape` | Close the popup | + +Navigation loops and auto-scrolls the active option into view. + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | --------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"autocomplete-input"`) | +| `data-cl-open` / `data-cl-closed` | Input | Popup open state | +| `data-cl-selected` | Option | The currently selected option | +| `data-cl-active` | Option | The keyboard-highlighted option | +| `data-cl-disabled` | Option | Disabled option | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Open/Close Behavior + +- Typing a non-empty string opens the popup automatically. +- Clearing the input closes the popup. +- Clicking an option closes the popup and returns focus to the input. +- Outside click and Escape close the popup. + +## ARIA + +- Input: `aria-autocomplete="list"`, `aria-activedescendant` (virtual focus) +- Options: `role="option"`, `aria-selected`, `aria-disabled` +- Focus manager: non-modal, `initialFocus={-1}` (focus stays on input) diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx new file mode 100644 index 00000000000..f1172044e17 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FloatingArrow } from '@floating-ui/react'; +import React from 'react'; + +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompleteArrowProps = React.ComponentPropsWithRef; + +export function AutocompleteArrow(props: AutocompleteArrowProps) { + const { floatingContext, arrowRef, placement } = useAutocompleteContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts new file mode 100644 index 00000000000..3fae09aff96 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts @@ -0,0 +1,46 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface AutocompleteContextValue { + open: boolean; + inputValue: string; + selectedValue: string | undefined; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + getItemProps: UseInteractionsReturn['getItemProps']; + activeIndex: number | null; + selectedIndex: number | null; + elementsRef: React.MutableRefObject>; + labelsRef: React.MutableRefObject>; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + valuesByIndexRef: React.MutableRefObject>; + setInlineMode: React.Dispatch>; + handleSelect: (value: string, index: number, label: string) => void; + handleInputChange: (value: string) => void; + registerSelectedIndex: (index: number, value: string) => void; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const AutocompleteContext = createContext(null); + +export function useAutocompleteContext() { + const ctx = useContext(AutocompleteContext); + if (!ctx) { + throw new Error('Autocomplete compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx new file mode 100644 index 00000000000..0604785a422 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompleteInputProps = ComponentProps<'input'>; + +export const AutocompleteInput = React.forwardRef( + function AutocompleteInput(props, ref) { + const { render, ...otherProps } = props; + const { + open, + inputValue, + activeIndex, + refs, + getReferenceProps, + handleInputChange, + handleSelect, + labelsRef, + valuesByIndexRef, + } = useAutocompleteContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-input', + ...(getReferenceProps({ + ref: combinedRef, + value: inputValue, + 'aria-autocomplete': 'list' as const, + onChange(event: React.ChangeEvent) { + handleInputChange(event.target.value); + }, + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter' && activeIndex != null) { + const value = valuesByIndexRef.current.get(activeIndex); + const label = labelsRef.current[activeIndex]; + if (value != null) { + event.preventDefault(); + handleSelect(value, activeIndex, label ?? value); + } + } + }, + }) as React.ComponentPropsWithRef<'input'>), + }; + + return renderElement({ + defaultTagName: 'input', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'input'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx new file mode 100644 index 00000000000..27b4e952c6c --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { FloatingList, useMergeRefs } from '@floating-ui/react'; +import React, { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompleteListProps = ComponentProps<'div'>; + +export const AutocompleteList = React.forwardRef( + function AutocompleteList(props, ref) { + const { render, ...otherProps } = props; + const { elementsRef, labelsRef, refs, getFloatingProps, setInlineMode } = useAutocompleteContext(); + + useEffect(() => { + setInlineMode(true); + return () => setInlineMode(false); + }, [setInlineMode]); + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>; + const wiredId = floatingProps.id; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-list', + ref: combinedRef, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the aria-controls pairing would silently break. + if (wiredId != null) { + merged.id = wiredId; + } + + return ( + + {renderElement({ + defaultTagName: 'div', + render, + props: merged, + })} + + ); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx new file mode 100644 index 00000000000..38ba5d05237 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useListItem, useMergeRefs } from '@floating-ui/react'; +import React, { useEffect, useId } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompleteOptionProps extends ComponentProps<'div'> { + value: string; + label?: string; + disabled?: boolean; +} + +export const AutocompleteOption = React.forwardRef( + function AutocompleteOption(props, ref) { + const { render, value, label, disabled, ...otherProps } = props; + const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = + useAutocompleteContext(); + + const id = useId(); + const displayLabel = label ?? value; + const { ref: itemRef, index } = useListItem({ label: displayLabel }); + const combinedRef = useMergeRefs([itemRef, ref]); + + const isSelected = selectedValue === value; + const isActive = activeIndex === index; + + useEffect(() => { + const map = valuesByIndexRef.current; + if (!disabled) { + map.set(index, value); + } + registerSelectedIndex(index, value); + return () => { + map.delete(index); + }; + }, [index, value, disabled, valuesByIndexRef, registerSelectedIndex]); + + const state = { + selected: isSelected, + active: isActive, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-option', + id, + ref: combinedRef, + role: 'option' as const, + 'aria-selected': isActive, + 'aria-disabled': disabled || undefined, + ...(getItemProps({ + onClick() { + if (!disabled) { + handleSelect(value, index, displayLabel); + (refs.domReference.current as HTMLElement | null)?.focus(); + } + }, + }) as React.ComponentPropsWithRef<'div'>), + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The option id is owned by the primitive and drives the input's + // aria-activedescendant linkage: a consumer-supplied id must not override it. + merged.id = id; + + return renderElement({ + defaultTagName: 'div', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: merged, + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx new file mode 100644 index 00000000000..74f443ec5be --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompletePopupProps = ComponentProps<'div'>; + +export const AutocompletePopup = React.forwardRef( + function AutocompletePopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useAutocompleteContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'autocomplete-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-portal.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-portal.tsx new file mode 100644 index 00000000000..a0d3f780a85 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompletePortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function AutocompletePortal(props: AutocompletePortalProps) { + const { mounted } = useAutocompleteContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx new file mode 100644 index 00000000000..4cb4cc03cfa --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompletePositionerProps = ComponentProps<'div'>; + +export const AutocompletePositioner = React.forwardRef( + function AutocompletePositioner(props, ref) { + const { render, ...otherProps } = props; + const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } = + useAutocompleteContext(); + + const side = placement.split('-')[0]; + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>; + const wiredId = floatingProps.id; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the aria-controls pairing would silently break. + if (wiredId != null) { + merged.id = wiredId; + } + + return ( + + + {renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: merged, + })} + + + ); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx new file mode 100644 index 00000000000..fd2eba8a36c --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + size, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { AutocompleteContext, type AutocompleteContextValue } from './autocomplete-context'; + +export interface AutocompleteProps { + /** Controlled input text. */ + inputValue?: string; + defaultInputValue?: string; + onInputValueChange?: (value: string) => void; + /** Controlled selected value. */ + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + children: ReactNode; +} + +function AutocompleteInner(props: AutocompleteProps) { + const { placement: placementProp = 'bottom-start', sideOffset = 4, children } = props; + + const nodeId = useFloatingNodeId(); + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [inputValue, setInputValue] = useControllableState( + props.inputValue, + props.defaultInputValue ?? '', + props.onInputValueChange, + ); + + const [selectedValue, setSelectedValue] = useControllableState( + props.value, + props.defaultValue, + props.onValueChange as ((value: string | undefined) => void) | undefined, + ); + + const [activeIndex, setActiveIndex] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(null); + const [inlineMode, setInlineMode] = useState(false); + + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const arrowRef = useRef(null); + const popupRef = useRef(null); + const valuesByIndexRef = useRef>(new Map()); + const registerSelectedIndex = useCallback( + (index: number, value: string) => { + if (value === selectedValue) { + setSelectedIndex(index); + } + }, + [selectedValue], + ); + + const previousOpenRef = useRef(open); + useEffect(() => { + if (open && !previousOpenRef.current && selectedIndex != null) { + setActiveIndex(selectedIndex); + } + previousOpenRef.current = open; + }, [open, selectedIndex]); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ padding: 5 }), + size({ + apply({ rects, availableHeight, elements }) { + if (elements.floating.getAttribute('data-cl-slot') !== 'autocomplete-positioner') { + return; + } + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + maxHeight: `${availableHeight}px`, + }); + }, + padding: 5, + }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const dismiss = useDismiss(floatingContext, { + escapeKey: !inlineMode, + outsidePress: !inlineMode, + bubbles: { + escapeKey: inlineMode, + outsidePress: inlineMode, + }, + }); + const role = useRole(floatingContext, { role: 'listbox' }); + const listNav = useListNavigation(floatingContext, { + listRef: elementsRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: true, + scrollItemIntoView: true, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([dismiss, role, listNav]); + + const handleSelect = useCallback( + (value: string, index: number, label: string) => { + setSelectedValue(value); + setSelectedIndex(index); + setInputValue(label); + setActiveIndex(null); + setOpen(false); + }, + [setSelectedValue, setInputValue, setOpen], + ); + + const handleInputChange = useCallback( + (value: string) => { + setInputValue(value); + if (value) { + setOpen(true); + setActiveIndex(0); + } else { + setOpen(false); + setActiveIndex(null); + } + }, + [setInputValue, setOpen], + ); + + const contextValue = useMemo( + () => ({ + open, + inputValue, + selectedValue, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + selectedIndex, + elementsRef, + labelsRef, + popupRef, + arrowRef, + valuesByIndexRef, + setInlineMode, + handleSelect, + handleInputChange, + registerSelectedIndex, + mounted, + transitionProps, + }), + [ + open, + inputValue, + selectedValue, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + selectedIndex, + handleSelect, + handleInputChange, + registerSelectedIndex, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function AutocompleteRoot(props: AutocompleteProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx new file mode 100644 index 00000000000..0e0ce59d47c --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx @@ -0,0 +1,1272 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Popover } from '../popover/index'; +import { Autocomplete } from './index'; + +afterEach(() => cleanup()); + +const fruits = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, +]; + +function FilteredAutocomplete( + props: { + onValueChange?: (value: string) => void; + onInputValueChange?: (value: string) => void; + defaultInputValue?: string; + } = {}, +) { + const [inputValue, setInputValue] = useState(props.defaultInputValue ?? ''); + const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); + + return ( + { + setInputValue(v); + props.onInputValueChange?.(v); + }} + onValueChange={props.onValueChange} + > + + + + {filtered.map(f => ( + + {f.label} + + ))} + + + + ); +} + +function StaticAutocomplete(props: Partial> = {}) { + return ( + + + + + {fruits.map(f => ( + + {f.label} + + ))} + + + + ); +} + +describe('Autocomplete', () => { + describe('slot attributes', () => { + it('renders input with data-cl-slot', () => { + render(); + const input = screen.getByPlaceholderText('Search fruits...'); + expect(input).toHaveAttribute('data-cl-slot', 'autocomplete-input'); + }); + + it('renders all parts with correct slot attributes when open', () => { + render(); + + expect(document.querySelector('[data-cl-slot="autocomplete-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument(); + expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4); + }); + }); + + describe('open/close', () => { + it('opens when user types', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + + expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument(); + }); + + it('closes when input is cleared', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + + expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument(); + + await user.clear(input); + + expect(input).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + await user.keyboard('{Escape}'); + + expect(input).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('filtering', () => { + it('filters options based on input', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'ch'); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Cherry'); + }); + + it('shows all matching options', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options).toHaveLength(1); // Only "Apple" starts with "a" + }); + }); + + describe('selection', () => { + it('selects option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'b'); + await user.click(screen.getByText('Banana')); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + }); + + it('updates input value to label on selection', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...') as HTMLInputElement; + await user.type(input, 'b'); + await user.click(screen.getByText('Banana')); + + expect(input.value).toBe('Banana'); + }); + + it('closes after selection', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'b'); + await user.click(screen.getByText('Banana')); + + expect(input).toHaveAttribute('data-cl-closed', ''); + }); + + it('returns focus to input after click selection', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'b'); + await user.click(screen.getByText('Banana')); + + expect(document.activeElement).toBe(input); + }); + }); + + describe('keyboard navigation', () => { + it('navigates options with arrow keys', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + + it('selects option on Enter', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'b'); + // activeIndex starts at 0 when typing opens the list + await user.keyboard('{Enter}'); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + }); + + it('updates input value on Enter selection', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...') as HTMLInputElement; + await user.type(input, 'b'); + await user.keyboard('{Enter}'); + + expect(input.value).toBe('Banana'); + }); + + it('focus stays on input during arrow navigation', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + await user.keyboard('{ArrowDown}'); + + expect(document.activeElement).toBe(input); + }); + }); + + describe('option state attributes', () => { + it('marks active option with data-cl-active', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Search fruits...'), 'a'); + + // First option is active by default (activeIndex starts at 0) + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options[0]).toHaveAttribute('data-cl-active', ''); + }); + + it('marks selected option with data-cl-selected', () => { + render( + , + ); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options[1]).toHaveAttribute('data-cl-selected', ''); + }); + }); + + describe('ARIA attributes', () => { + it('input has role=combobox', () => { + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('input has aria-autocomplete=list', () => { + render(); + const input = screen.getByRole('combobox'); + expect(input).toHaveAttribute('aria-autocomplete', 'list'); + }); + + it('options have role=option', () => { + render(); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(4); + }); + + it('active option has aria-selected=true', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Search fruits...'), 'a'); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + render(); + expect(document.querySelector('[data-cl-slot="autocomplete-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Search fruits...'), 'a'); + + const popup = document.querySelector('[data-cl-slot="autocomplete-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('Search fruits...'), 'a'); + + const positioner = document.querySelector('[data-cl-slot="autocomplete-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('disabled option', () => { + it('renders disabled option with data-cl-disabled', () => { + render( + + + + + + Apple + + + Banana + + + + , + ); + + const disabledOption = screen.getByText('Banana').closest('[data-cl-slot="autocomplete-option"]'); + expect(disabledOption).toHaveAttribute('data-cl-disabled', ''); + expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not select disabled option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + + Apple + + + Banana + + + + , + ); + + await user.click(screen.getByText('Banana')); + + expect(onValueChange).not.toHaveBeenCalledWith('banana'); + }); + + it('does not select a disabled option via Enter', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + + Apple + + + Banana + + + + , + ); + + const input = screen.getByPlaceholderText('Search...'); + // Typing opens the list and highlights the first option (the disabled "Apple"). + await user.type(input, 'a'); + const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Apple'); + + await user.keyboard('{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe('Autocomplete.List (inline mode)', () => { + function InlineAutocomplete(props: { value?: string; onValueChange?: (value: string) => void } = {}) { + const [inputValue, setInputValue] = useState(''); + const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); + + return ( + + + + {filtered.map(f => ( + + {f.label} + + ))} + + + ); + } + + it('renders options with data-cl-slot', () => { + render(); + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options).toHaveLength(4); + }); + + it('renders list with data-cl-slot', () => { + render(); + expect(document.querySelector('[data-cl-slot="autocomplete-list"]')).toBeInTheDocument(); + }); + + it('marks selected option with data-cl-selected via controlled value', () => { + render(); + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options[1]).toHaveAttribute('data-cl-selected', ''); + }); + + it('selects option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Banana')); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + }); + + it('navigates options with arrow keys', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.click(input); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + + it('links the input to the inline listbox with aria-controls', () => { + render(); + + const input = screen.getByRole('combobox'); + const list = document.querySelector('[data-cl-slot="autocomplete-list"]'); + + expect(list).toHaveAttribute('id'); + expect(input).toHaveAttribute('aria-controls', list?.getAttribute('id')); + }); + + it('keeps the input/listbox aria-controls pairing intact when a custom id is passed to the list', () => { + render( + + + + + Apple + + + , + ); + + const input = screen.getByRole('combobox'); + const list = document.querySelector('[data-cl-slot="autocomplete-list"]'); + + // The listbox id is owned by floating-ui: a consumer-supplied id must not + // override it, or the input's aria-controls pairing would silently break. + expect(list).not.toHaveAttribute('id', 'consumer-custom-id'); + expect(input).toHaveAttribute('aria-controls', list?.getAttribute('id')); + }); + + it('updates aria-activedescendant during keyboard navigation', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(activeOption).toHaveAttribute('id'); + expect(input).toHaveAttribute('aria-activedescendant', activeOption?.getAttribute('id')); + }); + + it('selects option on Enter after arrow navigation', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.click(input); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(onValueChange).toHaveBeenCalled(); + }); + + it('filters options based on input', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'ch'); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Cherry'); + }); + + it('preserves selected state after unmount and remount', () => { + const { unmount } = render(); + + let options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options[2]).toHaveAttribute('data-cl-selected', ''); + + unmount(); + + render(); + + options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options[2]).toHaveAttribute('data-cl-selected', ''); + }); + + it('shows selected state after selecting then remounting', async () => { + function TestHarness() { + const [mounted, setMounted] = useState(true); + const [value, setValue] = useState(); + + return ( + <> + setMounted(m => !m)} + /> + {mounted && ( + + )} + > + ); + } + + const user = userEvent.setup(); + render(); + + // Select banana + await user.click(screen.getByText('Banana')); + + // Unmount (simulates popover close) + await user.click(screen.getByTestId('toggle')); + expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument(); + + // Remount (simulates popover reopen) + await user.click(screen.getByTestId('toggle')); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options[1]).toHaveAttribute('data-cl-selected', ''); + }); + }); + + describe('Autocomplete.List inside Popover', () => { + function AutocompleteInPopover() { + const [popoverOpen, setPopoverOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(); + const [inputValue, setInputValue] = useState(''); + const selectedLabel = fruits.find(f => f.value === selectedValue)?.label; + const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); + + return ( + { + setPopoverOpen(open); + if (open) { + setInputValue(''); + } + }} + > + {selectedLabel || 'Pick a fruit...'} + + + { + setSelectedValue(value); + setPopoverOpen(false); + }} + > + + + {filtered.map(f => ( + + {f.label} + + ))} + + + + + + ); + } + + it('renders options when popover is open', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options).toHaveLength(4); + }); + + it('wires the popover autocomplete input to the inline listbox', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + const input = screen.getByRole('combobox'); + const list = document.querySelector('[data-cl-slot="autocomplete-list"]'); + + expect(list).toHaveAttribute('id'); + expect(input).toHaveAttribute('aria-controls', list?.getAttribute('id')); + }); + + it('navigates options with arrow keys inside popover', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + // Verify options rendered and input has focus + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options.length).toBeGreaterThan(0); + + const input = screen.getByPlaceholderText('Search...'); + expect(document.activeElement).toBe(input); + + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + + it('selects option on click and updates trigger', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Banana')); + + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.getByText('Banana').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); + }); + + it('shows data-cl-selected on previously selected option after reopen', async () => { + const user = userEvent.setup(); + render(); + + // Open and select + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Cherry')); + + // Reopen + await user.click(screen.getByText('Cherry')); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const cherryOption = Array.from(options).find(o => o.textContent === 'Cherry'); + expect(cherryOption).toHaveAttribute('data-cl-selected', ''); + }); + + it('selects option with Enter after arrow navigation inside popover', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + await user.keyboard('{ArrowDown}{Enter}'); + + // Should have selected the first option + expect(screen.getByText('Apple').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); + }); + }); + + describe('scrolling', () => { + const manyFruits = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + { value: 'elderberry', label: 'Elderberry' }, + { value: 'fig', label: 'Fig' }, + { value: 'grape', label: 'Grape' }, + { value: 'honeydew', label: 'Honeydew' }, + ]; + + function ScrollableAutocomplete({ defaultValue }: { defaultValue?: string }) { + const [popoverOpen, setPopoverOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(defaultValue); + const [inputValue, setInputValue] = useState(''); + const selectedLabel = manyFruits.find(f => f.value === selectedValue)?.label; + const filtered = manyFruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); + + return ( + { + setPopoverOpen(open); + if (open) { + setInputValue(''); + } + }} + > + {selectedLabel || 'Pick a fruit...'} + + + { + setSelectedValue(value); + setPopoverOpen(false); + }} + > + + + {filtered.map(f => ( + + {f.label} + + ))} + + + + + + ); + } + + it('sets active index on arrow key navigation', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + + it('selected item has data-cl-active on reopen', async () => { + const user = userEvent.setup(); + render(); + + // Open and select "Grape" (6th item) + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Grape')); + + // Reopen — trigger now shows "Grape" + await user.click(screen.getByText('Grape').closest('[data-cl-slot="popover-trigger"]')!); + + // The selected option should be active + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const grapeOption = Array.from(options).find(o => o.textContent === 'Grape'); + expect(grapeOption).toHaveAttribute('data-cl-active', ''); + }); + + it('selected item is active on open when defaultValue is set', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Grape')); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const grapeOption = Array.from(options).find(o => o.textContent === 'Grape'); + expect(grapeOption).toHaveAttribute('data-cl-active', ''); + }); + }); + + describe('Autocomplete.List inside Popover — edge cases', () => { + function AutocompleteInPopoverFull() { + const [popoverOpen, setPopoverOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(); + const [inputValue, setInputValue] = useState(''); + const selectedLabel = fruits.find(f => f.value === selectedValue)?.label; + const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); + + return ( + { + setPopoverOpen(open); + if (open) { + setInputValue(''); + } + }} + > + {selectedLabel || 'Pick a fruit...'} + + + { + setSelectedValue(value); + setPopoverOpen(false); + }} + > + + + {filtered.map(f => ( + + {f.label} + + ))} + + + + + + ); + } + + it('closes popover on Escape key', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByText('Pick a fruit...'); + await user.click(trigger); + expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]').length).toBeGreaterThan(0); + + await user.keyboard('{Escape}'); + + // Popover should close — no options visible + expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument(); + expect(document.activeElement).toBe(trigger); + }); + + it('focuses the input on open with an empty value', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + const input = screen.getByPlaceholderText('Search...') as HTMLInputElement; + expect(document.activeElement).toBe(input); + expect(input.value).toBe(''); + }); + + it('keeps the input empty on open even when a selected item exists', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Cherry')); + + await user.click(screen.getByText('Cherry')); + + const input = screen.getByPlaceholderText('Search...') as HTMLInputElement; + expect(document.activeElement).toBe(input); + expect(input.value).toBe(''); + }); + + it('marks the previously selected item as active on reopen while focus stays on the input', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Banana')); + + await user.click(screen.getByText('Banana')); + + const input = screen.getByPlaceholderText('Search...'); + const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + + expect(document.activeElement).toBe(input); + expect(active).toHaveTextContent('Banana'); + expect(input).toHaveAttribute('aria-activedescendant', active?.getAttribute('id')); + }); + + it('jumps to selected item on reopen even when it was filtered away before closing', async () => { + const user = userEvent.setup(); + render(); + + // Select Banana + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Banana')); + + // Reopen + await user.click(screen.getByText('Banana')); + + // Filter to only Banana (Banana is now at index 0) + const input = screen.getByPlaceholderText('Search...'); + await user.type(input, 'b'); + + // Close with Escape (selectedIndex is 0 due to filtering) + await user.keyboard('{Escape}'); + + // Reopen — input is cleared, all 4 options visible + await user.click(screen.getByText('Banana')); + + // Banana (index 1) should be active, not Apple (index 0) + const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Banana'); + }); + + it('clears input on reopen after Escape', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + const input = screen.getByPlaceholderText('Search...'); + await user.type(input, 'ch'); + + // Should be filtered to Cherry only + expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(1); + + await user.keyboard('{Escape}'); + + // Reopen + await user.click(screen.getByText('Pick a fruit...')); + + // Input should be cleared, all options visible + const newInput = screen.getByPlaceholderText('Search...'); + expect((newInput as HTMLInputElement).value).toBe(''); + expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4); + }); + + it('navigates all options with repeated ArrowDown', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + await user.keyboard('{ArrowDown}'); + let active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Apple'); + + await user.keyboard('{ArrowDown}'); + active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Banana'); + + await user.keyboard('{ArrowDown}'); + active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Cherry'); + + await user.keyboard('{ArrowDown}'); + active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Date'); + }); + + it('loops navigation with ArrowDown past last option', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + // Navigate past all 4 options to loop back + await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}'); + const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Apple'); + }); + + it('navigates with ArrowUp', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + // ArrowUp from no active item should go to last item (loop) + await user.keyboard('{ArrowUp}'); + const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Date'); + }); + + it('selects with Enter after filtering and navigating', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + const input = screen.getByPlaceholderText('Search...'); + await user.type(input, 'b'); + + // Only Banana should be shown, activeIndex should be 0 + expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(1); + + await user.keyboard('{Enter}'); + + // Popover should close and trigger should show Banana + expect(screen.getByText('Banana').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); + }); + + it('selects the highlighted item with Enter, closes the popover, and returns focus to the trigger', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByText('Pick a fruit...'); + await user.click(trigger); + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + + const updatedTrigger = screen.getByText('Banana'); + expect(updatedTrigger.closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument(); + expect(document.activeElement).toBe(updatedTrigger); + }); + + it('keeps focus on input during keyboard navigation', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + const input = screen.getByPlaceholderText('Search...'); + + await user.keyboard('{ArrowDown}{ArrowDown}'); + + expect(document.activeElement).toBe(input); + }); + + it('can type to filter after arrow navigation', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + + // Navigate first + await user.keyboard('{ArrowDown}{ArrowDown}'); + + // Then type to filter + const input = screen.getByPlaceholderText('Search...'); + await user.type(input, 'd'); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Date'); + }); + + it('reopen after selection shows all options with empty input', async () => { + const user = userEvent.setup(); + render(); + + // Select Cherry + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Cherry')); + + // Reopen + await user.click(screen.getByText('Cherry')); + + // Input should be empty, all options should show + const input = screen.getByPlaceholderText('Search...'); + expect((input as HTMLInputElement).value).toBe(''); + expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4); + }); + + it('selected option retains data-cl-selected on reopen', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Pick a fruit...')); + await user.click(screen.getByText('Banana')); + + // Reopen + await user.click(screen.getByText('Banana')); + + const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const bananaOption = Array.from(options).find(o => o.textContent === 'Banana'); + expect(bananaOption).toHaveAttribute('data-cl-selected', ''); + }); + }); + + describe('input state attributes', () => { + it('input has data-cl-open when list is visible', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + await user.type(input, 'a'); + + expect(input).toHaveAttribute('data-cl-open', ''); + }); + + it('input has data-cl-closed when list is hidden', () => { + render(); + + const input = screen.getByPlaceholderText('Search fruits...'); + expect(input).toHaveAttribute('data-cl-closed', ''); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByPlaceholderText('Search fruits...')); + await user.keyboard('a'); + + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + + it('has no violations for the inline listbox inside a popover', async () => { + const user = userEvent.setup(); + render( + + Pick a fruit... + + + Fruit picker + + + + {fruits.map(f => ( + + {f.label} + + ))} + + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); + + describe('option id protection', () => { + it('does not let a consumer id override the primitive-owned option id', () => { + render( + + + + + + Apple + + + + , + ); + + const option = screen.getByRole('option'); + expect(option.id).not.toBe('consumer-id'); + expect(option.id).not.toBe(''); + }); + }); +}); diff --git a/packages/headless/src/primitives/autocomplete/index.ts b/packages/headless/src/primitives/autocomplete/index.ts new file mode 100644 index 00000000000..e282f71bbda --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/index.ts @@ -0,0 +1,11 @@ +export * as Autocomplete from './parts'; +export type { + AutocompleteArrowProps, + AutocompleteInputProps, + AutocompleteListProps, + AutocompleteOptionProps, + AutocompletePopupProps, + AutocompletePortalProps, + AutocompletePositionerProps, + AutocompleteProps, +} from './parts'; diff --git a/packages/headless/src/primitives/autocomplete/parts.ts b/packages/headless/src/primitives/autocomplete/parts.ts new file mode 100644 index 00000000000..8d184c6c6d3 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/parts.ts @@ -0,0 +1,8 @@ +export { type AutocompleteProps, AutocompleteRoot as Root } from './autocomplete-root'; +export { type AutocompleteInputProps, AutocompleteInput as Input } from './autocomplete-input'; +export { type AutocompletePortalProps, AutocompletePortal as Portal } from './autocomplete-portal'; +export { type AutocompletePositionerProps, AutocompletePositioner as Positioner } from './autocomplete-positioner'; +export { type AutocompletePopupProps, AutocompletePopup as Popup } from './autocomplete-popup'; +export { type AutocompleteListProps, AutocompleteList as List } from './autocomplete-list'; +export { type AutocompleteOptionProps, AutocompleteOption as Option } from './autocomplete-option'; +export { type AutocompleteArrowProps, AutocompleteArrow as Arrow } from './autocomplete-arrow'; diff --git a/packages/headless/src/primitives/collapsible/README.md b/packages/headless/src/primitives/collapsible/README.md new file mode 100644 index 00000000000..3382bf6c8cd --- /dev/null +++ b/packages/headless/src/primitives/collapsible/README.md @@ -0,0 +1,110 @@ +# Collapsible + +A single show/hide panel toggled by a button. Supports controlled/uncontrolled state, disabled state, and CSS-driven expand/collapse animations. + +## When to Use + +- Inline content toggles: show more text, advanced options, filter panels. +- When you need a single toggle rather than a stacked accordion. +- Prefer Collapsible over manual `display: none` — it handles ARIA attributes and animation lifecycle automatically. + +## Usage + +```tsx +import { Collapsible } from '@/primitives/collapsible'; + + + Toggle + Hidden content +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + Toggle + Hidden content +; +``` + +### Disabled + +```tsx + + Toggle + Hidden content + +``` + +## Parts + +| Part | Default Element | Description | +| --------------------- | --------------- | ---------------------------------- | +| `Collapsible.Root` | `` | Root wrapper, provides context | +| `Collapsible.Trigger` | `` | Clickable toggle that opens/closes | +| `Collapsible.Panel` | `` | Collapsible content area | + +## Props + +### `Collapsible.Root` + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ------- | ---------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `disabled` | `boolean` | `false` | Prevents the trigger from toggling | + +### `Collapsible.Trigger` + +No additional props. Renders as `` by default. + +### `Collapsible.Panel` + +No additional props. Renders as `` by default. + +All parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their default element. + +## Data Attributes + +| Attribute | Applies To | Description | +| ------------------ | -------------------- | -------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"collapsible-panel"`) | +| `data-cl-open` | Root, Trigger, Panel | Present when the panel is open | +| `data-cl-closed` | Root, Trigger, Panel | Present when the panel is closed | +| `data-cl-disabled` | Root, Trigger | Present when disabled | + +## CSS Animation + +`Collapsible.Panel` exposes CSS custom properties set to the panel's measured dimensions: + +| Property | Value | +| ---------------------------- | --------------------------- | +| `--collapsible-panel-height` | `scrollHeight` of the panel | +| `--collapsible-panel-width` | `scrollWidth` of the panel | + +Use these for height/width-based animations: + +```css +[data-cl-slot='collapsible-panel'] { + overflow: hidden; + height: var(--collapsible-panel-height); + transition: height 200ms ease; +} +[data-cl-slot='collapsible-panel'][data-cl-closed] { + height: 0; +} +``` + +The panel suppresses the enter animation on initial mount — only subsequent opens animate. + +## ARIA + +- Trigger: `aria-expanded`, `aria-controls` (pointing to its panel), `aria-disabled` +- Panel: `role="region"`, `aria-labelledby` (pointing to its trigger) diff --git a/packages/headless/src/primitives/collapsible/collapsible-context.ts b/packages/headless/src/primitives/collapsible/collapsible-context.ts new file mode 100644 index 00000000000..bab45ad7cfa --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-context.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +export interface CollapsibleContextValue { + open: boolean; + setOpen: (v: boolean) => void; + disabled: boolean; + triggerId: string; + panelId: string; +} + +export const CollapsibleContext = createContext(null); + +export function useCollapsibleContext() { + const ctx = useContext(CollapsibleContext); + if (!ctx) { + throw new Error('Collapsible compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/collapsible/collapsible-panel.tsx b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx new file mode 100644 index 00000000000..17ae3688c0e --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react'; + +import { useTransition } from '../../hooks/use-transition'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useCollapsibleContext } from './collapsible-context'; + +export type CollapsiblePanelProps = ComponentProps<'div'>; + +export const CollapsiblePanel = React.forwardRef( + function CollapsiblePanel(props, ref) { + const { render, ...otherProps } = props; + const { open, triggerId, panelId } = useCollapsibleContext(); + + const panelRef = useRef(null); + // Merge the consumer ref with the internal panelRef so passing a ref does not + // clobber the ref the panel relies on for height measurement. + const combinedRef = useMergeRefs([panelRef, ref]); + const [height, setHeight] = useState(undefined); + const [width, setWidth] = useState(undefined); + + const hasBeenClosed = useRef(false); + if (!open) { + hasBeenClosed.current = true; + } + + const { mounted, transitionProps } = useTransition({ + open, + ref: panelRef as RefObject, + }); + + useLayoutEffect(() => { + if (!mounted) { + return; + } + + const panel = panelRef.current; + if (!panel) { + return; + } + + const measure = () => { + setHeight(panel.scrollHeight); + setWidth(panel.scrollWidth); + }; + + measure(); + + const ro = new ResizeObserver(measure); + ro.observe(panel, { box: 'border-box' }); + + return () => ro.disconnect(); + }, [mounted]); + + const state = { open }; + + const effectiveTransitionProps = !hasBeenClosed.current + ? { + ...transitionProps, + 'data-cl-starting-style': undefined, + style: undefined, + } + : transitionProps; + + const defaultProps: Record = { + 'data-cl-slot': 'collapsible-panel', + id: panelId, + role: 'region' as const, + 'aria-labelledby': triggerId, + ref: combinedRef, + ...effectiveTransitionProps, + style: { + '--collapsible-panel-height': height != null ? `${height}px` : undefined, + '--collapsible-panel-width': width != null ? `${width}px` : undefined, + ...effectiveTransitionProps.style, + }, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the trigger/panel aria pairing would silently break. + merged.id = panelId; + + return renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: merged, + }); + }, +); diff --git a/packages/headless/src/primitives/collapsible/collapsible-root.tsx b/packages/headless/src/primitives/collapsible/collapsible-root.tsx new file mode 100644 index 00000000000..4c94e060e75 --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-root.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { type ReactNode, useId, useMemo } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { CollapsibleContext, type CollapsibleContextValue } from './collapsible-context'; + +export interface CollapsibleProps extends ComponentProps<'div'> { + /** Controlled open state. */ + open?: boolean; + /** Initial open state (uncontrolled). */ + defaultOpen?: boolean; + /** Called when open state changes. */ + onOpenChange?: (open: boolean) => void; + /** Disable the collapsible. @default false */ + disabled?: boolean; + children: ReactNode; +} + +export function CollapsibleRoot(props: CollapsibleProps) { + const { render, open: openProp, defaultOpen, onOpenChange, disabled = false, children, ...otherProps } = props; + + const [open, setOpen] = useControllableState(openProp, defaultOpen ?? false, onOpenChange); + + const rootId = useId(); + const triggerId = `${rootId}trigger`; + const panelId = `${rootId}panel`; + + const contextValue = useMemo( + () => ({ open, setOpen, disabled, triggerId, panelId }), + [open, setOpen, disabled, triggerId, panelId], + ); + + const state = { open, disabled }; + + const defaultProps: Record = { + 'data-cl-slot': 'collapsible-root', + children, + }; + + return ( + + {renderElement({ + defaultTagName: 'div', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + })} + + ); +} diff --git a/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx new file mode 100644 index 00000000000..63d9555e973 --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useCollapsibleContext } from './collapsible-context'; + +export type CollapsibleTriggerProps = ComponentProps<'button'>; + +export function CollapsibleTrigger(props: CollapsibleTriggerProps) { + const { render, ...otherProps } = props; + const { open, setOpen, disabled, triggerId, panelId } = useCollapsibleContext(); + + const state = { open, disabled }; + + const defaultProps: Record = { + 'data-cl-slot': 'collapsible-trigger', + id: triggerId, + type: 'button' as const, + 'aria-expanded': open, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + onClick: () => { + if (!disabled) { + setOpen(!open); + } + }, + }; + + const merged = mergeProps<'button'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the trigger/panel aria pairing would silently break. + merged.id = triggerId; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: merged, + }); +} diff --git a/packages/headless/src/primitives/collapsible/collapsible.test.tsx b/packages/headless/src/primitives/collapsible/collapsible.test.tsx new file mode 100644 index 00000000000..8d1683aa9aa --- /dev/null +++ b/packages/headless/src/primitives/collapsible/collapsible.test.tsx @@ -0,0 +1,207 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Collapsible } from './index'; + +afterEach(() => cleanup()); + +function renderCollapsible(props: Partial> = {}) { + return render( + + Toggle + Content + , + ); +} + +describe('Collapsible', () => { + describe('slot attributes', () => { + it('renders root with data-cl-slot', () => { + renderCollapsible(); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toBeInTheDocument(); + }); + + it('renders trigger with data-cl-slot', () => { + renderCollapsible(); + expect(document.querySelector('[data-cl-slot="collapsible-trigger"]')).toBeInTheDocument(); + }); + + it('renders panel with data-cl-slot when open', () => { + renderCollapsible({ defaultOpen: true }); + expect(document.querySelector('[data-cl-slot="collapsible-panel"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens panel on trigger click', async () => { + const user = userEvent.setup(); + renderCollapsible(); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(screen.getByText('Content')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-open', ''); + }); + + it('closes panel on second trigger click', async () => { + const user = userEvent.setup(); + renderCollapsible({ defaultOpen: true }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderCollapsible({ onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('starts closed by default', () => { + renderCollapsible(); + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-closed', ''); + }); + + it('starts open with defaultOpen=true', () => { + renderCollapsible({ defaultOpen: true }); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + }); + + describe('controlled value', () => { + it('respects controlled open prop', () => { + renderCollapsible({ open: true }); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('does not change when controlled', async () => { + const user = userEvent.setup(); + renderCollapsible({ open: false }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(screen.queryByText('Content')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('trigger has aria-expanded=false when closed', () => { + renderCollapsible(); + expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-expanded', 'false'); + }); + + it('trigger has aria-expanded=true when open', () => { + renderCollapsible({ defaultOpen: true }); + expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-expanded', 'true'); + }); + + it('trigger has aria-controls linked to panel id', () => { + renderCollapsible({ defaultOpen: true }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(trigger).toHaveAttribute('aria-controls', panel?.getAttribute('id')); + }); + + it('panel has aria-labelledby linked to trigger id', () => { + renderCollapsible({ defaultOpen: true }); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(panel).toHaveAttribute('aria-labelledby', trigger.getAttribute('id')); + }); + + it('panel has role=region', () => { + renderCollapsible({ defaultOpen: true }); + expect(screen.getByRole('region')).toBeInTheDocument(); + }); + + it('keeps trigger/panel association intact when a custom id is passed to the trigger', () => { + render( + + Toggle + Content + , + ); + const trigger = screen.getByRole('button', { name: 'Toggle' }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + + // The wired ids are owned by the primitive: a consumer-supplied id must + // not silently break the aria pairing between trigger and panel. + expect(panel).toHaveAttribute('aria-labelledby', trigger.getAttribute('id')); + expect(trigger).toHaveAttribute('aria-controls', panel?.getAttribute('id')); + }); + }); + + describe('animation lifecycle', () => { + it('panel is not in DOM when closed', () => { + renderCollapsible(); + expect(document.querySelector('[data-cl-slot="collapsible-panel"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on panel when open', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(panel).toHaveAttribute('data-cl-open', ''); + }); + + it('does not apply starting-style on initially open panel', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + expect(panel).not.toHaveAttribute('data-cl-starting-style'); + }); + + it('sets --collapsible-panel-height CSS variable on panel', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]') as HTMLElement; + expect(panel.getAttribute('style')).toContain('--collapsible-panel-height'); + }); + + it('sets --collapsible-panel-width CSS variable on panel', () => { + renderCollapsible({ defaultOpen: true }); + const panel = document.querySelector('[data-cl-slot="collapsible-panel"]') as HTMLElement; + expect(panel.getAttribute('style')).toContain('--collapsible-panel-width'); + }); + }); + + describe('disabled', () => { + it('prevents toggle when disabled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderCollapsible({ disabled: true, onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Toggle' })); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('applies aria-disabled on trigger', () => { + renderCollapsible({ disabled: true }); + expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('applies data-cl-disabled on root and trigger', () => { + renderCollapsible({ disabled: true }); + expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-disabled', ''); + expect(document.querySelector('[data-cl-slot="collapsible-trigger"]')).toHaveAttribute('data-cl-disabled', ''); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderCollapsible(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + const { container } = renderCollapsible({ defaultOpen: true }); + expect(await axe(container)).toHaveNoViolations(); + }); + }); +}); diff --git a/packages/headless/src/primitives/collapsible/index.ts b/packages/headless/src/primitives/collapsible/index.ts new file mode 100644 index 00000000000..c1ad32a41fe --- /dev/null +++ b/packages/headless/src/primitives/collapsible/index.ts @@ -0,0 +1,3 @@ +export * as Collapsible from './parts'; + +export type { CollapsibleProps, CollapsibleTriggerProps, CollapsiblePanelProps } from './parts'; diff --git a/packages/headless/src/primitives/collapsible/parts.ts b/packages/headless/src/primitives/collapsible/parts.ts new file mode 100644 index 00000000000..d09f50c23ac --- /dev/null +++ b/packages/headless/src/primitives/collapsible/parts.ts @@ -0,0 +1,3 @@ +export { type CollapsibleProps, CollapsibleRoot as Root } from './collapsible-root'; +export { type CollapsibleTriggerProps, CollapsibleTrigger as Trigger } from './collapsible-trigger'; +export { type CollapsiblePanelProps, CollapsiblePanel as Panel } from './collapsible-panel'; diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 665872aad92..54e173127f2 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -1,14 +1,15 @@ 'use client'; import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; import { useDialogContext } from './dialog-context'; export type DialogPopupProps = ComponentProps<'div'>; -export function DialogPopup(props: DialogPopupProps) { - const { render, ref: consumerRef, ...otherProps } = props; +export const DialogPopup = React.forwardRef(function DialogPopup(props, ref) { + const { render, ...otherProps } = props; const { popupRef, refs, getFloatingProps, floatingContext, modal, labelId, descriptionId, mounted, transitionProps } = useDialogContext(); @@ -16,7 +17,7 @@ export function DialogPopup(props: DialogPopupProps) { // a stable callback that doesn't use `this`, so the unbound-method check is a // false positive here. // eslint-disable-next-line @typescript-eslint/unbound-method - const combinedRef = useMergeRefs([popupRef, refs.setFloating, consumerRef]); + const combinedRef = useMergeRefs([popupRef, refs.setFloating, ref]); if (!mounted) { return null; @@ -43,4 +44,4 @@ export function DialogPopup(props: DialogPopupProps) { })} ); -} +}); diff --git a/packages/headless/src/primitives/dialog/dialog-trigger.tsx b/packages/headless/src/primitives/dialog/dialog-trigger.tsx index 90a12a81506..47c7b890f1b 100644 --- a/packages/headless/src/primitives/dialog/dialog-trigger.tsx +++ b/packages/headless/src/primitives/dialog/dialog-trigger.tsx @@ -1,38 +1,41 @@ 'use client'; import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; import { useDialogContext } from './dialog-context'; export type DialogTriggerProps = ComponentProps<'button'>; -export function DialogTrigger(props: DialogTriggerProps) { - const { render, ref: consumerRef, ...otherProps } = props; - const { open, refs, getReferenceProps } = useDialogContext(); - - // floating-ui types `setReference` as a method signature, but at runtime it's - // a stable callback that doesn't use `this`, so the unbound-method check is a - // false positive here. - // eslint-disable-next-line @typescript-eslint/unbound-method - const combinedRef = useMergeRefs([refs.setReference, consumerRef]); - - const state = { open }; - - const defaultProps = { - type: 'button' as const, - 'data-cl-slot': 'dialog-trigger', - ref: combinedRef, - ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), - }; - - return renderElement({ - defaultTagName: 'button', - render, - state, - stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - }, - props: mergeProps<'button'>(defaultProps, otherProps), - }); -} +export const DialogTrigger = React.forwardRef( + function DialogTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useDialogContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'dialog-trigger', + ref: combinedRef, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/menu/README.md b/packages/headless/src/primitives/menu/README.md new file mode 100644 index 00000000000..b07feffab81 --- /dev/null +++ b/packages/headless/src/primitives/menu/README.md @@ -0,0 +1,157 @@ +# Menu + +A dropdown menu with keyboard navigation, typeahead, and nested submenu support. Handles ARIA roles, safe hover zones for submenus, and tree-level close-on-click. + +## When to Use + +- Action menus, context menus, dropdown menus attached to a button trigger. +- When you need nested submenus with safe pointer zones between trigger and submenu. +- Prefer Menu over Popover when the content is a list of actions/commands rather than arbitrary content. + +## Usage + +```tsx +import { Menu } from '@/primitives/menu'; + + + Actions + + + handleEdit()} + /> + handleDuplicate()} + /> + + handleDelete()} + /> + + +; +``` + +### Nested Submenus + +Nest a `` inside a parent `` — the nested trigger automatically renders as a `menuitem` and opens on hover with a safe polygon zone. + +```tsx + + Actions + + + + + Share + + + + + + + + + + +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + {/* ... */} +; +``` + +## Parts + +| Part | Default Element | Description | +| ----------------- | --------------- | -------------------------------------- | +| `Menu.Root` | — | Root context provider | +| `Menu.Trigger` | `` | Opens/closes the menu | +| `Menu.Portal` | — | Portals children (accepts `root` prop) | +| `Menu.Positioner` | `` | Floating positioned container | +| `Menu.Popup` | `` | Visual wrapper for menu items | +| `Menu.Item` | `` | A menu action item | +| `Menu.Separator` | `` | Visual divider between items | +| `Menu.Arrow` | `` | Optional floating arrow | + +## Props + +### `Menu.Root` + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ------------------------------------------------- | ---------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"bottom-start"` (root), `"right-start"` (nested) | Floating UI placement | +| `sideOffset` | `number` | `4` (root), `0` (nested) | Gap between trigger and popup (px) | + +### `Menu.Item` + +| Prop | Type | Default | Description | +| -------------- | --------- | ------------ | ------------------------------------------------------ | +| `label` | `string` | **required** | Item text, also used for typeahead matching | +| `disabled` | `boolean` | — | Prevents click handler, keeps item focusable | +| `closeOnClick` | `boolean` | `true` | Whether clicking this item closes the entire menu tree | + +### `Menu.Trigger`, `Menu.Positioner`, `Menu.Popup`, `Menu.Separator` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Menu.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard Navigation + +| Key | Action | +| ----------------- | -------------------------------------- | +| `ArrowDown` | Move to next item | +| `ArrowUp` | Move to previous item | +| `ArrowRight` | Open nested submenu | +| `ArrowLeft` | Close nested submenu, return to parent | +| `Enter` / `Space` | Activate the focused item | +| `Escape` | Close the current menu level | +| Type a character | Jump to matching item (typeahead) | + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ------------------------------------ | +| `data-cl-slot` | All parts | Part identifier (e.g. `"menu-item"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Menu open state | +| `data-cl-active` | Item | Keyboard-focused item | +| `data-cl-disabled` | Item | Disabled item | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Nested Menu Behavior + +- Nested menus open on hover (75ms delay) with a `safePolygon` safe zone. +- Only one sibling submenu can be open at a time. +- Clicking any item with `closeOnClick={true}` (default) closes the entire menu tree via a tree event. +- `Escape` closes the innermost menu first, bubbling up through the tree. + +## Important Notes + +- **No built-in animations.** The positioner simply mounts/unmounts. Use `data-cl-open`/`data-cl-closed` for CSS-driven transitions. +- **Disabled items use `aria-disabled`, not `disabled`.** They remain focusable for keyboard users. +- **`label` is required on `Menu.Item`** — it drives typeahead matching. Disabled items are excluded from typeahead. + +## ARIA + +- Popup: `role="menu"` +- Item: `role="menuitem"`, `aria-disabled` +- Separator: `role="separator"` +- Trigger: `aria-expanded`, `aria-haspopup="menu"`, `aria-controls` +- Nested trigger: `role="menuitem"` (instead of button) diff --git a/packages/headless/src/primitives/menu/index.ts b/packages/headless/src/primitives/menu/index.ts new file mode 100644 index 00000000000..5ba4fb08c83 --- /dev/null +++ b/packages/headless/src/primitives/menu/index.ts @@ -0,0 +1,12 @@ +export * as Menu from './parts'; + +export type { + MenuArrowProps, + MenuItemProps, + MenuPopupProps, + MenuPortalProps, + MenuPositionerProps, + MenuProps, + MenuSeparatorProps, + MenuTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/menu/menu-arrow.tsx b/packages/headless/src/primitives/menu/menu-arrow.tsx new file mode 100644 index 00000000000..a7a1f9d1813 --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-arrow.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FloatingArrow } from '@floating-ui/react'; +import React from 'react'; + +import { useMenuContext } from './menu-context'; + +export type MenuArrowProps = React.ComponentPropsWithRef; + +export function MenuArrow(props: MenuArrowProps) { + const { floatingContext, arrowRef, placement } = useMenuContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} diff --git a/packages/headless/src/primitives/menu/menu-context.ts b/packages/headless/src/primitives/menu/menu-context.ts new file mode 100644 index 00000000000..1d3b464b08a --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-context.ts @@ -0,0 +1,41 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface MenuContextValue { + open: boolean; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + getItemProps: UseInteractionsReturn['getItemProps']; + activeIndex: number | null; + setActiveIndex: React.Dispatch>; + elementsRef: React.MutableRefObject>; + labelsRef: React.MutableRefObject>; + arrowRef: React.MutableRefObject; + popupRef: React.RefObject; + isNested: boolean; + mounted: boolean; + transitionProps: TransitionProps; + parentContext: MenuContextValue | null; +} + +export const MenuContext = createContext(null); + +export function useMenuContext() { + const ctx = useContext(MenuContext); + if (!ctx) { + throw new Error('Menu compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/menu/menu-item.tsx b/packages/headless/src/primitives/menu/menu-item.tsx new file mode 100644 index 00000000000..826c90106f9 --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-item.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useFloatingTree, useListItem, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useMenuContext } from './menu-context'; + +export interface MenuItemProps extends ComponentProps<'button'> { + label: string; + disabled?: boolean; + closeOnClick?: boolean; +} + +export const MenuItem = React.forwardRef(function MenuItem(props, ref) { + const { render, label, disabled, closeOnClick = true, onClick: consumerOnClick, ...otherProps } = props; + // When disabled, omit the consumer onClick entirely so mergeProps doesn't chain it. + // When not disabled, add it back only if it's a function (avoids spreading onClick: undefined + // which would overwrite the internal getItemProps handler via mergeProps). + const safeOtherProps = + !disabled && typeof consumerOnClick === 'function' ? { ...otherProps, onClick: consumerOnClick } : otherProps; + const { activeIndex, getItemProps } = useMenuContext(); + const tree = useFloatingTree(); + const item = useListItem({ label: disabled ? null : label }); + const isActive = item.index === activeIndex; + + const combinedRef = useMergeRefs([item.ref, ref]); + + const state = { + active: isActive, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'menu-item', + type: 'button' as const, + ref: combinedRef, + role: 'menuitem' as const, + tabIndex: isActive ? 0 : -1, + ...(disabled && { 'aria-disabled': true as const }), + ...(getItemProps({ + onClick() { + if (!disabled && closeOnClick) { + tree?.events.emit('click'); + } + }, + }) as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'button'>(defaultProps, safeOtherProps), + }); +}); diff --git a/packages/headless/src/primitives/menu/menu-popup.tsx b/packages/headless/src/primitives/menu/menu-popup.tsx new file mode 100644 index 00000000000..22e5f6ca3c2 --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-popup.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useMenuContext } from './menu-context'; + +export type MenuPopupProps = ComponentProps<'div'>; + +export const MenuPopup = React.forwardRef(function MenuPopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useMenuContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'menu-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/menu/menu-portal.tsx b/packages/headless/src/primitives/menu/menu-portal.tsx new file mode 100644 index 00000000000..9fc33389aec --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { useMenuContext } from './menu-context'; + +export interface MenuPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function MenuPortal(props: MenuPortalProps) { + const { mounted } = useMenuContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/menu/menu-positioner.tsx b/packages/headless/src/primitives/menu/menu-positioner.tsx new file mode 100644 index 00000000000..811adc047a5 --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-positioner.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useMenuContext } from './menu-context'; + +export type MenuPositionerProps = ComponentProps<'div'>; + +export const MenuPositioner = React.forwardRef( + function MenuPositioner(props, ref) { + const { render, ...otherProps } = props; + const { + mounted, + floatingContext, + refs, + floatingStyles, + placement, + getFloatingProps, + elementsRef, + labelsRef, + isNested, + setActiveIndex, + } = useMenuContext(); + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const side = placement.split('-')[0]; + + const floatingProps = getFloatingProps({ + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + const items = elementsRef.current; + if (event.key === 'Home') { + const firstEnabled = items.findIndex(el => el != null && !el.hasAttribute('aria-disabled')); + if (firstEnabled !== -1) { + setActiveIndex(firstEnabled); + } + } else { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item != null && !item.hasAttribute('aria-disabled')) { + setActiveIndex(i); + break; + } + } + } + } + }, + }) as React.ComponentPropsWithRef<'div'>; + + const defaultProps = { + 'data-cl-slot': 'menu-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...floatingProps, + }; + + if (!mounted) { + return null; + } + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The menu id is owned by floating-ui's menu role: a consumer-supplied id must + // not override it, or the trigger's aria-controls pairing would silently break. + if (floatingProps.id != null) { + merged.id = floatingProps.id; + } + + const element = renderElement({ + defaultTagName: 'div', + render, + props: merged, + }); + + return ( + + + {element} + + + ); + }, +); diff --git a/packages/headless/src/primitives/menu/menu-root.tsx b/packages/headless/src/primitives/menu/menu-root.tsx new file mode 100644 index 00000000000..f8ac956d40c --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-root.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + safePolygon, + shift, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useFloatingTree, + useHover, + useInteractions, + useListNavigation, + useRole, + useTypeahead, +} from '@floating-ui/react'; +import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { MenuContext, type MenuContextValue } from './menu-context'; + +export interface MenuProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + children: ReactNode; +} + +function MenuInner(props: MenuProps) { + const { placement: placementProp, sideOffset, children } = props; + + const parentContext = useContext(MenuContext); + const tree = useFloatingTree(); + const nodeId = useFloatingNodeId(); + const parentId = useFloatingParentNodeId(); + const isNested = parentId != null; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const resolvedPlacement = placementProp ?? (isNested ? 'right-start' : 'bottom-start'); + const resolvedOffset = sideOffset ?? (isNested ? 0 : 4); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: resolvedPlacement, + middleware: [ + offset({ + mainAxis: resolvedOffset, + alignmentAxis: isNested ? -4 : 0, + }), + flip(), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset: resolvedOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const hover = useHover(floatingContext, { + enabled: isNested, + delay: { open: 75 }, + handleClose: safePolygon({ blockPointerEvents: true }), + }); + const click = useClick(floatingContext, { + event: 'mousedown', + toggle: !isNested, + ignoreMouse: isNested, + }); + const role = useRole(floatingContext, { role: 'menu' }); + const dismiss = useDismiss(floatingContext, { bubbles: true }); + const listNavigation = useListNavigation(floatingContext, { + listRef: elementsRef, + activeIndex, + nested: isNested, + onNavigate: setActiveIndex, + }); + const typeahead = useTypeahead(floatingContext, { + listRef: labelsRef, + onMatch: open ? setActiveIndex : undefined, + activeIndex, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + hover, + click, + role, + dismiss, + listNavigation, + typeahead, + ]); + + useEffect(() => { + if (!tree) { + return; + } + + function handleTreeClick() { + setOpen(false); + } + + function onSubMenuOpen(event: { nodeId: string; parentId: string }) { + if (event.nodeId !== nodeId && event.parentId === parentId) { + setOpen(false); + } + } + + tree.events.on('click', handleTreeClick); + tree.events.on('menuopen', onSubMenuOpen); + + return () => { + tree.events.off('click', handleTreeClick); + tree.events.off('menuopen', onSubMenuOpen); + }; + }, [tree, nodeId, parentId, setOpen]); + + useEffect(() => { + if (open && tree) { + tree.events.emit('menuopen', { parentId, nodeId }); + } + }, [tree, open, nodeId, parentId]); + + const contextValue = useMemo( + () => ({ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + elementsRef, + labelsRef, + arrowRef, + popupRef, + isNested, + mounted, + transitionProps, + parentContext, + }), + [ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + isNested, + mounted, + transitionProps, + parentContext, + ], + ); + + return ( + + {children} + + ); +} + +export function MenuRoot(props: MenuProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/menu/menu-separator.tsx b/packages/headless/src/primitives/menu/menu-separator.tsx new file mode 100644 index 00000000000..d1990043dac --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-separator.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; + +export type MenuSeparatorProps = ComponentProps<'div'>; + +export function MenuSeparator(props: MenuSeparatorProps) { + const { render, ...otherProps } = props; + + const defaultProps = { + 'data-cl-slot': 'menu-separator', + role: 'separator' as const, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/menu/menu-trigger.tsx b/packages/headless/src/primitives/menu/menu-trigger.tsx new file mode 100644 index 00000000000..40a501e7ad8 --- /dev/null +++ b/packages/headless/src/primitives/menu/menu-trigger.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useListItem, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useMenuContext } from './menu-context'; + +export type MenuTriggerProps = ComponentProps<'button'>; + +export const MenuTrigger = React.forwardRef(function MenuTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, isNested, refs, getReferenceProps, parentContext } = useMenuContext(); + + const item = useListItem(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const mergedRef = useMergeRefs([refs.setReference, isNested ? item.ref : null, ref ?? null]); + + const state = { open }; + + let referenceProps: Record; + + if (isNested && parentContext) { + referenceProps = getReferenceProps(parentContext.getItemProps() as React.HTMLProps); + } else { + referenceProps = getReferenceProps(); + } + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'menu-trigger', + ref: mergedRef, + ...(isNested && { + role: 'menuitem' as const, + tabIndex: parentContext?.activeIndex === item.index ? 0 : -1, + }), + ...(referenceProps as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx new file mode 100644 index 00000000000..765f19a4006 --- /dev/null +++ b/packages/headless/src/primitives/menu/menu.test.tsx @@ -0,0 +1,726 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Menu } from './index'; + +afterEach(() => cleanup()); + +describe('Menu', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + render( + + Actions + + + Cut + + + , + ); + expect(screen.getByText('Actions')).toHaveAttribute('data-cl-slot', 'menu-trigger'); + }); + + it('renders all parts with correct slot attributes when open', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + Paste + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(document.querySelector('[data-cl-slot="menu-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="menu-popup"]')).toBeInTheDocument(); + expect(document.querySelectorAll('[data-cl-slot="menu-item"]')).toHaveLength(2); + expect(document.querySelector('[data-cl-slot="menu-separator"]')).toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('keeps the trigger/menu aria-controls pairing intact when a custom id is passed to the positioner', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + + const trigger = screen.getByText('Actions'); + const positioner = document.querySelector('[data-cl-slot="menu-positioner"]'); + + // The menu id is owned by floating-ui: a consumer-supplied id must not + // override it, or the trigger's aria-controls pairing would silently break. + expect(positioner).not.toHaveAttribute('id', 'consumer-custom-id'); + expect(trigger.getAttribute('aria-controls')).toBe(positioner?.getAttribute('id')); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(screen.getByText('Cut')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', ''); + }); + + it('closes on trigger click when open', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.click(screen.getByText('Actions')); + + expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.keyboard('{Escape}'); + + expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes when item is clicked', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render( + + Actions + + + + Cut + + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.click(screen.getByText('Cut')); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', ''); + }); + + it('does not close on item click when closeOnClick=false', async () => { + const user = userEvent.setup(); + render( + + Actions + + + + Toggle + + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.click(screen.getByText('Toggle')); + + expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', ''); + }); + + it('calls onOpenChange', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('item interaction', () => { + it('fires onClick on item click', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render( + + Actions + + + + Cut + + Copy + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.click(screen.getByText('Cut')); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('items are buttons with role=menuitem', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + + const item = screen.getByText('Cut'); + expect(item.tagName).toBe('BUTTON'); + expect(item).toHaveAttribute('role', 'menuitem'); + }); + }); + + describe('disabled items', () => { + it('marks disabled item with data-cl-disabled', async () => { + const user = userEvent.setup(); + render( + + Actions + + + + Cut + + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(document.querySelector('[data-cl-slot="menu-item"]')).toHaveAttribute('data-cl-disabled', ''); + }); + + it('disabled item has aria-disabled', async () => { + const user = userEvent.setup(); + render( + + Actions + + + + Cut + + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(document.querySelector('[data-cl-slot="menu-item"]')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not close menu when clicking disabled item', async () => { + const user = userEvent.setup(); + render( + + Actions + + + + Cut + + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.click(screen.getByText('Cut')); + + expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', ''); + }); + + it('does not fire the consumer onClick when clicking a disabled item', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render( + + Actions + + + + Delete + + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.click(screen.getByText('Delete')); + + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard navigation', () => { + it('navigates items with ArrowDown', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + Copy + + + , + ); + + await user.click(screen.getByText('Actions')); + await new Promise(r => requestAnimationFrame(r)); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', ''); + }); + + it('navigates items with ArrowUp from last to first', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + Copy + + + , + ); + + await user.click(screen.getByText('Actions')); + await new Promise(r => requestAnimationFrame(r)); + await user.keyboard('{ArrowDown}{ArrowUp}'); + + expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', ''); + }); + + it('Home moves to first item', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + Copy + Paste + + + , + ); + + await user.click(screen.getByText('Actions')); + await new Promise(r => requestAnimationFrame(r)); + await user.keyboard('{ArrowDown}{ArrowDown}{Home}'); + + expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', ''); + }); + + it('End moves to last item', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + Copy + Paste + + + , + ); + + await user.click(screen.getByText('Actions')); + await new Promise(r => requestAnimationFrame(r)); + await user.keyboard('{End}'); + + expect(screen.getByText('Paste')).toHaveAttribute('data-cl-active', ''); + }); + }); + + describe('focus management', () => { + it('returns focus to trigger on close via Escape', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.keyboard('{Escape}'); + + expect(document.activeElement).toBe(screen.getByText('Actions')); + }); + + it('returns focus to trigger on item click', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + await user.click(screen.getByText('Cut')); + + expect(document.activeElement).toBe(screen.getByText('Actions')); + }); + }); + + describe('ARIA attributes', () => { + it('trigger has aria-expanded', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + expect(screen.getByText('Actions')).toHaveAttribute('aria-expanded', 'false'); + + await user.click(screen.getByText('Actions')); + + expect(screen.getByText('Actions')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('popup has role=menu', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('trigger has aria-haspopup', () => { + render( + + Actions + + + Cut + + + , + ); + expect(screen.getByText('Actions')).toHaveAttribute('aria-haspopup'); + }); + + it('items have role=menuitem', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + Copy + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(screen.getAllByRole('menuitem')).toHaveLength(2); + }); + + it('separator has role=separator', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + Paste + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + }); + + describe('nested menus', () => { + it('renders submenu trigger as menuitem', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + Share + + + Email + + + + + + , + ); + + await user.click(screen.getByText('Actions')); + + const shareTrigger = screen.getByText('Share'); + expect(shareTrigger).toHaveAttribute('role', 'menuitem'); + expect(shareTrigger).toHaveAttribute('data-cl-slot', 'menu-trigger'); + }); + + it('opens submenu via controlled open prop', () => { + render( + + Actions + + + Cut + + Share + + + Email + Slack + + + + + + , + ); + + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('Slack')).toBeInTheDocument(); + }); + + it('submenu items close all menus on click', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render( + + Actions + + + + Share + + + + Email + + + + + + + , + ); + + await user.click(screen.getByText('Email')); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('positioner', () => { + it('not rendered when closed', () => { + render( + + Actions + + + Cut + + + , + ); + + expect(document.querySelector('[data-cl-slot="menu-positioner"]')).not.toBeInTheDocument(); + }); + + it('has data-cl-side when open', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(document.querySelector('[data-cl-slot="menu-positioner"]')).toHaveAttribute('data-cl-side'); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = render( + + Actions + + + Cut + Copy + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + const user = userEvent.setup(); + render( + + Actions + + + Cut + Copy + + + , + ); + + await user.click(screen.getByText('Actions')); + + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); +}); diff --git a/packages/headless/src/primitives/menu/parts.ts b/packages/headless/src/primitives/menu/parts.ts new file mode 100644 index 00000000000..4126cd79dc3 --- /dev/null +++ b/packages/headless/src/primitives/menu/parts.ts @@ -0,0 +1,8 @@ +export { type MenuProps, MenuRoot as Root } from './menu-root'; +export { type MenuTriggerProps, MenuTrigger as Trigger } from './menu-trigger'; +export { type MenuPortalProps, MenuPortal as Portal } from './menu-portal'; +export { type MenuPositionerProps, MenuPositioner as Positioner } from './menu-positioner'; +export { type MenuPopupProps, MenuPopup as Popup } from './menu-popup'; +export { type MenuItemProps, MenuItem as Item } from './menu-item'; +export { type MenuSeparatorProps, MenuSeparator as Separator } from './menu-separator'; +export { type MenuArrowProps, MenuArrow as Arrow } from './menu-arrow'; diff --git a/packages/headless/src/primitives/popover/README.md b/packages/headless/src/primitives/popover/README.md new file mode 100644 index 00000000000..7f2060642fd --- /dev/null +++ b/packages/headless/src/primitives/popover/README.md @@ -0,0 +1,111 @@ +# Popover + +A floating panel anchored to a trigger element. Supports focus management, ARIA labeling, and enter/exit animations. + +## When to Use + +- Rich content panels, filter dropdowns, or any non-modal floating content anchored to a trigger. +- When content includes interactive elements (inputs, buttons) — unlike Tooltip which is display-only. +- Prefer Popover over Dialog when the content should be anchored to a specific element and the page should remain interactive by default. + +## Usage + +```tsx +import { Popover } from '@/primitives/popover'; + + + Settings + + + Preferences + Adjust your settings below. + {/* Interactive content here */} + Done + + +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + {/* ... */} +; +``` + +### Modal Mode + +```tsx +{/* Focus is trapped within the popover */} +``` + +## Parts + +| Part | Default Element | Description | +| --------------------- | --------------- | ---------------------------------------- | +| `Popover.Root` | — | Root context provider | +| `Popover.Trigger` | `` | Toggles the popover on click | +| `Popover.Portal` | — | Portals children (accepts `root` prop) | +| `Popover.Positioner` | `` | Floating positioned container | +| `Popover.Popup` | `` | Visual content wrapper | +| `Popover.Arrow` | `` | Optional floating arrow | +| `Popover.Title` | `` | Heading, wired to `aria-labelledby` | +| `Popover.Description` | `` | Description, wired to `aria-describedby` | +| `Popover.Close` | `` | Closes the popover on click | + +## Props + +### `Popover.Root` + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ---------- | ---------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"bottom"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | +| `modal` | `boolean` | `false` | Traps focus within the popover | + +### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Popover.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard + +| Key | Action | +| -------- | -------------------------------------------------------------------- | +| `Escape` | Closes the popover | +| `Tab` | Cycles focus within popover (modal mode) or moves freely (non-modal) | + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"popover-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Open state | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Positioning + +Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popup auto-repositions on scroll and resize via `autoUpdate`. Cross-axis flipping is enabled only when using an aligned placement (e.g. `"bottom-start"`). + +## Important Notes + +- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent. +- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap. +- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically. + +## ARIA + +- Positioner: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description) +- Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls` diff --git a/packages/headless/src/primitives/popover/index.ts b/packages/headless/src/primitives/popover/index.ts new file mode 100644 index 00000000000..8a3021d9abe --- /dev/null +++ b/packages/headless/src/primitives/popover/index.ts @@ -0,0 +1,13 @@ +export * as Popover from './parts'; + +export type { + PopoverArrowProps, + PopoverCloseProps, + PopoverDescriptionProps, + PopoverPopupProps, + PopoverPortalProps, + PopoverPositionerProps, + PopoverProps, + PopoverTitleProps, + PopoverTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/popover/parts.ts b/packages/headless/src/primitives/popover/parts.ts new file mode 100644 index 00000000000..0ac2a6651e8 --- /dev/null +++ b/packages/headless/src/primitives/popover/parts.ts @@ -0,0 +1,9 @@ +export { type PopoverProps, PopoverRoot as Root } from './popover-root'; +export { type PopoverTriggerProps, PopoverTrigger as Trigger } from './popover-trigger'; +export { type PopoverPortalProps, PopoverPortal as Portal } from './popover-portal'; +export { type PopoverPositionerProps, PopoverPositioner as Positioner } from './popover-positioner'; +export { type PopoverPopupProps, PopoverPopup as Popup } from './popover-popup'; +export { type PopoverArrowProps, PopoverArrow as Arrow } from './popover-arrow'; +export { type PopoverTitleProps, PopoverTitle as Title } from './popover-title'; +export { type PopoverDescriptionProps, PopoverDescription as Description } from './popover-description'; +export { type PopoverCloseProps, PopoverClose as Close } from './popover-close'; diff --git a/packages/headless/src/primitives/popover/popover-arrow.tsx b/packages/headless/src/primitives/popover/popover-arrow.tsx new file mode 100644 index 00000000000..438c514b26d --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-arrow.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FloatingArrow, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { usePopoverContext } from './popover-context'; + +export type PopoverArrowProps = Omit, 'context'>; + +export const PopoverArrow = React.forwardRef(function PopoverArrow(props, ref) { + const { floatingContext, arrowRef, placement } = usePopoverContext(); + // Merge the consumer ref with the primitive-owned arrowRef so passing a ref + // does not clobber the ref FloatingArrow relies on for positioning. + const combinedRef = useMergeRefs([arrowRef, ref]); + const side = placement.split('-')[0]; + + return ( + + ); +}); diff --git a/packages/headless/src/primitives/popover/popover-close.tsx b/packages/headless/src/primitives/popover/popover-close.tsx new file mode 100644 index 00000000000..545ee097df6 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-close.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverCloseProps = ComponentProps<'button'>; + +export function PopoverClose(props: PopoverCloseProps) { + const { render, ...otherProps } = props; + const { setOpen } = usePopoverContext(); + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-close', + onClick() { + setOpen(false); + }, + }; + + return renderElement({ + defaultTagName: 'button', + render, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/popover/popover-context.ts b/packages/headless/src/primitives/popover/popover-context.ts new file mode 100644 index 00000000000..de0bdf5f0b0 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-context.ts @@ -0,0 +1,42 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface PopoverContextValue { + open: boolean; + setOpen: (open: boolean) => void; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + modal: boolean; + labelId: string; + descriptionId: string; + hasTitle: boolean; + hasDescription: boolean; + setHasTitle: (v: boolean) => void; + setHasDescription: (v: boolean) => void; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const PopoverContext = createContext(null); + +export function usePopoverContext() { + const ctx = useContext(PopoverContext); + if (!ctx) { + throw new Error('Popover compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/popover/popover-description.tsx b/packages/headless/src/primitives/popover/popover-description.tsx new file mode 100644 index 00000000000..598de543142 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-description.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverDescriptionProps = Omit, 'id'>; + +export function PopoverDescription(props: PopoverDescriptionProps) { + const { render, ...otherProps } = props; + const { descriptionId, setHasDescription } = usePopoverContext(); + + useEffect(() => { + setHasDescription(true); + return () => setHasDescription(false); + }, [setHasDescription]); + + const defaultProps = { + 'data-cl-slot': 'popover-description', + id: descriptionId, + }; + + return renderElement({ + defaultTagName: 'p', + render, + props: mergeProps<'p'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/popover/popover-popup.tsx b/packages/headless/src/primitives/popover/popover-popup.tsx new file mode 100644 index 00000000000..197ebfd9a5a --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-popup.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverPopupProps = ComponentProps<'div'>; + +export const PopoverPopup = React.forwardRef(function PopoverPopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = usePopoverContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'popover-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/popover/popover-portal.tsx b/packages/headless/src/primitives/popover/popover-portal.tsx new file mode 100644 index 00000000000..651b33c93a3 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { usePopoverContext } from './popover-context'; + +export interface PopoverPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function PopoverPortal(props: PopoverPortalProps) { + const { mounted } = usePopoverContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx new file mode 100644 index 00000000000..7e6a8e1abae --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverPositionerProps = ComponentProps<'div'>; + +export const PopoverPositioner = React.forwardRef( + function PopoverPositioner(props, ref) { + const { render, ...otherProps } = props; + const { + mounted, + floatingContext, + refs, + floatingStyles, + placement, + getFloatingProps, + modal, + labelId, + descriptionId, + hasTitle, + hasDescription, + } = usePopoverContext(); + + const side = placement.split('-')[0]; + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const defaultProps = { + 'data-cl-slot': 'popover-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...(hasTitle && { 'aria-labelledby': labelId }), + ...(hasDescription && { 'aria-describedby': descriptionId }), + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + }; + + const element = renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + if (!element) { + return null; + } + + return ( + + {element} + + ); + }, +); diff --git a/packages/headless/src/primitives/popover/popover-root.tsx b/packages/headless/src/primitives/popover/popover-root.tsx new file mode 100644 index 00000000000..3edf3efabb4 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-root.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + shift, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { PopoverContext, type PopoverContextValue } from './popover-context'; + +export interface PopoverProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + modal?: boolean; + children: ReactNode; +} + +function PopoverInner(props: PopoverProps) { + const nodeId = useFloatingNodeId(); + const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const labelId = useId(); + const descriptionId = useId(); + const [hasTitle, setHasTitle] = useState(false); + const [hasDescription, setHasDescription] = useState(false); + const setHasTitleCb = useCallback((v: boolean) => setHasTitle(v), []); + const setHasDescriptionCb = useCallback((v: boolean) => setHasDescription(v), []); + + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ + crossAxis: placementProp.includes('-'), + fallbackAxisSideDirection: 'end', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + popupRef, + arrowRef, + modal, + labelId, + descriptionId, + hasTitle, + hasDescription, + setHasTitle: setHasTitleCb, + setHasDescription: setHasDescriptionCb, + mounted, + transitionProps, + }), + [ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + modal, + labelId, + descriptionId, + hasTitle, + hasDescription, + setHasTitleCb, + setHasDescriptionCb, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function PopoverRoot(props: PopoverProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/popover/popover-title.tsx b/packages/headless/src/primitives/popover/popover-title.tsx new file mode 100644 index 00000000000..62d3605d007 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-title.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverTitleProps = Omit, 'id'>; + +export function PopoverTitle(props: PopoverTitleProps) { + const { render, ...otherProps } = props; + const { labelId, setHasTitle } = usePopoverContext(); + + useEffect(() => { + setHasTitle(true); + return () => setHasTitle(false); + }, [setHasTitle]); + + const defaultProps = { + 'data-cl-slot': 'popover-title', + id: labelId, + }; + + return renderElement({ + defaultTagName: 'h2', + render, + props: mergeProps<'h2'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/popover/popover-trigger.tsx b/packages/headless/src/primitives/popover/popover-trigger.tsx new file mode 100644 index 00000000000..8231e348bed --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-trigger.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverTriggerProps = ComponentProps<'button'>; + +export const PopoverTrigger = React.forwardRef( + function PopoverTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = usePopoverContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-trigger', + ref: combinedRef, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/popover/popover.test.tsx b/packages/headless/src/primitives/popover/popover.test.tsx new file mode 100644 index 00000000000..d27ed3e8ad7 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover.test.tsx @@ -0,0 +1,361 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Popover } from './index'; + +afterEach(() => cleanup()); + +function renderPopover(props: Partial> = {}) { + return render( + + Open popover + + + Popover Title + Some description + Popover content + Close + + + , + ); +} + +describe('Popover', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderPopover(); + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-slot', 'popover-trigger'); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-title"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-description"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-close"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + }); + + it('closes on trigger click when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes via Close button', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + const closeBtn = screen.getByRole('button', { name: 'Close' }); + await user.click(closeBtn); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderPopover({ onOpenChange }); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('closes on outside click', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + + await user.click(document.body); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).not.toBeInTheDocument(); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderPopover({ open: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderPopover({ open: false }); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('positioner has aria-labelledby linked to title', () => { + renderPopover({ defaultOpen: true }); + + const title = document.querySelector('[data-cl-slot="popover-title"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(title).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + }); + + it('positioner has aria-describedby linked to description', () => { + renderPopover({ defaultOpen: true }); + + const desc = document.querySelector('[data-cl-slot="popover-description"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(desc).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); + }); + + it('trigger has role=button', () => { + renderPopover(); + expect(screen.getByRole('button', { name: 'Open popover' })).toBeInTheDocument(); + }); + + it('keeps positioner aria-labelledby/aria-describedby wired to the correct elements', () => { + // The primitive owns the ids on Title and Description (id is omitted from + // their public props) — the aria pairing must always resolve correctly. + renderPopover({ defaultOpen: true }); + + const title = document.querySelector('[data-cl-slot="popover-title"]'); + const desc = document.querySelector('[data-cl-slot="popover-description"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); + expect(title).toHaveTextContent('Popover Title'); + expect(desc).toHaveTextContent('Some description'); + }); + + it('omits aria-labelledby and aria-describedby when no Title or Description is rendered', () => { + // Title and Description are optional. When absent, the positioner must not + // emit dangling idrefs pointing at elements that were never rendered. + render( + + Open popover + + + Popover content + + + , + ); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).not.toHaveAttribute('aria-labelledby'); + expect(positioner).not.toHaveAttribute('aria-describedby'); + }); + + it('sets only aria-labelledby when a Title is rendered without a Description', () => { + render( + + Open popover + + + Popover Title + Popover content + + + , + ); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + const title = document.querySelector('[data-cl-slot="popover-title"]'); + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + expect(positioner).not.toHaveAttribute('aria-describedby'); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderPopover(); + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const popup = document.querySelector('[data-cl-slot="popover-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('content rendering', () => { + it('renders children content when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(screen.getByText('Popover content')).toBeInTheDocument(); + expect(screen.getByText('Popover Title')).toBeInTheDocument(); + expect(screen.getByText('Some description')).toBeInTheDocument(); + }); + }); + + describe('placement', () => { + it('accepts custom placement', () => { + renderPopover({ defaultOpen: true, placement: 'top-start' }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'top'); + }); + + it('defaults to bottom placement', () => { + renderPopover({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + }); + }); + + describe('focus management', () => { + it('moves focus into popover on open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + // FloatingFocusManager schedules focus via requestAnimationFrame + await new Promise(r => requestAnimationFrame(r)); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner?.contains(document.activeElement)).toBe(true); + }); + + it('returns focus to trigger on close via Escape', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.keyboard('{Escape}'); + + expect(document.activeElement).toBe(trigger); + }); + + it('returns focus to trigger on close via Close button', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(document.activeElement).toBe(trigger); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderPopover(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + renderPopover({ defaultOpen: true }); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); + + describe('consumer ref forwarding', () => { + it('forwards a consumer ref on Trigger (host button shape)', () => { + const ref = createRef(); + render( + + Open popover + , + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-trigger'); + }); + + it('forwards a consumer ref on Positioner (FloatingFocusManager wrapper shape)', () => { + const ref = createRef(); + render( + + Open popover + + content + + , + ); + + expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-positioner'); + }); + }); + + describe('Arrow ref', () => { + it('merges a consumer ref with the internal arrow ref', () => { + const ref = createRef(); + render( + + Open popover + + + + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-arrow'); + }); + }); +}); diff --git a/packages/headless/src/primitives/select/README.md b/packages/headless/src/primitives/select/README.md new file mode 100644 index 00000000000..47bd2ef3feb --- /dev/null +++ b/packages/headless/src/primitives/select/README.md @@ -0,0 +1,163 @@ +# Select + +A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `` with a fully styled, accessible alternative. + +## When to Use + +- Picking a single value from a predefined list of options. +- When you need typeahead, keyboard navigation, and full styling control. +- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter. + +## Usage + +```tsx +import { Select } from '@/primitives/select'; + + + + + + + + + + + + +; +``` + +### Controlled + +```tsx +const [value, setValue] = useState('apple'); + + + {/* ... */} +; +``` + +### With `items` for SSR label resolution + +The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists): + +```tsx +const items = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, +]; + + + {/* Select.Value will display "Apple" even before Options mount */} +; +``` + +### Disable item-to-trigger alignment + +By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning: + +```tsx +{/* Uses standard Floating UI positioning */} +``` + +## Parts + +| Part | Default Element | Description | +| ------------------- | --------------- | ------------------------------------------ | +| `Select.Root` | — | Root context provider | +| `Select.Trigger` | `` | Toggles the dropdown on click | +| `Select.Value` | `` | Displays the selected label or placeholder | +| `Select.Portal` | — | Portals children (accepts `root` prop) | +| `Select.Positioner` | `` | Floating positioned container | +| `Select.Popup` | `` | Visual wrapper for the option list | +| `Select.Option` | `` | A selectable option | +| `Select.Arrow` | `` | Optional floating arrow | + +## Props + +### `Select.Root` + +| Prop | Type | Default | Description | +| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ | +| `value` | `string` | — | Controlled selected value | +| `defaultValue` | `string` | — | Initial selected value (uncontrolled) | +| `onValueChange` | `(value: string) => void` | — | Called when selection changes | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount | +| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger | +| `placement` | `Placement` | `"bottom-start"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | + +### `Select.Value` + +| Prop | Type | Default | Description | +| ------------- | ----------- | ------- | ------------------------------- | +| `placeholder` | `ReactNode` | — | Shown when no value is selected | + +### `Select.Option` + +| Prop | Type | Default | Description | +| ---------- | --------- | --------------------- | -------------------------------------- | +| `value` | `string` | **required** | The option's value | +| `label` | `string` | falls back to `value` | Display label, also used for typeahead | +| `disabled` | `boolean` | — | Prevents selection | + +### `Select.Trigger`, `Select.Positioner`, `Select.Popup` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Select.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard Navigation + +| Key | Action | +| ----------------- | ------------------------------------- | +| `ArrowDown` | Move to next option | +| `ArrowUp` | Move to previous option | +| `Enter` / `Space` | Select the active option, close popup | +| `Escape` | Close the popup | +| Type a character | Jump to matching option (typeahead) | + +Typeahead is active only while the popup is open. It highlights the matching option; pressing Enter or Space then selects it. + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ---------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"select-option"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Popup open state | +| `data-cl-selected` | Option | The currently selected option | +| `data-cl-active` | Option | The keyboard-highlighted option | +| `data-cl-disabled` | Option | Disabled option | +| `data-cl-side` | Positioner | Resolved placement side | + +## Important Notes + +- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both. +- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself. +- **Disabled options** can still receive keyboard focus but cannot be selected. + +## ARIA + +- Popup: `role="listbox"` +- Option: `role="option"`, `aria-selected`, `aria-disabled` +- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls` diff --git a/packages/headless/src/primitives/select/index.ts b/packages/headless/src/primitives/select/index.ts new file mode 100644 index 00000000000..659c55ba1db --- /dev/null +++ b/packages/headless/src/primitives/select/index.ts @@ -0,0 +1,13 @@ +export * as Select from './parts'; + +export type { + SelectArrowProps, + SelectItem, + SelectOptionProps, + SelectPopupProps, + SelectPortalProps, + SelectPositionerProps, + SelectProps, + SelectTriggerProps, + SelectValueProps, +} from './parts'; diff --git a/packages/headless/src/primitives/select/parts.ts b/packages/headless/src/primitives/select/parts.ts new file mode 100644 index 00000000000..1ce683e4f84 --- /dev/null +++ b/packages/headless/src/primitives/select/parts.ts @@ -0,0 +1,8 @@ +export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root'; +export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger'; +export { type SelectValueProps, SelectValue as Value } from './select-value'; +export { type SelectPortalProps, SelectPortal as Portal } from './select-portal'; +export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner'; +export { type SelectPopupProps, SelectPopup as Popup } from './select-popup'; +export { type SelectOptionProps, SelectOption as Option } from './select-option'; +export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow'; diff --git a/packages/headless/src/primitives/select/select-arrow.tsx b/packages/headless/src/primitives/select/select-arrow.tsx new file mode 100644 index 00000000000..948dadcdf75 --- /dev/null +++ b/packages/headless/src/primitives/select/select-arrow.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FloatingArrow } from '@floating-ui/react'; +import React from 'react'; + +import { useSelectContext } from './select-context'; + +export type SelectArrowProps = React.ComponentPropsWithRef; + +export function SelectArrow(props: SelectArrowProps) { + const { floatingContext, arrowRef, placement } = useSelectContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} diff --git a/packages/headless/src/primitives/select/select-context.ts b/packages/headless/src/primitives/select/select-context.ts new file mode 100644 index 00000000000..a26ec66413e --- /dev/null +++ b/packages/headless/src/primitives/select/select-context.ts @@ -0,0 +1,52 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, type RefObject, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface SelectItem { + label: string; + value: string; +} + +export interface SelectContextValue { + open: boolean; + items: SelectItem[] | undefined; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + getItemProps: UseInteractionsReturn['getItemProps']; + activeIndex: number | null; + setActiveIndex: React.Dispatch>; + selectedIndex: number | null; + selectedValue: string | undefined; + selectedLabel: string | null; + elementsRef: React.MutableRefObject>; + labelsRef: React.MutableRefObject>; + popupRef: RefObject; + arrowRef: React.MutableRefObject; + valueToLabelRef: React.MutableRefObject>; + selectedItemRef: React.MutableRefObject; + alignItemWithTrigger: boolean; + handleSelect: (value: string, index: number) => void; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const SelectContext = createContext(null); + +export function useSelectContext() { + const ctx = useContext(SelectContext); + if (!ctx) { + throw new Error('Select compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/select/select-option.tsx b/packages/headless/src/primitives/select/select-option.tsx new file mode 100644 index 00000000000..42a76e29897 --- /dev/null +++ b/packages/headless/src/primitives/select/select-option.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useListItem, useMergeRefs } from '@floating-ui/react'; +import type React from 'react'; +import { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export interface SelectOptionProps extends ComponentProps<'button'> { + value: string; + label?: string; + disabled?: boolean; +} + +export function SelectOption(props: SelectOptionProps) { + const { render, value, label, disabled, ...otherProps } = props; + const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } = + useSelectContext(); + + const displayLabel = label ?? value; + const { ref: itemRef, index } = useListItem({ label: displayLabel }); + + const isSelected = selectedValue === value; + const isActive = activeIndex === index; + + useEffect(() => { + valueToLabelRef.current.set(value, displayLabel); + return () => { + valueToLabelRef.current.delete(value); + }; + }, [value, displayLabel, valueToLabelRef]); + + const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]); + + const state = { + selected: isSelected, + active: isActive, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'select-option', + type: 'button' as const, + ref: combinedRef, + role: 'option' as const, + 'aria-selected': isSelected, + 'aria-disabled': disabled || undefined, + tabIndex: isActive ? 0 : -1, + ...(getItemProps({ + onClick() { + if (!disabled) { + handleSelect(value, index); + } + }, + }) as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/select/select-popup.tsx b/packages/headless/src/primitives/select/select-popup.tsx new file mode 100644 index 00000000000..9d8ba64bc17 --- /dev/null +++ b/packages/headless/src/primitives/select/select-popup.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export type SelectPopupProps = ComponentProps<'div'>; + +export const SelectPopup = React.forwardRef(function SelectPopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useSelectContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'select-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/select/select-portal.tsx b/packages/headless/src/primitives/select/select-portal.tsx new file mode 100644 index 00000000000..8de3ba97f92 --- /dev/null +++ b/packages/headless/src/primitives/select/select-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { useSelectContext } from './select-context'; + +export interface SelectPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function SelectPortal(props: SelectPortalProps) { + const { mounted } = useSelectContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/select/select-positioner.tsx b/packages/headless/src/primitives/select/select-positioner.tsx new file mode 100644 index 00000000000..16ba6fee87c --- /dev/null +++ b/packages/headless/src/primitives/select/select-positioner.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export type SelectPositionerProps = ComponentProps<'div'>; + +export const SelectPositioner = React.forwardRef( + function SelectPositioner(props, ref) { + const { render, ...otherProps } = props; + const { + mounted, + floatingContext, + refs, + floatingStyles, + placement, + getFloatingProps, + elementsRef, + labelsRef, + setActiveIndex, + } = useSelectContext(); + + const side = placement.split('-')[0]; + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const floatingProps = getFloatingProps({ + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + const items = elementsRef.current; + if (event.key === 'Home') { + const firstEnabled = items.findIndex(el => el != null && el.getAttribute('aria-disabled') !== 'true'); + if (firstEnabled !== -1) { + setActiveIndex(firstEnabled); + } + } else { + for (let i = items.length - 1; i >= 0; i--) { + const el = items[i]; + if (el != null && el.getAttribute('aria-disabled') !== 'true') { + setActiveIndex(i); + break; + } + } + } + } + }, + }) as React.ComponentPropsWithRef<'div'>; + + const defaultProps = { + 'data-cl-slot': 'select-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The listbox id is owned by floating-ui's listbox role: a consumer-supplied + // id must not override it, or the trigger's aria-controls pairing would + // silently break. + if (floatingProps.id != null) { + merged.id = floatingProps.id; + } + + return ( + + + {renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: merged, + })} + + + ); + }, +); diff --git a/packages/headless/src/primitives/select/select-root.tsx b/packages/headless/src/primitives/select/select-root.tsx new file mode 100644 index 00000000000..ed3a85113ee --- /dev/null +++ b/packages/headless/src/primitives/select/select-root.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + type Middleware, + offset, + type Placement, + shift, + size, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useListNavigation, + useRole, + useTypeahead, +} from '@floating-ui/react'; +import { type ReactNode, type RefObject, useCallback, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { SelectContext, type SelectContextValue, type SelectItem } from './select-context'; + +export type { SelectItem } from './select-context'; + +export interface SelectProps { + /** Array of `{ label, value }` items for label resolution before options mount. */ + items?: SelectItem[]; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + /** + * When true, the popup is positioned so the selected item overlays the + * trigger — like a native ``. Defaults to `true`. + */ + alignItemWithTrigger?: boolean; + placement?: Placement; + sideOffset?: number; + children: ReactNode; +} + +function alignSelectedItem(selectedItemRef: RefObject): Middleware { + return { + name: 'alignSelectedItem', + fn({ elements }) { + const selectedEl = selectedItemRef.current; + if (!selectedEl) { + return {}; + } + + const floatingRect = elements.floating.getBoundingClientRect(); + const selectedRect = selectedEl.getBoundingClientRect(); + const referenceRect = (elements.reference as HTMLElement).getBoundingClientRect(); + + const itemOffsetInPopup = selectedRect.top - floatingRect.top; + const desiredTop = referenceRect.top - itemOffsetInPopup; + + const viewportHeight = window.innerHeight; + const clampedTop = Math.max(8, Math.min(desiredTop, viewportHeight - floatingRect.height - 8)); + + return { + x: referenceRect.left, + y: clampedTop, + }; + }, + }; +} + +function SelectInner(props: SelectProps) { + const { + items, + alignItemWithTrigger: alignProp = true, + placement: placementProp = 'bottom-start', + sideOffset = 4, + children, + } = props; + + const nodeId = useFloatingNodeId(); + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [selectedValue, setSelectedValue] = useControllableState( + props.value, + props.defaultValue, + props.onValueChange as ((value: string | undefined) => void) | undefined, + ); + + const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedLabel, setSelectedLabel] = useState(null); + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const arrowRef = useRef(null); + const popupRef = useRef(null); + const valueToLabelRef = useRef>(new Map()); + const selectedItemRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(alignProp ? 0 : sideOffset), + ...(!alignProp ? [flip(), shift({ padding: 5 })] : []), + size({ + apply({ availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxHeight: `${availableHeight}px`, + }); + }, + }), + ...(!alignProp ? [arrow({ element: arrowRef })] : []), + ...(alignProp ? [alignSelectedItem(selectedItemRef)] : []), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const isControlled = props.value !== undefined; + + const handleSelect = useCallback( + (value: string, index: number) => { + setSelectedValue(value); + setSelectedIndex(index); + // In controlled mode the parent decides whether to accept the new value. + // If they reject it, selectedValue rolls back but selectedLabel would not, + // showing a stale label. Only cache the label in uncontrolled mode where + // the value always persists after selection. + if (!isControlled) { + setSelectedLabel(valueToLabelRef.current.get(value) ?? value); + } + setOpen(false); + }, + [isControlled, setSelectedValue, setOpen], + ); + + const handleTypeaheadMatch = useCallback( + (index: number | null) => { + if (open) { + setActiveIndex(index); + } else if (index !== null) { + setSelectedIndex(index); + } + }, + [open], + ); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext, { role: 'listbox' }); + const listNav = useListNavigation(floatingContext, { + listRef: elementsRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + loop: true, + }); + const typeahead = useTypeahead(floatingContext, { + listRef: labelsRef, + activeIndex, + selectedIndex, + onMatch: handleTypeaheadMatch, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + typeahead, + ]); + + const contextValue = useMemo( + () => ({ + open, + items, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + selectedIndex, + selectedValue, + selectedLabel, + elementsRef, + labelsRef, + popupRef, + arrowRef, + valueToLabelRef, + selectedItemRef, + alignItemWithTrigger: alignProp, + handleSelect, + mounted, + transitionProps, + }), + [ + open, + items, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + selectedIndex, + selectedValue, + selectedLabel, + alignProp, + handleSelect, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function SelectRoot(props: SelectProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/select/select-trigger.tsx b/packages/headless/src/primitives/select/select-trigger.tsx new file mode 100644 index 00000000000..750458c18c0 --- /dev/null +++ b/packages/headless/src/primitives/select/select-trigger.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export type SelectTriggerProps = ComponentProps<'button'>; + +export const SelectTrigger = React.forwardRef( + function SelectTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useSelectContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'select-trigger', + ref: combinedRef, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/select/select-value.tsx b/packages/headless/src/primitives/select/select-value.tsx new file mode 100644 index 00000000000..8c28ece29d8 --- /dev/null +++ b/packages/headless/src/primitives/select/select-value.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import type { SelectItem } from './select-context'; +import { useSelectContext } from './select-context'; + +function resolveLabel( + value: string | undefined, + items: SelectItem[] | undefined, + valueToLabelRef: React.MutableRefObject>, +): string | null { + if (value === undefined) { + return null; + } + if (items) { + const item = items.find(i => i.value === value); + if (item) { + return item.label; + } + } + const label = valueToLabelRef.current.get(value); + if (label) { + return label; + } + return value; +} + +export interface SelectValueProps extends ComponentProps<'span'> { + placeholder?: ReactNode; +} + +export function SelectValue(props: SelectValueProps) { + const { render, placeholder, ...otherProps } = props; + const { selectedValue, selectedLabel, items, valueToLabelRef } = useSelectContext(); + + const displayText = + selectedValue !== undefined ? (selectedLabel ?? resolveLabel(selectedValue, items, valueToLabelRef)) : placeholder; + + const defaultProps = { + 'data-cl-slot': 'select-value', + children: displayText, + }; + + return renderElement({ + defaultTagName: 'span', + render, + props: mergeProps<'span'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/select/select.test.tsx b/packages/headless/src/primitives/select/select.test.tsx new file mode 100644 index 00000000000..8b2f414f65b --- /dev/null +++ b/packages/headless/src/primitives/select/select.test.tsx @@ -0,0 +1,838 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Select, type SelectItem } from './index'; + +afterEach(() => cleanup()); + +const fruits: SelectItem[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, +]; + +function renderSelect(props: Partial> = {}) { + const { children, ...rest } = props as Record; + return render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); +} + +describe('Select', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderSelect(); + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('data-cl-slot', 'select-trigger'); + }); + + it('renders value with data-cl-slot', () => { + renderSelect(); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value).toBeInTheDocument(); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderSelect({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const popup = document.querySelector('[data-cl-slot="select-popup"]'); + const options = document.querySelectorAll('[data-cl-slot="select-option"]'); + + expect(positioner).toBeInTheDocument(); + expect(popup).toBeInTheDocument(); + expect(options).toHaveLength(3); + }); + }); + + describe('ARIA attributes', () => { + it('keeps the trigger/listbox aria-controls pairing intact when a custom id is passed to the positioner', () => { + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + const trigger = screen.getByRole('combobox'); + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + + // The listbox id is owned by floating-ui: a consumer-supplied id must not + // override it, or the trigger's aria-controls pairing would silently break. + expect(positioner).not.toHaveAttribute('id', 'consumer-custom-id'); + expect(trigger.getAttribute('aria-controls')).toBe(positioner?.getAttribute('id')); + }); + }); + + describe('items prop and label resolution', () => { + it('shows placeholder when no value is selected', () => { + renderSelect(); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Pick a fruit...'); + }); + + it('resolves label from items before options mount', () => { + renderSelect({ defaultValue: 'banana' }); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Banana'); + }); + + it('resolves label for controlled value from items', () => { + renderSelect({ value: 'cherry' }); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Cherry'); + }); + + it('falls back to raw value when no items provided', () => { + render( + + + + + + + + Banana + + + + , + ); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('banana'); + }); + + it('updates label after selection via option registry', async () => { + const user = userEvent.setup(); + // No items prop — labels only known once options mount + render( + + + + + + + + Special Item + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Special Item')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Special Item'); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + renderSelect(); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-cl-slot="select-popup"]')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderSelect({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderSelect({ onOpenChange }); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('selection', () => { + it('selects option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + renderSelect({ onValueChange }); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + const option = screen.getByText('Banana'); + await user.click(option); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + }); + + it('displays selected label in Value after selection', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Cherry')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toContain('Cherry'); + }); + }); + + describe('controlled value', () => { + it('does not display a stale label when the controlled parent rejects the change', async () => { + const user = userEvent.setup(); + // The parent keeps `value` pinned to 'apple' and ignores onValueChange, + // so the displayed label must stay "Apple" even after clicking "Banana". + render( + {}} + > + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Banana')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Apple'); + }); + }); + + describe('keyboard navigation', () => { + it('navigates options with arrow keys', async () => { + const user = userEvent.setup(); + renderSelect(); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + + it('scrolls options into view on arrow key navigation', async () => { + const manyItems = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + const user = userEvent.setup(); + render( + + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + // Navigate down through many items to force scrolling + for (let i = 0; i < 15; i++) { + await user.keyboard('{ArrowDown}'); + } + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + + // The active item should be visible within its scroll container + const scrollContainer = activeOption!.closest('div[style]') as HTMLElement; + const optionRect = activeOption!.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + + expect(optionRect.bottom).toBeLessThanOrEqual(containerRect.bottom + 1); + expect(optionRect.top).toBeGreaterThanOrEqual(containerRect.top - 1); + }); + + it('scrolls selected item into view when reopening', async () => { + const manyItems = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + const user = userEvent.setup(); + render( + + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + + , + ); + + const trigger = screen.getByRole('combobox'); + + // Open, navigate to item near the bottom, select it + await user.click(trigger); + for (let i = 0; i < 15; i++) { + await user.keyboard('{ArrowDown}'); + } + await user.keyboard('{Enter}'); + + // Reopen — the selected item should be scrolled into view + await user.click(trigger); + + const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + expect(selectedOption).toBeInTheDocument(); + + const scrollContainer = selectedOption!.closest('div[style]') as HTMLElement; + const optionRect = selectedOption!.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + + expect(optionRect.bottom).toBeLessThanOrEqual(containerRect.bottom + 1); + expect(optionRect.top).toBeGreaterThanOrEqual(containerRect.top - 1); + }); + }); + + describe('option state attributes', () => { + it('marks selected option with data-cl-selected', () => { + renderSelect({ defaultValue: 'banana', defaultOpen: true }); + + const options = document.querySelectorAll('[data-cl-slot="select-option"]'); + expect(options[1]).toHaveAttribute('data-cl-selected', ''); + }); + + it('marks active option with data-cl-active', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + }); + + describe('disabled option', () => { + it('renders disabled option with data-cl-disabled', async () => { + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + const disabledOption = screen.getByText('Banana').closest("[data-cl-slot='select-option']"); + expect(disabledOption).toHaveAttribute('data-cl-disabled', ''); + expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not select disabled option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Banana')); + + expect(onValueChange).not.toHaveBeenCalledWith('banana'); + }); + }); + + describe('ARIA attributes', () => { + it('options have role=option', () => { + renderSelect({ defaultOpen: true }); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + }); + + it('selected option has aria-selected=true', () => { + renderSelect({ defaultValue: 'apple', defaultOpen: true }); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderSelect(); + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + + const popup = document.querySelector('[data-cl-slot="select-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderSelect({ open: true }); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderSelect({ open: false }); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).not.toBeInTheDocument(); + }); + }); + + describe('alignItemWithTrigger', () => { + it('defaults to true', () => { + // Render without explicitly setting alignItemWithTrigger + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + // The positioner should render (alignItemWithTrigger doesn't prevent rendering) + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toBeInTheDocument(); + }); + + it('uses standard floating styles when disabled', async () => { + const user = userEvent.setup(); + renderSelect({ alignItemWithTrigger: false }); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + // Standard Floating UI positioning uses position: absolute with transform + expect(positioner.style.position).toBe('absolute'); + }); + + const manyItems: SelectItem[] = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + it('aligns selected item with trigger vertically', () => { + render( + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + const trigger = screen.getByRole('combobox'); + const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + expect(selectedOption).toBeInTheDocument(); + + const triggerRect = trigger.getBoundingClientRect(); + const selectedRect = selectedOption!.getBoundingClientRect(); + + // The selected item should be positioned near the trigger's vertical position + expect(Math.abs(selectedRect.top - triggerRect.top)).toBeLessThan(50); + }); + + // Requires real layout engine — getBoundingClientRect returns 0 in happy-dom + it.skip('repositions when ancestor scrolls', async () => { + const user = userEvent.setup(); + render( + + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + const initialTop = positioner.getBoundingClientRect().top; + + // Scroll the container + const scrollContainer = screen.getByTestId('scroll-container'); + scrollContainer.scrollTop = 100; + scrollContainer.dispatchEvent(new Event('scroll')); + + // autoUpdate repositions on scroll — wait for the update + await waitFor(() => { + const newTop = positioner.getBoundingClientRect().top; + expect(newTop).not.toBe(initialTop); + }); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + + it('has no violations with a selected value', async () => { + const { container } = render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + describe('option button type', () => { + it('renders options with type="button" so they do not submit a wrapping form', () => { + renderSelect({ defaultOpen: true }); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + for (const option of options) { + expect(option).toHaveAttribute('type', 'button'); + } + }); + }); + + describe('Home/End navigation honors aria-disabled value', () => { + it('treats aria-disabled="false" as enabled when pressing Home', async () => { + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + Cherry + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{Home}'); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-disabled', 'false'); + expect(options[0]).toHaveAttribute('data-cl-active'); + }); + }); +}); diff --git a/packages/headless/src/primitives/tabs/README.md b/packages/headless/src/primitives/tabs/README.md new file mode 100644 index 00000000000..b51d40f3723 --- /dev/null +++ b/packages/headless/src/primitives/tabs/README.md @@ -0,0 +1,199 @@ +# Tabs + +A tabbed interface with automatic or manual activation, keyboard navigation, and an animated indicator. Panels are shown/hidden via the HTML `hidden` attribute (not unmounted). + +## When to Use + +- Switching between views or content sections within the same page area. +- When you need accessible tab navigation with `role="tablist"` / `role="tab"` / `role="tabpanel"`. +- Prefer Tabs over Accordion when content sections are mutually exclusive and should feel like parallel views. + +## Usage + +```tsx +import { Tabs } from '@/primitives/tabs'; + + + + Account + Security + Notifications + + + Account settings content + Security settings content + Notification preferences content +; +``` + +### Controlled + +```tsx +const [value, setValue] = useState('tab1'); + + + {/* ... */} +; +``` + +### Manual Activation + +By default, arrowing to a tab immediately activates it. Use `activationMode="manual"` to require Enter/Space: + +```tsx +{/* Arrow keys move focus, Enter/Space activates */} +``` + +### Vertical Orientation + +```tsx +{/* Arrow Up/Down navigates instead of Left/Right */} +``` + +## Parts + +| Part | Default Element | Description | +| ---------------- | --------------- | -------------------------------------------------- | +| `Tabs.Root` | — | Root context provider | +| `Tabs.List` | `` | Container for tabs (`role="tablist"`) | +| `Tabs.Tab` | `` | A tab trigger inside `Tabs.List` (`role="tab"`) | +| `Tabs.Trigger` | `` | Standalone tab trigger for use outside `Tabs.List` | +| `Tabs.Panel` | `` | Content panel (`role="tabpanel"`) | +| `Tabs.Indicator` | `` | Animated indicator tracking the active tab | + +## Props + +### `Tabs.Root` + +| Prop | Type | Default | Description | +| ---------------- | ---------------------------- | -------------- | ----------------------------------------- | +| `value` | `string` | — | Controlled active tab | +| `defaultValue` | `string` | `""` | Initial active tab (uncontrolled) | +| `onValueChange` | `(value: string) => void` | — | Called when active tab changes | +| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Arrow key direction | +| `activationMode` | `"automatic" \| "manual"` | `"automatic"` | Whether focus activates a tab immediately | + +### `Tabs.Tab` + +| Prop | Type | Default | Description | +| ---------- | --------- | ------------ | ---------------------------------------------------------- | +| `value` | `string` | **required** | Unique tab identifier, must match a Panel's `value` | +| `disabled` | `boolean` | — | Disables the tab (uses `aria-disabled`, remains focusable) | + +### `Tabs.Trigger` + +A standalone tab button for use outside `Tabs.List`. Unlike `Tabs.Tab`, it does not participate in roving tabindex keyboard navigation — it's a plain button with `onClick`. + +| Prop | Type | Default | Description | +| ---------- | --------- | ------------ | -------------------------------------------- | +| `value` | `string` | **required** | Tab identifier, must match a Panel's `value` | +| `disabled` | `boolean` | — | Disables the trigger | + +### `Tabs.Panel` + +| Prop | Type | Default | Description | +| ------------------ | --------- | ------------ | ------------------------------------------------------------------- | +| `value` | `string` | **required** | Must match a Tab's `value` | +| `shouldForceMount` | `boolean` | — | When true, keeps the panel in layout flow instead of using `hidden` | + +### `Tabs.List`, `Tabs.Indicator` + +No additional props beyond standard HTML attributes and the `render` prop. + +## Keyboard Navigation + +| Key | Action (horizontal) | Action (vertical) | +| ----------------- | -------------------------- | -------------------------- | +| `ArrowRight` | Next tab | — | +| `ArrowLeft` | Previous tab | — | +| `ArrowDown` | — | Next tab | +| `ArrowUp` | — | Previous tab | +| `Enter` / `Space` | Activate tab (manual mode) | Activate tab (manual mode) | + +## Data Attributes + +| Attribute | Applies To | Description | +| ------------------------ | ------------ | ----------------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"tabs-tab"`, `"tabs-trigger"`) | +| `data-cl-selected` | Tab, Trigger | Active tab | +| `data-cl-disabled` | Tab, Trigger | Disabled tab | +| `data-cl-hidden` | Panel | Inactive panel | +| `data-cl-open` | Panel | Selected panel (when `shouldForceMount`) | +| `data-cl-closed` | Panel | Deselected panel (when `shouldForceMount`) | +| `data-cl-starting-style` | Panel | Enter animation frame (when `shouldForceMount`) | +| `data-cl-ending-style` | Panel | Exit animation frame (when `shouldForceMount`) | + +## CSS Variables + +### Indicator + +`Tabs.Indicator` exposes CSS custom properties for positioning and sizing: + +| CSS Variable | Description | +| ----------------- | ---------------------------------- | +| `--cl-tab-left` | Left offset of the active tab (px) | +| `--cl-tab-width` | Width of the active tab (px) | +| `--cl-tab-top` | Top offset of the active tab (px) | +| `--cl-tab-height` | Height of the active tab (px) | + +Use these to animate the indicator: + +```css +[data-cl-slot='tabs-indicator'] { + position: absolute; + left: var(--cl-tab-left); + width: var(--cl-tab-width); + transition: + left 200ms ease, + width 200ms ease; +} +``` + +The initial render suppresses the transition to prevent the indicator from animating from `0,0`. + +### Panel (with `shouldForceMount`) + +When `shouldForceMount` is set, panels expose a direction variable for directional animations: + +| CSS Variable | Description | +| ------------------------------- | -------------------------------------------------------------- | +| `--cl-tab-transition-direction` | `"1"` when navigating forward, `"-1"` when navigating backward | + +Use this to drive directional slide animations: + +```css +[data-cl-slot='tabs-panel'] { + --_direction: var(--cl-tab-transition-direction, 1); + transition: + opacity 200ms, + translate 200ms; +} +[data-cl-slot='tabs-panel'][data-cl-starting-style], +[data-cl-slot='tabs-panel'][data-cl-ending-style] { + opacity: 0; +} +[data-cl-slot='tabs-panel'][data-cl-starting-style] { + translate: calc(var(--_direction) * 8px) 0; +} +[data-cl-slot='tabs-panel'][data-cl-ending-style] { + translate: calc(var(--_direction) * -8px) 0; +} +``` + +## Important Notes + +- **`Tabs.List` must have `position: relative`** in your CSS for the indicator to position correctly. +- **Panels use the `hidden` attribute** by default — they stay in the DOM but are hidden when inactive. This preserves state in inactive panels. +- **`shouldForceMount` panels** stay in layout flow with `inert` on inactive panels. This enables CSS enter/exit animations between tabs. The initially-selected panel appears instantly (no enter animation on page load). +- **`Tabs.Trigger` vs `Tabs.Tab`**: Use `Tabs.Tab` inside `Tabs.List` for keyboard-navigable tabs with roving tabindex. Use `Tabs.Trigger` for standalone tab buttons placed anywhere in the tree (e.g., in a sidebar). +- **Disabled tabs use `aria-disabled`**, not the native `disabled` attribute, keeping them focusable for keyboard users. +- **Indicator is `aria-hidden`** — it's purely decorative. + +## ARIA + +- List: `role="tablist"` +- Tab: `role="tab"`, `aria-selected`, `aria-controls` (pointing to its panel), `aria-disabled` +- Panel: `role="tabpanel"`, `aria-labelledby` (pointing to its tab), `tabIndex={0}` diff --git a/packages/headless/src/primitives/tabs/index.ts b/packages/headless/src/primitives/tabs/index.ts new file mode 100644 index 00000000000..86038858f5b --- /dev/null +++ b/packages/headless/src/primitives/tabs/index.ts @@ -0,0 +1,10 @@ +export * as Tabs from './parts'; + +export type { + TabsIndicatorProps, + TabsListProps, + TabsPanelProps, + TabsProps, + TabsTabProps, + TabsTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/tabs/parts.ts b/packages/headless/src/primitives/tabs/parts.ts new file mode 100644 index 00000000000..81a526095d0 --- /dev/null +++ b/packages/headless/src/primitives/tabs/parts.ts @@ -0,0 +1,6 @@ +export { type TabsProps, TabsRoot as Root } from './tabs-root'; +export { type TabsListProps, TabsList as List } from './tabs-list'; +export { type TabsTabProps, TabsTab as Tab } from './tabs-tab'; +export { type TabsTriggerProps, TabsTrigger as Trigger } from './tabs-trigger'; +export { type TabsPanelProps, TabsPanel as Panel } from './tabs-panel'; +export { type TabsIndicatorProps, TabsIndicator as Indicator } from './tabs-indicator'; diff --git a/packages/headless/src/primitives/tabs/tabs-context.ts b/packages/headless/src/primitives/tabs/tabs-context.ts new file mode 100644 index 00000000000..4c4c6600527 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-context.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react'; + +export interface TabsContextValue { + value: string; + setValue: (value: string) => void; + orientation: 'horizontal' | 'vertical'; + activationMode: 'automatic' | 'manual'; + tabsId: string; + registerTab: (value: string, element: HTMLElement | null) => void; + getTabElement: (value: string) => HTMLElement | null; + listRef: React.RefObject; + direction: 1 | -1; +} + +export const TabsContext = createContext(null); + +export function useTabsContext() { + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error('Tabs compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/tabs/tabs-indicator.tsx b/packages/headless/src/primitives/tabs/tabs-indicator.tsx new file mode 100644 index 00000000000..116367a1ddc --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-indicator.tsx @@ -0,0 +1,80 @@ +'use client'; + +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export type TabsIndicatorProps = ComponentProps<'span'>; + +export function TabsIndicator(props: TabsIndicatorProps) { + const { render, ...otherProps } = props; + const { value, getTabElement, orientation, listRef } = useTabsContext(); + + const [style, setStyle] = useState({}); + const previousRectRef = useRef<{ + left: number; + top: number; + width: number; + height: number; + } | null>(null); + + useEffect(() => { + const el = getTabElement(value); + const list = listRef.current; + if (!el || !list) { + return; + } + + const measure = () => { + const tabRect = el.getBoundingClientRect(); + const listRect = list.getBoundingClientRect(); + + const newRect = { + left: tabRect.left - listRect.left, + top: tabRect.top - listRect.top, + width: tabRect.width, + height: tabRect.height, + }; + + const prev = previousRectRef.current; + previousRectRef.current = newRect; + + const sharedVars = { + ['--cl-tab-left' as string]: `${newRect.left}px`, + ['--cl-tab-width' as string]: `${newRect.width}px`, + ['--cl-tab-top' as string]: `${newRect.top}px`, + ['--cl-tab-height' as string]: `${newRect.height}px`, + ...(prev == null ? { transition: 'none' } : {}), + }; + + if (orientation === 'horizontal') { + setStyle({ position: 'absolute', left: newRect.left, width: newRect.width, ...sharedVars }); + } else { + setStyle({ position: 'absolute', top: newRect.top, height: newRect.height, ...sharedVars }); + } + }; + + measure(); + + // Keep the indicator in sync when the active tab or the list changes size + // (font load, container resize) without a tab-selection change. + const ro = new ResizeObserver(measure); + ro.observe(el); + ro.observe(list); + return () => ro.disconnect(); + }, [value, getTabElement, orientation, listRef]); + + const defaultProps = { + 'data-cl-slot': 'tabs-indicator', + 'aria-hidden': true as const, + style, + }; + + return renderElement({ + defaultTagName: 'span', + render, + props: mergeProps<'span'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/tabs/tabs-list.tsx b/packages/headless/src/primitives/tabs/tabs-list.tsx new file mode 100644 index 00000000000..05af1a70986 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-list.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Composite } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export type TabsListProps = ComponentProps<'div'>; + +export function TabsList(props: TabsListProps) { + const { render, children, ...otherProps } = props; + const { orientation, listRef } = useTabsContext(); + + return ( + ) => { + const defaultProps: Record = { + 'data-cl-slot': 'tabs-list', + role: 'tablist' as const, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key !== 'Home' && event.key !== 'End') { + return; + } + event.preventDefault(); + const items = Array.from(event.currentTarget.querySelectorAll('[role="tab"]:not([disabled])')); + if (items.length === 0) { + return; + } + const target = event.key === 'Home' ? items[0] : items[items.length - 1]; + target.focus(); + }, + }; + + const merged = mergeProps<'div'>( + defaultProps, + mergeProps<'div'>(otherProps, compositeProps as Record), + ); + + return renderElement({ + defaultTagName: 'div', + render, + props: merged, + }); + }} + > + {children} + + ); +} diff --git a/packages/headless/src/primitives/tabs/tabs-panel.tsx b/packages/headless/src/primitives/tabs/tabs-panel.tsx new file mode 100644 index 00000000000..dfe659c10bb --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React, { useRef } from 'react'; + +import { useTransition } from '../../hooks/use-transition'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export interface TabsPanelProps extends ComponentProps<'div'> { + value: string; + /** When true, removes `hidden` so the panel stays in layout flow. */ + shouldForceMount?: boolean; +} + +export const TabsPanel = React.forwardRef(function TabsPanel(props, ref) { + const { render, value: panelValue, shouldForceMount, ...otherProps } = props; + const { value: selectedValue, tabsId, direction } = useTabsContext(); + + const isSelected = selectedValue === panelValue; + const tabId = `${tabsId}-tab-${panelValue}`; + const panelId = `${tabsId}-panel-${panelValue}`; + + const panelRef = useRef(null); + // Merge the consumer ref with the internal panelRef so passing a ref does not + // clobber the ref the panel relies on for transition tracking. + const combinedRef = useMergeRefs([panelRef, ref]); + const { transitionProps } = useTransition({ + open: isSelected, + ref: panelRef, + }); + + // Suppress enter animation on initial mount so the initially-selected panel + // appears instantly. After the panel has been deselected once, subsequent + // selections will animate normally. Matches the Accordion pattern. + const hasBeenDeselected = useRef(false); + if (!isSelected) { + hasBeenDeselected.current = true; + } + + const effectiveTransitionProps = + shouldForceMount && !hasBeenDeselected.current + ? { ...transitionProps, 'data-cl-starting-style': undefined, style: undefined } + : transitionProps; + + const state = { hidden: !isSelected }; + + const defaultProps = { + 'data-cl-slot': 'tabs-panel', + id: panelId, + role: 'tabpanel' as const, + 'aria-labelledby': tabId, + tabIndex: 0, + // `inert` must be a truthy string, not a boolean or empty string, to stay + // correct across React 18 and 19: React 18 drops a boolean `true` and React + // 19 treats `''` as falsy. `'true'` renders the (presence-based) attribute in + // both. Matches the existing pattern in packages/ui PricingTableMatrix. + inert: !isSelected ? 'true' : undefined, + hidden: !isSelected && !shouldForceMount ? true : undefined, + ref: combinedRef, + ...(shouldForceMount + ? { + ...effectiveTransitionProps, + style: { ...effectiveTransitionProps.style, ['--cl-tab-transition-direction' as string]: String(direction) }, + } + : {}), + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the tab/panel aria pairing would silently break. + merged.id = panelId; + + return renderElement({ + defaultTagName: 'div', + render, + state, + stateAttributesMapping: { + hidden: (v: boolean) => (v ? { 'data-cl-hidden': '' } : null), + }, + props: merged, + }); +}); diff --git a/packages/headless/src/primitives/tabs/tabs-root.tsx b/packages/headless/src/primitives/tabs/tabs-root.tsx new file mode 100644 index 00000000000..181e478573c --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-root.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { TabsContext, type TabsContextValue } from './tabs-context'; + +export interface TabsProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + orientation?: 'horizontal' | 'vertical'; + activationMode?: 'automatic' | 'manual'; + children: ReactNode; +} + +export function TabsRoot(props: TabsProps) { + const { orientation = 'horizontal', activationMode = 'automatic', children } = props; + + const [value, setValueRaw] = useControllableState(props.value, props.defaultValue ?? '', props.onValueChange); + + const [direction, setDirection] = useState<1 | -1>(1); + const tabsId = useId(); + const tabElementsRef = useRef>(new Map()); + const tabOrderRef = useRef([]); + const listRef = useRef(null); + const valueRef = useRef(value); + valueRef.current = value; + + const registerTab = useCallback((tabValue: string, element: HTMLElement | null) => { + if (element) { + tabElementsRef.current.set(tabValue, element); + if (!tabOrderRef.current.includes(tabValue)) { + tabOrderRef.current.push(tabValue); + } + } else { + tabElementsRef.current.delete(tabValue); + tabOrderRef.current = tabOrderRef.current.filter(v => v !== tabValue); + } + }, []); + + const getTabElement = useCallback((tabValue: string) => { + return tabElementsRef.current.get(tabValue) ?? null; + }, []); + + const setValue = useCallback( + (newValue: string) => { + const prevIndex = tabOrderRef.current.indexOf(valueRef.current); + const nextIndex = tabOrderRef.current.indexOf(newValue); + if (prevIndex !== -1 && nextIndex !== -1 && nextIndex !== prevIndex) { + setDirection(nextIndex > prevIndex ? 1 : -1); + } + setValueRaw(newValue); + }, + [setValueRaw], + ); + + const contextValue = useMemo( + () => ({ + value, + setValue, + orientation, + activationMode, + tabsId, + registerTab, + getTabElement, + listRef, + direction, + }), + [value, setValue, orientation, activationMode, tabsId, registerTab, getTabElement, direction], + ); + + return {children}; +} diff --git a/packages/headless/src/primitives/tabs/tabs-tab.tsx b/packages/headless/src/primitives/tabs/tabs-tab.tsx new file mode 100644 index 00000000000..e610e91c188 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-tab.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { CompositeItem, useMergeRefs } from '@floating-ui/react'; +import React, { useLayoutEffect, useRef } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export interface TabsTabProps extends ComponentProps<'button'> { + value: string; + disabled?: boolean; +} + +export const TabsTab = React.forwardRef(function TabsTab(props, ref) { + const { render, value: tabValue, disabled, children, ...otherProps } = props; + const { value: selectedValue, setValue, activationMode, tabsId, registerTab } = useTabsContext(); + + const isSelected = selectedValue === tabValue; + const tabId = `${tabsId}-tab-${tabValue}`; + const panelId = `${tabsId}-panel-${tabValue}`; + const internalRef = useRef(null); + const combinedRef = useMergeRefs([internalRef, ref]); + + useLayoutEffect(() => { + registerTab(tabValue, internalRef.current); + return () => registerTab(tabValue, null); + }, [tabValue, registerTab]); + + const state = { + selected: isSelected, + disabled: !!disabled, + }; + + return ( + ) => { + const defaultProps: Record = { + 'data-cl-slot': 'tabs-tab', + id: tabId, + role: 'tab' as const, + type: 'button' as const, + 'aria-selected': isSelected, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + }; + + if (activationMode === 'automatic') { + defaultProps.onFocus = () => { + if (!disabled) { + setValue(tabValue); + } + }; + } else { + defaultProps.onClick = () => { + if (!disabled) { + setValue(tabValue); + } + }; + defaultProps.onKeyDown = (event: React.KeyboardEvent) => { + if (!disabled && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + setValue(tabValue); + } + }; + } + + // Merge: defaultProps first, then consumer props, then composite props last + // (composite needs to win on tabIndex, data-active, onFocus, ref) + const merged = mergeProps<'button'>( + mergeProps<'button'>(defaultProps, otherProps), + compositeProps as Record, + ); + + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the tab/panel aria pairing would silently break. + merged.id = tabId; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: merged, + }); + }} + > + {children} + + ); +}); diff --git a/packages/headless/src/primitives/tabs/tabs-trigger.tsx b/packages/headless/src/primitives/tabs/tabs-trigger.tsx new file mode 100644 index 00000000000..74b4ef72ce9 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-trigger.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React, { useLayoutEffect, useRef } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export interface TabsTriggerProps extends ComponentProps<'button'> { + value: string; + disabled?: boolean; +} + +export const TabsTrigger = React.forwardRef(function TabsTrigger(props, ref) { + const { render, value: tabValue, disabled, ...otherProps } = props; + const { value: selectedValue, setValue, tabsId, registerTab } = useTabsContext(); + const triggerRef = useRef(null); + const combinedRef = useMergeRefs([triggerRef, ref]); + + const isSelected = selectedValue === tabValue; + const tabId = `${tabsId}-tab-${tabValue}`; + const panelId = `${tabsId}-panel-${tabValue}`; + + useLayoutEffect(() => { + registerTab(tabValue, triggerRef.current); + return () => registerTab(tabValue, null); + }, [tabValue, registerTab]); + + const state = { + selected: isSelected, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'tabs-trigger', + ref: combinedRef, + id: tabId, + role: 'tab' as const, + type: 'button' as const, + 'aria-selected': isSelected, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + onClick: () => { + if (!disabled) { + setValue(tabValue); + } + }, + }; + + const merged = mergeProps<'button'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the tab/panel aria pairing would silently break. + merged.id = tabId; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: merged, + }); +}); diff --git a/packages/headless/src/primitives/tabs/tabs.test.tsx b/packages/headless/src/primitives/tabs/tabs.test.tsx new file mode 100644 index 00000000000..24a8ec960dc --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs.test.tsx @@ -0,0 +1,618 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Tabs } from './index'; + +afterEach(() => cleanup()); + +function renderTabs(props: Partial> = {}) { + return render( + + + Account + Settings + Billing + + Account content + Settings content + Billing content + , + ); +} + +describe('Tabs', () => { + describe('slot attributes', () => { + it('renders list with data-cl-slot', () => { + renderTabs(); + expect(document.querySelector('[data-cl-slot="tabs-list"]')).toBeInTheDocument(); + }); + + it('renders tabs with data-cl-slot', () => { + renderTabs(); + const tabs = document.querySelectorAll('[data-cl-slot="tabs-tab"]'); + expect(tabs).toHaveLength(3); + }); + + it('renders panels with data-cl-slot', () => { + renderTabs(); + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + expect(panels).toHaveLength(3); + }); + }); + + describe('ARIA attributes', () => { + it('list has role=tablist', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toBeInTheDocument(); + }); + + it('list has aria-orientation', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + it('vertical orientation sets aria-orientation', () => { + renderTabs({ orientation: 'vertical' }); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('tabs have role=tab', () => { + renderTabs(); + expect(screen.getAllByRole('tab')).toHaveLength(3); + }); + + it('selected tab has aria-selected=true', () => { + renderTabs(); + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'false'); + }); + + it('panels have role=tabpanel', () => { + renderTabs(); + // Only the selected panel is visible + expect(screen.getByRole('tabpanel')).toBeInTheDocument(); + }); + + it('tab has aria-controls linking to panel', () => { + renderTabs(); + const tab = screen.getByText('Account'); + const panelId = tab.getAttribute('aria-controls'); + expect(panelId).toBeTruthy(); + expect(document.getElementById(panelId!)).toHaveTextContent('Account content'); + }); + + it('panel has aria-labelledby linking to tab', () => { + renderTabs(); + const panel = screen.getByRole('tabpanel'); + const tabId = panel.getAttribute('aria-labelledby'); + expect(tabId).toBeTruthy(); + expect(document.getElementById(tabId!)).toHaveTextContent('Account'); + }); + + it('keeps tab/panel association intact when a custom id is passed to the tab', () => { + render( + + + + Account + + + Account content + , + ); + const tab = screen.getByRole('tab', { name: 'Account' }); + const panel = screen.getByRole('tabpanel'); + + // The wired ids are owned by the primitive: a consumer-supplied id must + // not silently break the aria pairing between tab and panel. + expect(tab).toHaveAttribute('aria-controls', panel.getAttribute('id')); + expect(panel).toHaveAttribute('aria-labelledby', tab.getAttribute('id')); + }); + }); + + describe('selection', () => { + it('shows selected panel content', () => { + renderTabs(); + expect(screen.getByText('Account content')).toBeVisible(); + expect(screen.getByText('Settings content')).not.toBeVisible(); + }); + + it('selects tab on click', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Settings')); + + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings content')).toBeVisible(); + }); + + it('marks selected tab with data-cl-selected', () => { + renderTabs(); + expect(screen.getByText('Account')).toHaveAttribute('data-cl-selected', ''); + expect(screen.getByText('Settings').hasAttribute('data-cl-selected')).toBe(false); + }); + + it('calls onValueChange on selection', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + renderTabs({ onValueChange }); + + await user.click(screen.getByText('Settings')); + + expect(onValueChange).toHaveBeenCalledWith('tab2'); + }); + }); + + describe('controlled value', () => { + it('respects controlled value prop', () => { + renderTabs({ value: 'tab2' }); + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings content')).toBeVisible(); + }); + + it('does not change internally when controlled', async () => { + const user = userEvent.setup(); + renderTabs({ value: 'tab1' }); + + await user.click(screen.getByText('Settings')); + + // Still on tab1 since controlled + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('keyboard navigation — automatic activation', () => { + it('moves focus with ArrowRight', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).toBe(screen.getByText('Settings')); + // Automatic mode: focus triggers selection + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + }); + + it('moves focus with ArrowLeft', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Settings')); + await user.keyboard('{ArrowLeft}'); + + expect(document.activeElement).toBe(screen.getByText('Account')); + }); + + it('wraps around at the end (loop)', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Billing')); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).toBe(screen.getByText('Account')); + }); + + it('wraps around at the start', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowLeft}'); + + expect(document.activeElement).toBe(screen.getByText('Billing')); + }); + + it('uses ArrowDown/ArrowUp for vertical orientation', async () => { + const user = userEvent.setup(); + renderTabs({ orientation: 'vertical' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowDown}'); + + expect(document.activeElement).toBe(screen.getByText('Settings')); + }); + + it('moves focus to first tab with Home', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Billing')); + await user.keyboard('{Home}'); + + expect(document.activeElement).toBe(screen.getByText('Account')); + }); + + it('moves focus to last tab with End', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{End}'); + + expect(document.activeElement).toBe(screen.getByText('Billing')); + }); + + it('Tab key moves focus from tab to panel', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.tab(); + + const panel = screen.getByRole('tabpanel'); + expect(document.activeElement).toBe(panel); + }); + + it('Home skips disabled tabs', async () => { + const user = userEvent.setup(); + render( + + + + First + + Second + Third + + Panel 1 + Panel 2 + Panel 3 + , + ); + + await user.click(screen.getByText('Third')); + await user.keyboard('{Home}'); + + expect(document.activeElement).toBe(screen.getByText('Second')); + }); + }); + + describe('keyboard navigation — manual activation', () => { + it('moves focus without selecting', async () => { + const user = userEvent.setup(); + renderTabs({ activationMode: 'manual' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).toBe(screen.getByText('Settings')); + // Manual mode: focus does NOT select + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'false'); + }); + + it('selects on Enter', async () => { + const user = userEvent.setup(); + renderTabs({ activationMode: 'manual' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}{Enter}'); + + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + }); + + it('selects on Space', async () => { + const user = userEvent.setup(); + renderTabs({ activationMode: 'manual' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight} '); + + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('disabled tab', () => { + function renderWithDisabled() { + return render( + + + Account + + Settings + + Billing + + Account content + Settings content + Billing content + , + ); + } + + it('disabled tab has data-cl-disabled', () => { + renderWithDisabled(); + expect(screen.getByText('Settings')).toHaveAttribute('data-cl-disabled', ''); + }); + + it('disabled tab has aria-disabled', () => { + renderWithDisabled(); + expect(screen.getByText('Settings')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('skips disabled tab during keyboard navigation', async () => { + const user = userEvent.setup(); + renderWithDisabled(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}'); + + // Should skip Settings (disabled) and land on Billing + expect(document.activeElement).toBe(screen.getByText('Billing')); + }); + + it('does not select disabled tab on click', async () => { + const user = userEvent.setup(); + renderWithDisabled(); + + await user.click(screen.getByText('Settings')); + + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('Tabs.Indicator', () => { + function renderWithIndicator() { + return render( + + + Account + Settings + + + Account content + Settings content + , + ); + } + + it('renders with data-cl-slot', () => { + renderWithIndicator(); + expect(document.querySelector('[data-cl-slot="tabs-indicator"]')).toBeInTheDocument(); + }); + + it('has position absolute', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + expect(indicator.style.position).toBe('absolute'); + }); + + it('has aria-hidden', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + expect(indicator).toHaveAttribute('aria-hidden', 'true'); + }); + + it('sets --cl-tab-width and --cl-tab-left CSS vars', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + // In a real browser, getBoundingClientRect returns actual measurements + expect(indicator.style.getPropertyValue('--cl-tab-width')).toBeTruthy(); + expect(indicator.style.getPropertyValue('--cl-tab-left')).toBeTruthy(); + }); + + it('updates position when tab changes', async () => { + const user = userEvent.setup(); + renderWithIndicator(); + + const indicator = screen.getByTestId('indicator'); + + await user.click(screen.getByText('Settings')); + + // Verify the effect ran and style properties are set + expect(indicator.style.position).toBe('absolute'); + expect(indicator.style.getPropertyValue('--cl-tab-width')).toBeDefined(); + }); + + it('skips transition on initial render', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + expect(indicator.style.transition).toBe('none'); + }); + }); + + describe('Tabs.Indicator resize tracking (B1)', () => { + it('re-measures the active tab when it resizes without a tab change', () => { + // Capture every ResizeObserver the tree creates so the test can drive it. + const observers: Array<{ cb: ResizeObserverCallback; targets: Element[] }> = []; + class MockResizeObserver { + cb: ResizeObserverCallback; + targets: Element[] = []; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + observers.push(this); + } + observe(el: Element) { + this.targets.push(el); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', MockResizeObserver); + + try { + render( + + + Account + Settings + + + Account content + Settings content + , + ); + + const indicator = screen.getByTestId('indicator'); + const list = document.querySelector('[data-cl-slot="tabs-list"]') as HTMLElement; + + // The indicator should have registered an observer for its active tab. + expect(observers.length).toBeGreaterThan(0); + + // Simulate the active tab growing (e.g. font load / container resize) + // with no tab-selection change. + const rect = (left: number, width: number) => + ({ left, top: 0, right: left + width, bottom: 20, width, height: 20, x: left, y: 0, toJSON() {} }) as DOMRect; + list.getBoundingClientRect = () => rect(0, 400); + for (const tab of document.querySelectorAll('[data-cl-slot="tabs-tab"]')) { + (tab as HTMLElement).getBoundingClientRect = () => rect(0, 250); + } + + // Fire every observer the tree created. + act(() => { + for (const o of observers) { + o.cb([], o as unknown as ResizeObserver); + } + }); + + expect(indicator.style.getPropertyValue('--cl-tab-width')).toBe('250px'); + } finally { + vi.unstubAllGlobals(); + } + }); + }); + + describe('panel visibility', () => { + it('hides non-selected panels with hidden attribute', () => { + renderTabs(); + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + const visible = Array.from(panels).filter(p => !p.hasAttribute('hidden')); + const hidden = Array.from(panels).filter(p => p.hasAttribute('hidden')); + + expect(visible).toHaveLength(1); + expect(hidden).toHaveLength(2); + }); + + it('non-selected panels have data-cl-hidden', () => { + renderTabs(); + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"][data-cl-hidden]'); + expect(panels).toHaveLength(2); + }); + }); + + describe('roving tabindex', () => { + it('selected tab has tabIndex=0, others have tabIndex=-1', () => { + renderTabs(); + expect(screen.getByText('Account')).toHaveAttribute('tabindex', '0'); + expect(screen.getByText('Settings')).toHaveAttribute('tabindex', '-1'); + expect(screen.getByText('Billing')).toHaveAttribute('tabindex', '-1'); + }); + + it('tabIndex updates when selection changes', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Settings')); + + expect(screen.getByText('Account')).toHaveAttribute('tabindex', '-1'); + expect(screen.getByText('Settings')).toHaveAttribute('tabindex', '0'); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations', async () => { + const { container } = renderTabs(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations with vertical orientation', async () => { + const { container } = renderTabs({ orientation: 'vertical' }); + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + describe('consumer ref forwarding', () => { + it('forwards a consumer ref on Tab (CompositeItem shape)', () => { + const ref = createRef(); + render( + + + + One + + + content + , + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current).toHaveAttribute('data-cl-slot', 'tabs-tab'); + }); + }); + + describe('inert state', () => { + it('marks deselected panels inert and leaves the selected panel interactive', () => { + renderTabs({ defaultValue: 'tab1' }); + + const selected = screen.getByText('Account content'); + const deselected = screen.getByText('Settings content'); + + expect(selected).not.toHaveAttribute('inert'); + expect(deselected).toHaveAttribute('inert'); + }); + }); + + describe('transition direction', () => { + function renderForceMounted() { + return render( + + + One + Two + + + One content + + + Two content + + , + ); + } + + it('does not flip direction backward when re-selecting the active tab', async () => { + const user = userEvent.setup(); + renderForceMounted(); + + const panel = screen.getByText('Two content'); + + // Moving forward to tab2 sets direction = 1. + await user.click(screen.getByRole('tab', { name: 'Two' })); + expect(panel.style.getPropertyValue('--cl-tab-transition-direction')).toBe('1'); + + // Re-selecting the already-active tab must not flip direction to -1. + await user.click(screen.getByRole('tab', { name: 'Two' })); + expect(panel.style.getPropertyValue('--cl-tab-transition-direction')).toBe('1'); + }); + }); +}); diff --git a/packages/headless/src/primitives/tooltip/README.md b/packages/headless/src/primitives/tooltip/README.md new file mode 100644 index 00000000000..73567689805 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/README.md @@ -0,0 +1,116 @@ +# Tooltip + +A floating label that appears on hover or focus. Non-interactive, used for supplementary descriptions. No focus trapping — tooltips never receive focus. + +## When to Use + +- Describing icon buttons, truncated text, or any element that benefits from a short label. +- When the content is display-only (no interactive elements inside). +- Prefer Tooltip over Popover when the content is a simple text label that should appear on hover/focus and disappear immediately. + +## Usage + +```tsx +import { Tooltip } from '@/primitives/tooltip'; + + + + + + + + Settings + + + +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + {/* ... */} +; +``` + +### Custom Delay + +```tsx + + {/* Opens after 500ms hover, closes 100ms after leaving */} + +``` + +## Parts + +| Part | Default Element | Description | +| -------------------- | --------------- | ------------------------------------------------------------------------ | +| `Tooltip.Root` | — | Root context provider | +| `Tooltip.Group` | — | Shares an open/close delay across grouped tooltips for instant switching | +| `Tooltip.Trigger` | `` | Element that triggers the tooltip on hover/focus | +| `Tooltip.Portal` | — | Portals children (accepts `root` prop) | +| `Tooltip.Positioner` | `` | Floating positioned container | +| `Tooltip.Popup` | `` | The visible tooltip content | +| `Tooltip.Arrow` | `` | Optional arrow pointing at the trigger | + +## Props + +### `Tooltip.Root` + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ------- | ------------------------------------ | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"top"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and tooltip (px) | +| `delay` | `number` | `200` | Hover open delay (ms) | +| `closeDelay` | `number` | `0` | Hover close delay (ms) | + +### `Tooltip.Trigger`, `Tooltip.Positioner`, `Tooltip.Popup` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Tooltip.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Open/Close Behavior + +- **Hover**: Opens after `delay` ms, closes after `closeDelay` ms. +- **Focus**: Opens on keyboard focus (`:focus-visible`), closes on blur. +- **Dismiss**: Closes on Escape key. +- **No click handling** — tooltips are triggered by hover and focus only. + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"tooltip-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Open state | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Positioning + +Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. Repositions automatically on scroll/resize via `autoUpdate`. + +## Important Notes + +- **No `FloatingFocusManager`** — tooltips do not receive or trap focus. This is correct per ARIA guidelines. +- **Nested tooltips are supported** via `FloatingTree`. +- **`Tooltip.Trigger` wraps its child** — if your trigger is already a button, the `render` prop can forward props to it instead of wrapping. +- **For tooltip clusters** (e.g. toolbar buttons), wrap them in `Tooltip.Group` to share an open/close delay so moving between triggers switches tooltips instantly. Accepts `delay` (`number | { open?, close? }`) and `timeoutMs` props. + +## ARIA + +- Popup: `role="tooltip"` +- Trigger: `aria-describedby` (pointing to the tooltip) diff --git a/packages/headless/src/primitives/tooltip/index.ts b/packages/headless/src/primitives/tooltip/index.ts new file mode 100644 index 00000000000..3cfd72b6a84 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/index.ts @@ -0,0 +1,11 @@ +export * as Tooltip from './parts'; + +export type { + TooltipArrowProps, + TooltipGroupProps, + TooltipPopupProps, + TooltipPortalProps, + TooltipPositionerProps, + TooltipProps, + TooltipTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/tooltip/parts.ts b/packages/headless/src/primitives/tooltip/parts.ts new file mode 100644 index 00000000000..29cab6119b3 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/parts.ts @@ -0,0 +1,7 @@ +export { type TooltipProps, TooltipRoot as Root } from './tooltip-root'; +export { type TooltipTriggerProps, TooltipTrigger as Trigger } from './tooltip-trigger'; +export { type TooltipPortalProps, TooltipPortal as Portal } from './tooltip-portal'; +export { type TooltipPositionerProps, TooltipPositioner as Positioner } from './tooltip-positioner'; +export { type TooltipPopupProps, TooltipPopup as Popup } from './tooltip-popup'; +export { type TooltipArrowProps, TooltipArrow as Arrow } from './tooltip-arrow'; +export { type TooltipGroupProps, TooltipGroup as Group } from './tooltip-group'; diff --git a/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx b/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx new file mode 100644 index 00000000000..5b9662f412a --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FloatingArrow, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { useTooltipContext } from './tooltip-context'; + +export type TooltipArrowProps = Omit, 'context'>; + +export const TooltipArrow = React.forwardRef(function TooltipArrow(props, ref) { + const { floatingContext, arrowRef, placement } = useTooltipContext(); + // Merge the consumer ref with the primitive-owned arrowRef so passing a ref + // does not clobber the ref FloatingArrow relies on for positioning. + const combinedRef = useMergeRefs([arrowRef, ref]); + const side = placement.split('-')[0]; + + return ( + + ); +}); diff --git a/packages/headless/src/primitives/tooltip/tooltip-context.ts b/packages/headless/src/primitives/tooltip/tooltip-context.ts new file mode 100644 index 00000000000..85838af9882 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-context.ts @@ -0,0 +1,34 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface TooltipContextValue { + open: boolean; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const TooltipContext = createContext(null); + +export function useTooltipContext() { + const ctx = useContext(TooltipContext); + if (!ctx) { + throw new Error('Tooltip compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-group.tsx b/packages/headless/src/primitives/tooltip/tooltip-group.tsx new file mode 100644 index 00000000000..21b8535083b --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-group.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { FloatingDelayGroup } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +export interface TooltipGroupProps { + /** Shared delay config for grouped tooltips. Default: { open: 200, close: 100 } */ + delay?: number | { open?: number; close?: number }; + /** Time in ms before the group resets to non-instant phase. Default: 300 */ + timeoutMs?: number; + children: ReactNode; +} + +export function TooltipGroup(props: TooltipGroupProps) { + const { delay = { open: 200, close: 100 }, timeoutMs = 300, children } = props; + return ( + + {children} + + ); +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-popup.tsx b/packages/headless/src/primitives/tooltip/tooltip-popup.tsx new file mode 100644 index 00000000000..8ba242a0c53 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-popup.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTooltipContext } from './tooltip-context'; + +export type TooltipPopupProps = ComponentProps<'div'>; + +export const TooltipPopup = React.forwardRef(function TooltipPopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useTooltipContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'tooltip-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/tooltip/tooltip-portal.tsx b/packages/headless/src/primitives/tooltip/tooltip-portal.tsx new file mode 100644 index 00000000000..07a6d8f3055 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode, RefObject } from 'react'; + +import { useTooltipContext } from './tooltip-context'; + +export interface TooltipPortalProps { + children: ReactNode; + root?: HTMLElement | null | RefObject; +} + +export function TooltipPortal(props: TooltipPortalProps) { + const { mounted } = useTooltipContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx new file mode 100644 index 00000000000..2724158a456 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTooltipContext } from './tooltip-context'; + +export type TooltipPositionerProps = ComponentProps<'div'>; + +export const TooltipPositioner = React.forwardRef( + function TooltipPositioner(props, ref) { + const { render, ...otherProps } = props; + const { mounted, refs, floatingStyles, placement, getFloatingProps } = useTooltipContext(); + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const side = placement.split('-')[0]; + const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>; + const wiredId = floatingProps.id; + + const defaultProps = { + 'data-cl-slot': 'tooltip-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by floating-ui: it pairs with the trigger's aria-describedby. + // A consumer-supplied id must not override it, or the aria pairing would silently break. + merged.id = wiredId; + + return renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: merged, + }); + }, +); diff --git a/packages/headless/src/primitives/tooltip/tooltip-root.tsx b/packages/headless/src/primitives/tooltip/tooltip-root.tsx new file mode 100644 index 00000000000..75d9f356a5c --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-root.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + shift, + useDelayGroup, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { type ReactNode, useMemo, useRef } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { TooltipContext, type TooltipContextValue } from './tooltip-context'; + +export interface TooltipProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + /** Delay in ms before the tooltip opens on hover. Default: 200 */ + delay?: number; + /** Delay in ms before the tooltip closes on hover out. Default: 0 */ + closeDelay?: number; + children: ReactNode; +} + +function TooltipInner(props: TooltipProps) { + const nodeId = useFloatingNodeId(); + + const { placement: placementProp = 'top', sideOffset = 4, delay = 200, closeDelay = 0, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ + crossAxis: placementProp.includes('-'), + fallbackAxisSideDirection: 'start', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + useDelayGroup(floatingContext, { id: nodeId }); + + const hover = useHover(floatingContext, { + move: false, + delay: { open: delay, close: closeDelay }, + }); + const focus = useFocus(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext, { role: 'tooltip' }); + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + popupRef, + arrowRef, + mounted, + transitionProps, + }), + [ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function TooltipRoot(props: TooltipProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx new file mode 100644 index 00000000000..1f54df30f71 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTooltipContext } from './tooltip-context'; + +export type TooltipTriggerProps = ComponentProps<'button'>; + +export const TooltipTrigger = React.forwardRef( + function TooltipTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useTooltipContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'tooltip-trigger', + ref: combinedRef, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/tooltip/tooltip.test.tsx b/packages/headless/src/primitives/tooltip/tooltip.test.tsx new file mode 100644 index 00000000000..0a6fd637ffa --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip.test.tsx @@ -0,0 +1,353 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Tooltip } from './index'; + +afterEach(() => cleanup()); + +function renderTooltip(props: Partial> = {}) { + return render( + + Hover me + + Tooltip content + + , + ); +} + +describe('Tooltip', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderTooltip(); + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-slot', 'tooltip-trigger'); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderTooltip({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens on hover', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + + it('closes on unhover', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + + await user.unhover(trigger); + + const triggerEl = screen.getByRole('button', { name: 'Hover me' }); + expect(triggerEl).toHaveAttribute('data-cl-closed', ''); + }); + + it('opens on focus', async () => { + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await act(() => { + trigger.focus(); + }); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderTooltip({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderTooltip({ onOpenChange }); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderTooltip({ open: true }); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderTooltip({ open: false }); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('trigger has tooltip role association', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + // aria-describedby must reference the tooltip positioner's id + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toBeInTheDocument(); + const positionerId = positioner!.getAttribute('id'); + expect(positionerId).toBeTruthy(); + expect(trigger.getAttribute('aria-describedby')).toBe(positionerId); + }); + + it('tooltip content has role=tooltip', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('focus stays on trigger when tooltip is open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + // Tooltip must not steal focus — active element should not be inside the tooltip + const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); + expect(popup).not.toContainElement(document.activeElement as HTMLElement); + }); + + it('keeps trigger/positioner aria-describedby intact when a custom id is passed to the positioner', async () => { + const user = userEvent.setup(); + render( + + Hover me + + Tooltip content + + , + ); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toBeInTheDocument(); + // The wired id is owned by floating-ui: a consumer-supplied id must not + // silently break the aria-describedby pairing between trigger and positioner. + const positionerId = positioner!.getAttribute('id'); + expect(positionerId).not.toBe('consumer-custom-id'); + expect(trigger.getAttribute('aria-describedby')).toBe(positionerId); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderTooltip(); + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('placement', () => { + it('accepts custom placement', () => { + renderTooltip({ defaultOpen: true, placement: 'bottom-end' }); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + }); + + it('defaults to top placement', () => { + renderTooltip({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'top'); + }); + }); + + describe('trigger state attributes', () => { + it('trigger has data-cl-open when tooltip is visible', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + }); + + it('trigger has data-cl-closed when tooltip is hidden', () => { + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + }); + + describe('content rendering', () => { + it('renders children content when open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(screen.getByText('Tooltip content')).toBeInTheDocument(); + }); + + it('does not render content when closed', () => { + renderTooltip(); + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument(); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderTooltip(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + renderTooltip({ defaultOpen: true }); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); + + describe('Tooltip.Group', () => { + function renderTooltipGroup() { + return render( + + + Button A + + Tooltip A + + + + Button B + + Tooltip B + + + , + ); + } + + it('renders grouped tooltips', () => { + renderTooltipGroup(); + + expect(screen.getByRole('button', { name: 'Button A' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Button B' })).toBeInTheDocument(); + }); + + it('opens tooltip within a group on hover', async () => { + const user = userEvent.setup(); + renderTooltipGroup(); + + const triggerA = screen.getByRole('button', { name: 'Button A' }); + await user.hover(triggerA); + + await waitFor(() => { + expect(screen.getByText('Tooltip A')).toBeInTheDocument(); + }); + }); + + it('switches between grouped tooltips without full delay', async () => { + const user = userEvent.setup(); + renderTooltipGroup(); + + const triggerA = screen.getByRole('button', { name: 'Button A' }); + const triggerB = screen.getByRole('button', { name: 'Button B' }); + + // Open first tooltip (wait for it to appear) + await user.hover(triggerA); + await waitFor(() => { + expect(screen.getByText('Tooltip A')).toBeInTheDocument(); + }); + + // Move to second tooltip — group instant phase should show it quickly + await user.unhover(triggerA); + await user.hover(triggerB); + + await waitFor(() => { + expect(screen.getByText('Tooltip B')).toBeInTheDocument(); + }); + + // First tooltip should no longer be visible + expect(screen.queryByText('Tooltip A')).not.toBeInTheDocument(); + }); + }); + + describe('Arrow ref', () => { + it('merges a consumer ref with the internal arrow ref', () => { + const ref = createRef(); + render( + + Hover me + + + Tooltip content + + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toHaveAttribute('data-cl-slot', 'tooltip-arrow'); + }); + }); +}); diff --git a/packages/headless/src/test-utils/jest-dom.d.ts b/packages/headless/src/test-utils/jest-dom.d.ts new file mode 100644 index 00000000000..69f12cf866b --- /dev/null +++ b/packages/headless/src/test-utils/jest-dom.d.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom/vitest'; +import './vitest-axe'; diff --git a/packages/headless/src/test-utils/vitest-axe.d.ts b/packages/headless/src/test-utils/vitest-axe.d.ts new file mode 100644 index 00000000000..6c19867bb5a --- /dev/null +++ b/packages/headless/src/test-utils/vitest-axe.d.ts @@ -0,0 +1,10 @@ +import 'vitest'; + +import type { AxeMatchers } from 'vitest-axe/matchers'; + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Assertion<_T = unknown> extends AxeMatchers {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface AsymmetricMatchersContaining extends AxeMatchers {} +} diff --git a/packages/headless/src/utils/css-vars.test.ts b/packages/headless/src/utils/css-vars.test.ts new file mode 100644 index 00000000000..4d29ee60643 --- /dev/null +++ b/packages/headless/src/utils/css-vars.test.ts @@ -0,0 +1,269 @@ +import type { MiddlewareState } from '@floating-ui/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { cssVars } from './css-vars'; + +// Build a minimal MiddlewareState mock for testing the middleware fn +function createMockState( + overrides: { + placement?: string; + referenceWidth?: number; + referenceHeight?: number; + floatingWidth?: number; + floatingHeight?: number; + arrowX?: number; + arrowY?: number; + arrowElWidth?: number; + arrowElHeight?: number; + overflow?: { top: number; right: number; bottom: number; left: number }; + } = {}, +): MiddlewareState { + const { + placement = 'bottom', + referenceWidth = 100, + referenceHeight = 40, + floatingWidth = 200, + floatingHeight = 150, + arrowX, + arrowY, + arrowElWidth = 0, + arrowElHeight = 0, + } = overrides; + + const style = { + setProperty: vi.fn(), + }; + + // Mock arrow element inside floating + const arrowEl = arrowElWidth || arrowElHeight ? { clientWidth: arrowElWidth, clientHeight: arrowElHeight } : null; + + const floating = { + style, + querySelector: vi.fn(() => arrowEl), + } as unknown as HTMLElement; + + return { + placement, + elements: { + floating, + reference: document.createElement('div'), + }, + rects: { + reference: { width: referenceWidth, height: referenceHeight, x: 0, y: 0 }, + floating: { width: floatingWidth, height: floatingHeight, x: 0, y: 0 }, + }, + middlewareData: { + arrow: arrowX != null || arrowY != null ? { x: arrowX ?? 0, y: arrowY ?? 0, centerOffset: 0 } : {}, + }, + platform: { + getElementRects: vi.fn(), + getDimensions: vi.fn(), + getClippingRect: vi.fn(async () => ({ + width: 1024, + height: 768, + x: 0, + y: 0, + })), + convertOffsetParentRelativeRectToViewportRelativeRect: vi.fn(async ({ rect }: { rect: unknown }) => rect), + }, + x: 0, + y: 0, + initialPlacement: placement, + strategy: 'absolute', + } as unknown as MiddlewareState; +} + +// Helper to extract setProperty calls into a map +function getVars(state: MiddlewareState): Map { + const style = state.elements.floating.style; + const calls = (style.setProperty as ReturnType).mock.calls as [string, string][]; + return new Map(calls.map(([name, value]) => [name, value])); +} + +describe('cssVars middleware', () => { + it("has name 'cssVars'", () => { + const mw = cssVars(); + expect(mw.name).toBe('cssVars'); + }); + + describe('--cl-anchor-width and --cl-anchor-height', () => { + it('sets anchor dimensions from reference rects', async () => { + const mw = cssVars(); + const state = createMockState({ + referenceWidth: 120, + referenceHeight: 36, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-width')).toBe('120px'); + expect(vars.get('--cl-anchor-height')).toBe('36px'); + }); + + it('handles zero-size reference', async () => { + const mw = cssVars(); + const state = createMockState({ + referenceWidth: 0, + referenceHeight: 0, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-width')).toBe('0px'); + expect(vars.get('--cl-anchor-height')).toBe('0px'); + }); + }); + + describe('--cl-available-width and --cl-available-height', () => { + it('sets available dimensions as CSS vars', async () => { + const mw = cssVars(); + const state = createMockState({ + floatingWidth: 200, + floatingHeight: 150, + }); + await mw.fn(state); + + const vars = getVars(state); + // Values are set (exact numbers depend on detectOverflow's padding) + expect(vars.has('--cl-available-width')).toBe(true); + expect(vars.has('--cl-available-height')).toBe(true); + expect(vars.get('--cl-available-width')).toMatch(/^\d+px$/); + expect(vars.get('--cl-available-height')).toMatch(/^\d+px$/); + }); + }); + + describe('--cl-transform-origin', () => { + it('centers on anchor when no arrow (bottom)', async () => { + const mw = cssVars({ sideOffset: 8 }); + // Reference: x=0, width=100 → center at 50. Floating: x=0. + // transformX = 0 + 100/2 - 0 = 50 + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px -8px'); + }); + + it('centers on anchor when no arrow (top)', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'top', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px calc(100% + 4px)'); + }); + + it('centers on anchor when no arrow (left)', async () => { + const mw = cssVars({ sideOffset: 6 }); + const state = createMockState({ + placement: 'left', + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformY = 0 + 40/2 - 0 = 20 + expect(vars.get('--cl-transform-origin')).toBe('calc(100% + 6px) 20px'); + }); + + it('centers on anchor when no arrow (right)', async () => { + const mw = cssVars({ sideOffset: 6 }); + const state = createMockState({ + placement: 'right', + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('-6px 20px'); + }); + + it('handles alignment variants (e.g. bottom-start)', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom-start', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + // Side is still "bottom", centers on anchor + expect(vars.get('--cl-transform-origin')).toBe('50px -4px'); + }); + + it('uses arrow position when arrow is present', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom', + arrowX: 50, + arrowElWidth: 12, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformX = arrowX + arrowWidth/2 = 50 + 6 = 56 + expect(vars.get('--cl-transform-origin')).toBe('56px -4px'); + }); + + it('uses arrow Y position on left/right placement', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'right', + arrowY: 30, + arrowElHeight: 10, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformY = arrowY + arrowHeight/2 = 30 + 5 = 35 + expect(vars.get('--cl-transform-origin')).toBe('-4px 35px'); + }); + + it('defaults sideOffset to 0 when not provided', async () => { + const mw = cssVars(); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px 0px'); + }); + }); + + describe('return value', () => { + it('returns empty object (no position changes)', async () => { + const mw = cssVars(); + const state = createMockState(); + const result = await mw.fn(state); + expect(result).toEqual({}); + }); + }); + + describe('all five CSS vars are set', () => { + it('sets exactly 5 CSS custom properties', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ placement: 'bottom' }); + await mw.fn(state); + + const style = state.elements.floating.style; + const calls = (style.setProperty as ReturnType).mock.calls as [string, string][]; + const varNames = calls.map(([name]) => name); + + expect(varNames).toEqual([ + '--cl-anchor-width', + '--cl-anchor-height', + '--cl-available-width', + '--cl-available-height', + '--cl-transform-origin', + ]); + }); + }); +}); diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts new file mode 100644 index 00000000000..67c973bbfd2 --- /dev/null +++ b/packages/headless/src/utils/css-vars.ts @@ -0,0 +1,76 @@ +import { detectOverflow, type Middleware } from '@floating-ui/react'; + +/** + * Positioning middleware that sets CSS custom properties on the floating element: + * + * - `--cl-anchor-width` – reference element width (px) + * - `--cl-anchor-height` – reference element height (px) + * - `--cl-available-width` – available width between anchor and viewport edge (px) + * - `--cl-available-height` – available height between anchor and viewport edge (px) + * - `--cl-transform-origin` – CSS transform-origin pointing back toward the anchor + * + * Place **after** `arrow()` so arrow position data is available for transform-origin. + */ +export function cssVars(opts?: { sideOffset?: number }): Middleware { + return { + name: 'cssVars', + async fn(state) { + const { elements, rects, middlewareData, placement } = state; + const style = elements.floating.style; + const sideOffset = opts?.sideOffset ?? 0; + + // Anchor dimensions + style.setProperty('--cl-anchor-width', `${rects.reference.width}px`); + style.setProperty('--cl-anchor-height', `${rects.reference.height}px`); + + // Available space + const overflow = await detectOverflow(state, { padding: 5 }); + const side = placement.split('-')[0] as 'top' | 'bottom' | 'left' | 'right'; + + const availableHeight = + side === 'top' + ? rects.floating.height - overflow.top + : side === 'bottom' + ? rects.floating.height - overflow.bottom + : rects.floating.height - Math.max(overflow.top, 0) - Math.max(overflow.bottom, 0); + + const availableWidth = + side === 'left' + ? rects.floating.width - overflow.left + : side === 'right' + ? rects.floating.width - overflow.right + : rects.floating.width - Math.max(overflow.left, 0) - Math.max(overflow.right, 0); + + style.setProperty('--cl-available-width', `${availableWidth}px`); + style.setProperty('--cl-available-height', `${availableHeight}px`); + + // Transform origin — points back toward the anchor + const arrowEl = elements.floating.querySelector("[data-cl-slot$='-arrow']"); + + let transformX: number; + let transformY: number; + + if (arrowEl) { + const arrowX = middlewareData.arrow?.x ?? 0; + const arrowY = middlewareData.arrow?.y ?? 0; + transformX = arrowX + arrowEl.clientWidth / 2; + transformY = arrowY + arrowEl.clientHeight / 2; + } else { + // No arrow — use the anchor's center relative to the floating element + transformX = rects.reference.x + rects.reference.width / 2 - state.x; + transformY = rects.reference.y + rects.reference.height / 2 - state.y; + } + + const originMap: Record = { + top: `${transformX}px calc(100% + ${sideOffset}px)`, + bottom: `${transformX}px ${-sideOffset}px`, + left: `calc(100% + ${sideOffset}px) ${transformY}px`, + right: `${-sideOffset}px ${transformY}px`, + }; + + style.setProperty('--cl-transform-origin', originMap[side]); + + return {}; + }, + }; +} diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index 53fd01599c8..ebfd4caefaa 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1 +1,2 @@ +export { cssVars } from './css-vars'; export { type ComponentProps, mergeProps, type RenderProp, renderElement } from './render-element'; diff --git a/packages/headless/tsconfig.json b/packages/headless/tsconfig.json index c4540461007..2a7544581d4 100644 --- a/packages/headless/tsconfig.json +++ b/packages/headless/tsconfig.json @@ -10,7 +10,7 @@ "moduleResolution": "bundler", "moduleDetection": "force", "module": "preserve", - "lib": ["ES2023", "DOM"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "isolatedModules": true, "forceConsistentCasingInFileNames": true, @@ -19,5 +19,5 @@ "noEmit": true }, "include": ["src"], - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] + "exclude": ["node_modules"] } diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 99ff3925b72..8e7f6f2f831 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -1,3 +1,4 @@ +import preserveDirectives from 'rollup-plugin-preserve-directives'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; @@ -12,6 +13,13 @@ export default defineConfig({ lib: { entry: { 'primitives/accordion/index': 'src/primitives/accordion/index.ts', + 'primitives/tabs/index': 'src/primitives/tabs/index.ts', + 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', + 'primitives/popover/index': 'src/primitives/popover/index.ts', + 'primitives/select/index': 'src/primitives/select/index.ts', + 'primitives/menu/index': 'src/primitives/menu/index.ts', + 'primitives/autocomplete/index': 'src/primitives/autocomplete/index.ts', + 'primitives/collapsible/index': 'src/primitives/collapsible/index.ts', 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'utils/index': 'src/utils/index.ts', 'hooks/index': 'src/hooks/index.ts', @@ -24,10 +32,23 @@ export default defineConfig({ }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime', '@floating-ui/react'], + // Preserve module-level directives such as `'use client'`. Rollup otherwise + // strips them when bundling (emitting a warning), which would drop the + // client boundary for React Server Component consumers of the primitives. + plugins: [preserveDirectives()], output: { preserveModules: true, preserveModulesRoot: 'src', }, + // Rollup still warns that it ignored the directives during bundling even + // though preserveDirectives re-attaches them to the output chunks. Silence + // the now-expected noise (recommended by the plugin). + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return; + } + warn(warning); + }, }, sourcemap: true, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c8386e7ee7..0605508b533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -675,6 +675,9 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + rollup-plugin-preserve-directives: + specifier: ^0.4.0 + version: 0.4.0(rollup@4.60.2) typescript: specifier: catalog:repo version: 5.8.3 @@ -12705,6 +12708,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-preserve-directives@0.4.0: + resolution: {integrity: sha512-gx4nBxYm5BysmEQS+e2tAMrtFxrGvk+Pe5ppafRibQi0zlW7VYAbEGk6IKDw9sJGPdFWgVTE0o4BU4cdG0Fylg==} + peerDependencies: + rollup: 2.x || 3.x || 4.x + rollup-plugin-visualizer@7.0.1: resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} engines: {node: '>=22'} @@ -29526,6 +29534,12 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + rollup-plugin-preserve-directives@0.4.0(rollup@4.60.2): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) + magic-string: 0.30.21 + rollup: 4.60.2 + rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.47(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2): dependencies: open: 11.0.0
` | Description, wired to `aria-describedby` | +| `Popover.Close` | `` | Closes the popover on click | + +## Props + +### `Popover.Root` + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ---------- | ---------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"bottom"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | +| `modal` | `boolean` | `false` | Traps focus within the popover | + +### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Popover.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard + +| Key | Action | +| -------- | -------------------------------------------------------------------- | +| `Escape` | Closes the popover | +| `Tab` | Cycles focus within popover (modal mode) or moves freely (non-modal) | + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"popover-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Open state | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Positioning + +Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popup auto-repositions on scroll and resize via `autoUpdate`. Cross-axis flipping is enabled only when using an aligned placement (e.g. `"bottom-start"`). + +## Important Notes + +- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent. +- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap. +- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically. + +## ARIA + +- Positioner: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description) +- Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls` diff --git a/packages/headless/src/primitives/popover/index.ts b/packages/headless/src/primitives/popover/index.ts new file mode 100644 index 00000000000..8a3021d9abe --- /dev/null +++ b/packages/headless/src/primitives/popover/index.ts @@ -0,0 +1,13 @@ +export * as Popover from './parts'; + +export type { + PopoverArrowProps, + PopoverCloseProps, + PopoverDescriptionProps, + PopoverPopupProps, + PopoverPortalProps, + PopoverPositionerProps, + PopoverProps, + PopoverTitleProps, + PopoverTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/popover/parts.ts b/packages/headless/src/primitives/popover/parts.ts new file mode 100644 index 00000000000..0ac2a6651e8 --- /dev/null +++ b/packages/headless/src/primitives/popover/parts.ts @@ -0,0 +1,9 @@ +export { type PopoverProps, PopoverRoot as Root } from './popover-root'; +export { type PopoverTriggerProps, PopoverTrigger as Trigger } from './popover-trigger'; +export { type PopoverPortalProps, PopoverPortal as Portal } from './popover-portal'; +export { type PopoverPositionerProps, PopoverPositioner as Positioner } from './popover-positioner'; +export { type PopoverPopupProps, PopoverPopup as Popup } from './popover-popup'; +export { type PopoverArrowProps, PopoverArrow as Arrow } from './popover-arrow'; +export { type PopoverTitleProps, PopoverTitle as Title } from './popover-title'; +export { type PopoverDescriptionProps, PopoverDescription as Description } from './popover-description'; +export { type PopoverCloseProps, PopoverClose as Close } from './popover-close'; diff --git a/packages/headless/src/primitives/popover/popover-arrow.tsx b/packages/headless/src/primitives/popover/popover-arrow.tsx new file mode 100644 index 00000000000..438c514b26d --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-arrow.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FloatingArrow, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { usePopoverContext } from './popover-context'; + +export type PopoverArrowProps = Omit, 'context'>; + +export const PopoverArrow = React.forwardRef(function PopoverArrow(props, ref) { + const { floatingContext, arrowRef, placement } = usePopoverContext(); + // Merge the consumer ref with the primitive-owned arrowRef so passing a ref + // does not clobber the ref FloatingArrow relies on for positioning. + const combinedRef = useMergeRefs([arrowRef, ref]); + const side = placement.split('-')[0]; + + return ( + + ); +}); diff --git a/packages/headless/src/primitives/popover/popover-close.tsx b/packages/headless/src/primitives/popover/popover-close.tsx new file mode 100644 index 00000000000..545ee097df6 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-close.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverCloseProps = ComponentProps<'button'>; + +export function PopoverClose(props: PopoverCloseProps) { + const { render, ...otherProps } = props; + const { setOpen } = usePopoverContext(); + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-close', + onClick() { + setOpen(false); + }, + }; + + return renderElement({ + defaultTagName: 'button', + render, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/popover/popover-context.ts b/packages/headless/src/primitives/popover/popover-context.ts new file mode 100644 index 00000000000..de0bdf5f0b0 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-context.ts @@ -0,0 +1,42 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface PopoverContextValue { + open: boolean; + setOpen: (open: boolean) => void; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + modal: boolean; + labelId: string; + descriptionId: string; + hasTitle: boolean; + hasDescription: boolean; + setHasTitle: (v: boolean) => void; + setHasDescription: (v: boolean) => void; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const PopoverContext = createContext(null); + +export function usePopoverContext() { + const ctx = useContext(PopoverContext); + if (!ctx) { + throw new Error('Popover compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/popover/popover-description.tsx b/packages/headless/src/primitives/popover/popover-description.tsx new file mode 100644 index 00000000000..598de543142 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-description.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverDescriptionProps = Omit, 'id'>; + +export function PopoverDescription(props: PopoverDescriptionProps) { + const { render, ...otherProps } = props; + const { descriptionId, setHasDescription } = usePopoverContext(); + + useEffect(() => { + setHasDescription(true); + return () => setHasDescription(false); + }, [setHasDescription]); + + const defaultProps = { + 'data-cl-slot': 'popover-description', + id: descriptionId, + }; + + return renderElement({ + defaultTagName: 'p', + render, + props: mergeProps<'p'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/popover/popover-popup.tsx b/packages/headless/src/primitives/popover/popover-popup.tsx new file mode 100644 index 00000000000..197ebfd9a5a --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-popup.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverPopupProps = ComponentProps<'div'>; + +export const PopoverPopup = React.forwardRef(function PopoverPopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = usePopoverContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'popover-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/popover/popover-portal.tsx b/packages/headless/src/primitives/popover/popover-portal.tsx new file mode 100644 index 00000000000..651b33c93a3 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { usePopoverContext } from './popover-context'; + +export interface PopoverPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function PopoverPortal(props: PopoverPortalProps) { + const { mounted } = usePopoverContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx new file mode 100644 index 00000000000..7e6a8e1abae --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { FloatingFocusManager, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverPositionerProps = ComponentProps<'div'>; + +export const PopoverPositioner = React.forwardRef( + function PopoverPositioner(props, ref) { + const { render, ...otherProps } = props; + const { + mounted, + floatingContext, + refs, + floatingStyles, + placement, + getFloatingProps, + modal, + labelId, + descriptionId, + hasTitle, + hasDescription, + } = usePopoverContext(); + + const side = placement.split('-')[0]; + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const defaultProps = { + 'data-cl-slot': 'popover-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...(hasTitle && { 'aria-labelledby': labelId }), + ...(hasDescription && { 'aria-describedby': descriptionId }), + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + }; + + const element = renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + if (!element) { + return null; + } + + return ( + + {element} + + ); + }, +); diff --git a/packages/headless/src/primitives/popover/popover-root.tsx b/packages/headless/src/primitives/popover/popover-root.tsx new file mode 100644 index 00000000000..3edf3efabb4 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-root.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + shift, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { PopoverContext, type PopoverContextValue } from './popover-context'; + +export interface PopoverProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + modal?: boolean; + children: ReactNode; +} + +function PopoverInner(props: PopoverProps) { + const nodeId = useFloatingNodeId(); + const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const labelId = useId(); + const descriptionId = useId(); + const [hasTitle, setHasTitle] = useState(false); + const [hasDescription, setHasDescription] = useState(false); + const setHasTitleCb = useCallback((v: boolean) => setHasTitle(v), []); + const setHasDescriptionCb = useCallback((v: boolean) => setHasDescription(v), []); + + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ + crossAxis: placementProp.includes('-'), + fallbackAxisSideDirection: 'end', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + popupRef, + arrowRef, + modal, + labelId, + descriptionId, + hasTitle, + hasDescription, + setHasTitle: setHasTitleCb, + setHasDescription: setHasDescriptionCb, + mounted, + transitionProps, + }), + [ + open, + setOpen, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + modal, + labelId, + descriptionId, + hasTitle, + hasDescription, + setHasTitleCb, + setHasDescriptionCb, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function PopoverRoot(props: PopoverProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/popover/popover-title.tsx b/packages/headless/src/primitives/popover/popover-title.tsx new file mode 100644 index 00000000000..62d3605d007 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-title.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverTitleProps = Omit, 'id'>; + +export function PopoverTitle(props: PopoverTitleProps) { + const { render, ...otherProps } = props; + const { labelId, setHasTitle } = usePopoverContext(); + + useEffect(() => { + setHasTitle(true); + return () => setHasTitle(false); + }, [setHasTitle]); + + const defaultProps = { + 'data-cl-slot': 'popover-title', + id: labelId, + }; + + return renderElement({ + defaultTagName: 'h2', + render, + props: mergeProps<'h2'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/popover/popover-trigger.tsx b/packages/headless/src/primitives/popover/popover-trigger.tsx new file mode 100644 index 00000000000..8231e348bed --- /dev/null +++ b/packages/headless/src/primitives/popover/popover-trigger.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { usePopoverContext } from './popover-context'; + +export type PopoverTriggerProps = ComponentProps<'button'>; + +export const PopoverTrigger = React.forwardRef( + function PopoverTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = usePopoverContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'popover-trigger', + ref: combinedRef, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/popover/popover.test.tsx b/packages/headless/src/primitives/popover/popover.test.tsx new file mode 100644 index 00000000000..d27ed3e8ad7 --- /dev/null +++ b/packages/headless/src/primitives/popover/popover.test.tsx @@ -0,0 +1,361 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Popover } from './index'; + +afterEach(() => cleanup()); + +function renderPopover(props: Partial> = {}) { + return render( + + Open popover + + + Popover Title + Some description + Popover content + Close + + + , + ); +} + +describe('Popover', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderPopover(); + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-slot', 'popover-trigger'); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-title"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-description"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="popover-close"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + }); + + it('closes on trigger click when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('closes via Close button', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + const closeBtn = screen.getByRole('button', { name: 'Close' }); + await user.click(closeBtn); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderPopover({ onOpenChange }); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it('closes on outside click', async () => { + const user = userEvent.setup(); + renderPopover({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + + await user.click(document.body); + + expect(document.querySelector('[data-cl-slot="popover-popup"]')).not.toBeInTheDocument(); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderPopover({ open: true }); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderPopover({ open: false }); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('positioner has aria-labelledby linked to title', () => { + renderPopover({ defaultOpen: true }); + + const title = document.querySelector('[data-cl-slot="popover-title"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(title).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + }); + + it('positioner has aria-describedby linked to description', () => { + renderPopover({ defaultOpen: true }); + + const desc = document.querySelector('[data-cl-slot="popover-description"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(desc).toHaveAttribute('id'); + expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); + }); + + it('trigger has role=button', () => { + renderPopover(); + expect(screen.getByRole('button', { name: 'Open popover' })).toBeInTheDocument(); + }); + + it('keeps positioner aria-labelledby/aria-describedby wired to the correct elements', () => { + // The primitive owns the ids on Title and Description (id is omitted from + // their public props) — the aria pairing must always resolve correctly. + renderPopover({ defaultOpen: true }); + + const title = document.querySelector('[data-cl-slot="popover-title"]'); + const desc = document.querySelector('[data-cl-slot="popover-description"]'); + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); + expect(title).toHaveTextContent('Popover Title'); + expect(desc).toHaveTextContent('Some description'); + }); + + it('omits aria-labelledby and aria-describedby when no Title or Description is rendered', () => { + // Title and Description are optional. When absent, the positioner must not + // emit dangling idrefs pointing at elements that were never rendered. + render( + + Open popover + + + Popover content + + + , + ); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).not.toHaveAttribute('aria-labelledby'); + expect(positioner).not.toHaveAttribute('aria-describedby'); + }); + + it('sets only aria-labelledby when a Title is rendered without a Description', () => { + render( + + Open popover + + + Popover Title + Popover content + + + , + ); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + const title = document.querySelector('[data-cl-slot="popover-title"]'); + expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); + expect(positioner).not.toHaveAttribute('aria-describedby'); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderPopover(); + expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const popup = document.querySelector('[data-cl-slot="popover-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('content rendering', () => { + it('renders children content when open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + + expect(screen.getByText('Popover content')).toBeInTheDocument(); + expect(screen.getByText('Popover Title')).toBeInTheDocument(); + expect(screen.getByText('Some description')).toBeInTheDocument(); + }); + }); + + describe('placement', () => { + it('accepts custom placement', () => { + renderPopover({ defaultOpen: true, placement: 'top-start' }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'top'); + }); + + it('defaults to bottom placement', () => { + renderPopover({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + }); + }); + + describe('focus management', () => { + it('moves focus into popover on open', async () => { + const user = userEvent.setup(); + renderPopover(); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + // FloatingFocusManager schedules focus via requestAnimationFrame + await new Promise(r => requestAnimationFrame(r)); + + const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + expect(positioner?.contains(document.activeElement)).toBe(true); + }); + + it('returns focus to trigger on close via Escape', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + await user.keyboard('{Escape}'); + + expect(document.activeElement).toBe(trigger); + }); + + it('returns focus to trigger on close via Close button', async () => { + const user = userEvent.setup(); + renderPopover(); + + const trigger = screen.getByRole('button', { name: 'Open popover' }); + await user.click(trigger); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(document.activeElement).toBe(trigger); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderPopover(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + renderPopover({ defaultOpen: true }); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); + + describe('consumer ref forwarding', () => { + it('forwards a consumer ref on Trigger (host button shape)', () => { + const ref = createRef(); + render( + + Open popover + , + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-trigger'); + }); + + it('forwards a consumer ref on Positioner (FloatingFocusManager wrapper shape)', () => { + const ref = createRef(); + render( + + Open popover + + content + + , + ); + + expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-positioner'); + }); + }); + + describe('Arrow ref', () => { + it('merges a consumer ref with the internal arrow ref', () => { + const ref = createRef(); + render( + + Open popover + + + + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-arrow'); + }); + }); +}); diff --git a/packages/headless/src/primitives/select/README.md b/packages/headless/src/primitives/select/README.md new file mode 100644 index 00000000000..47bd2ef3feb --- /dev/null +++ b/packages/headless/src/primitives/select/README.md @@ -0,0 +1,163 @@ +# Select + +A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `` with a fully styled, accessible alternative. + +## When to Use + +- Picking a single value from a predefined list of options. +- When you need typeahead, keyboard navigation, and full styling control. +- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter. + +## Usage + +```tsx +import { Select } from '@/primitives/select'; + + + + + + + + + + + + +; +``` + +### Controlled + +```tsx +const [value, setValue] = useState('apple'); + + + {/* ... */} +; +``` + +### With `items` for SSR label resolution + +The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists): + +```tsx +const items = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, +]; + + + {/* Select.Value will display "Apple" even before Options mount */} +; +``` + +### Disable item-to-trigger alignment + +By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning: + +```tsx +{/* Uses standard Floating UI positioning */} +``` + +## Parts + +| Part | Default Element | Description | +| ------------------- | --------------- | ------------------------------------------ | +| `Select.Root` | — | Root context provider | +| `Select.Trigger` | `` | Toggles the dropdown on click | +| `Select.Value` | `` | Displays the selected label or placeholder | +| `Select.Portal` | — | Portals children (accepts `root` prop) | +| `Select.Positioner` | `` | Floating positioned container | +| `Select.Popup` | `` | Visual wrapper for the option list | +| `Select.Option` | `` | A selectable option | +| `Select.Arrow` | `` | Optional floating arrow | + +## Props + +### `Select.Root` + +| Prop | Type | Default | Description | +| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ | +| `value` | `string` | — | Controlled selected value | +| `defaultValue` | `string` | — | Initial selected value (uncontrolled) | +| `onValueChange` | `(value: string) => void` | — | Called when selection changes | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount | +| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger | +| `placement` | `Placement` | `"bottom-start"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) | + +### `Select.Value` + +| Prop | Type | Default | Description | +| ------------- | ----------- | ------- | ------------------------------- | +| `placeholder` | `ReactNode` | — | Shown when no value is selected | + +### `Select.Option` + +| Prop | Type | Default | Description | +| ---------- | --------- | --------------------- | -------------------------------------- | +| `value` | `string` | **required** | The option's value | +| `label` | `string` | falls back to `value` | Display label, also used for typeahead | +| `disabled` | `boolean` | — | Prevents selection | + +### `Select.Trigger`, `Select.Positioner`, `Select.Popup` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Select.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Keyboard Navigation + +| Key | Action | +| ----------------- | ------------------------------------- | +| `ArrowDown` | Move to next option | +| `ArrowUp` | Move to previous option | +| `Enter` / `Space` | Select the active option, close popup | +| `Escape` | Close the popup | +| Type a character | Jump to matching option (typeahead) | + +Typeahead is active only while the popup is open. It highlights the matching option; pressing Enter or Space then selects it. + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ---------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"select-option"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Popup open state | +| `data-cl-selected` | Option | The currently selected option | +| `data-cl-active` | Option | The keyboard-highlighted option | +| `data-cl-disabled` | Option | Disabled option | +| `data-cl-side` | Positioner | Resolved placement side | + +## Important Notes + +- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both. +- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself. +- **Disabled options** can still receive keyboard focus but cannot be selected. + +## ARIA + +- Popup: `role="listbox"` +- Option: `role="option"`, `aria-selected`, `aria-disabled` +- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls` diff --git a/packages/headless/src/primitives/select/index.ts b/packages/headless/src/primitives/select/index.ts new file mode 100644 index 00000000000..659c55ba1db --- /dev/null +++ b/packages/headless/src/primitives/select/index.ts @@ -0,0 +1,13 @@ +export * as Select from './parts'; + +export type { + SelectArrowProps, + SelectItem, + SelectOptionProps, + SelectPopupProps, + SelectPortalProps, + SelectPositionerProps, + SelectProps, + SelectTriggerProps, + SelectValueProps, +} from './parts'; diff --git a/packages/headless/src/primitives/select/parts.ts b/packages/headless/src/primitives/select/parts.ts new file mode 100644 index 00000000000..1ce683e4f84 --- /dev/null +++ b/packages/headless/src/primitives/select/parts.ts @@ -0,0 +1,8 @@ +export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root'; +export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger'; +export { type SelectValueProps, SelectValue as Value } from './select-value'; +export { type SelectPortalProps, SelectPortal as Portal } from './select-portal'; +export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner'; +export { type SelectPopupProps, SelectPopup as Popup } from './select-popup'; +export { type SelectOptionProps, SelectOption as Option } from './select-option'; +export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow'; diff --git a/packages/headless/src/primitives/select/select-arrow.tsx b/packages/headless/src/primitives/select/select-arrow.tsx new file mode 100644 index 00000000000..948dadcdf75 --- /dev/null +++ b/packages/headless/src/primitives/select/select-arrow.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FloatingArrow } from '@floating-ui/react'; +import React from 'react'; + +import { useSelectContext } from './select-context'; + +export type SelectArrowProps = React.ComponentPropsWithRef; + +export function SelectArrow(props: SelectArrowProps) { + const { floatingContext, arrowRef, placement } = useSelectContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} diff --git a/packages/headless/src/primitives/select/select-context.ts b/packages/headless/src/primitives/select/select-context.ts new file mode 100644 index 00000000000..a26ec66413e --- /dev/null +++ b/packages/headless/src/primitives/select/select-context.ts @@ -0,0 +1,52 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, type RefObject, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface SelectItem { + label: string; + value: string; +} + +export interface SelectContextValue { + open: boolean; + items: SelectItem[] | undefined; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + getItemProps: UseInteractionsReturn['getItemProps']; + activeIndex: number | null; + setActiveIndex: React.Dispatch>; + selectedIndex: number | null; + selectedValue: string | undefined; + selectedLabel: string | null; + elementsRef: React.MutableRefObject>; + labelsRef: React.MutableRefObject>; + popupRef: RefObject; + arrowRef: React.MutableRefObject; + valueToLabelRef: React.MutableRefObject>; + selectedItemRef: React.MutableRefObject; + alignItemWithTrigger: boolean; + handleSelect: (value: string, index: number) => void; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const SelectContext = createContext(null); + +export function useSelectContext() { + const ctx = useContext(SelectContext); + if (!ctx) { + throw new Error('Select compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/select/select-option.tsx b/packages/headless/src/primitives/select/select-option.tsx new file mode 100644 index 00000000000..42a76e29897 --- /dev/null +++ b/packages/headless/src/primitives/select/select-option.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useListItem, useMergeRefs } from '@floating-ui/react'; +import type React from 'react'; +import { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export interface SelectOptionProps extends ComponentProps<'button'> { + value: string; + label?: string; + disabled?: boolean; +} + +export function SelectOption(props: SelectOptionProps) { + const { render, value, label, disabled, ...otherProps } = props; + const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } = + useSelectContext(); + + const displayLabel = label ?? value; + const { ref: itemRef, index } = useListItem({ label: displayLabel }); + + const isSelected = selectedValue === value; + const isActive = activeIndex === index; + + useEffect(() => { + valueToLabelRef.current.set(value, displayLabel); + return () => { + valueToLabelRef.current.delete(value); + }; + }, [value, displayLabel, valueToLabelRef]); + + const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]); + + const state = { + selected: isSelected, + active: isActive, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'select-option', + type: 'button' as const, + ref: combinedRef, + role: 'option' as const, + 'aria-selected': isSelected, + 'aria-disabled': disabled || undefined, + tabIndex: isActive ? 0 : -1, + ...(getItemProps({ + onClick() { + if (!disabled) { + handleSelect(value, index); + } + }, + }) as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/select/select-popup.tsx b/packages/headless/src/primitives/select/select-popup.tsx new file mode 100644 index 00000000000..9d8ba64bc17 --- /dev/null +++ b/packages/headless/src/primitives/select/select-popup.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export type SelectPopupProps = ComponentProps<'div'>; + +export const SelectPopup = React.forwardRef(function SelectPopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useSelectContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'select-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/select/select-portal.tsx b/packages/headless/src/primitives/select/select-portal.tsx new file mode 100644 index 00000000000..8de3ba97f92 --- /dev/null +++ b/packages/headless/src/primitives/select/select-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { useSelectContext } from './select-context'; + +export interface SelectPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function SelectPortal(props: SelectPortalProps) { + const { mounted } = useSelectContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/select/select-positioner.tsx b/packages/headless/src/primitives/select/select-positioner.tsx new file mode 100644 index 00000000000..16ba6fee87c --- /dev/null +++ b/packages/headless/src/primitives/select/select-positioner.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export type SelectPositionerProps = ComponentProps<'div'>; + +export const SelectPositioner = React.forwardRef( + function SelectPositioner(props, ref) { + const { render, ...otherProps } = props; + const { + mounted, + floatingContext, + refs, + floatingStyles, + placement, + getFloatingProps, + elementsRef, + labelsRef, + setActiveIndex, + } = useSelectContext(); + + const side = placement.split('-')[0]; + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const floatingProps = getFloatingProps({ + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + const items = elementsRef.current; + if (event.key === 'Home') { + const firstEnabled = items.findIndex(el => el != null && el.getAttribute('aria-disabled') !== 'true'); + if (firstEnabled !== -1) { + setActiveIndex(firstEnabled); + } + } else { + for (let i = items.length - 1; i >= 0; i--) { + const el = items[i]; + if (el != null && el.getAttribute('aria-disabled') !== 'true') { + setActiveIndex(i); + break; + } + } + } + } + }, + }) as React.ComponentPropsWithRef<'div'>; + + const defaultProps = { + 'data-cl-slot': 'select-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The listbox id is owned by floating-ui's listbox role: a consumer-supplied + // id must not override it, or the trigger's aria-controls pairing would + // silently break. + if (floatingProps.id != null) { + merged.id = floatingProps.id; + } + + return ( + + + {renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: merged, + })} + + + ); + }, +); diff --git a/packages/headless/src/primitives/select/select-root.tsx b/packages/headless/src/primitives/select/select-root.tsx new file mode 100644 index 00000000000..ed3a85113ee --- /dev/null +++ b/packages/headless/src/primitives/select/select-root.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + type Middleware, + offset, + type Placement, + shift, + size, + useClick, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useListNavigation, + useRole, + useTypeahead, +} from '@floating-ui/react'; +import { type ReactNode, type RefObject, useCallback, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { SelectContext, type SelectContextValue, type SelectItem } from './select-context'; + +export type { SelectItem } from './select-context'; + +export interface SelectProps { + /** Array of `{ label, value }` items for label resolution before options mount. */ + items?: SelectItem[]; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + /** + * When true, the popup is positioned so the selected item overlays the + * trigger — like a native ``. Defaults to `true`. + */ + alignItemWithTrigger?: boolean; + placement?: Placement; + sideOffset?: number; + children: ReactNode; +} + +function alignSelectedItem(selectedItemRef: RefObject): Middleware { + return { + name: 'alignSelectedItem', + fn({ elements }) { + const selectedEl = selectedItemRef.current; + if (!selectedEl) { + return {}; + } + + const floatingRect = elements.floating.getBoundingClientRect(); + const selectedRect = selectedEl.getBoundingClientRect(); + const referenceRect = (elements.reference as HTMLElement).getBoundingClientRect(); + + const itemOffsetInPopup = selectedRect.top - floatingRect.top; + const desiredTop = referenceRect.top - itemOffsetInPopup; + + const viewportHeight = window.innerHeight; + const clampedTop = Math.max(8, Math.min(desiredTop, viewportHeight - floatingRect.height - 8)); + + return { + x: referenceRect.left, + y: clampedTop, + }; + }, + }; +} + +function SelectInner(props: SelectProps) { + const { + items, + alignItemWithTrigger: alignProp = true, + placement: placementProp = 'bottom-start', + sideOffset = 4, + children, + } = props; + + const nodeId = useFloatingNodeId(); + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const [selectedValue, setSelectedValue] = useControllableState( + props.value, + props.defaultValue, + props.onValueChange as ((value: string | undefined) => void) | undefined, + ); + + const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedLabel, setSelectedLabel] = useState(null); + const [activeIndex, setActiveIndex] = useState(null); + + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const arrowRef = useRef(null); + const popupRef = useRef(null); + const valueToLabelRef = useRef>(new Map()); + const selectedItemRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(alignProp ? 0 : sideOffset), + ...(!alignProp ? [flip(), shift({ padding: 5 })] : []), + size({ + apply({ availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxHeight: `${availableHeight}px`, + }); + }, + }), + ...(!alignProp ? [arrow({ element: arrowRef })] : []), + ...(alignProp ? [alignSelectedItem(selectedItemRef)] : []), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + const isControlled = props.value !== undefined; + + const handleSelect = useCallback( + (value: string, index: number) => { + setSelectedValue(value); + setSelectedIndex(index); + // In controlled mode the parent decides whether to accept the new value. + // If they reject it, selectedValue rolls back but selectedLabel would not, + // showing a stale label. Only cache the label in uncontrolled mode where + // the value always persists after selection. + if (!isControlled) { + setSelectedLabel(valueToLabelRef.current.get(value) ?? value); + } + setOpen(false); + }, + [isControlled, setSelectedValue, setOpen], + ); + + const handleTypeaheadMatch = useCallback( + (index: number | null) => { + if (open) { + setActiveIndex(index); + } else if (index !== null) { + setSelectedIndex(index); + } + }, + [open], + ); + + const click = useClick(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext, { role: 'listbox' }); + const listNav = useListNavigation(floatingContext, { + listRef: elementsRef, + activeIndex, + selectedIndex, + onNavigate: setActiveIndex, + loop: true, + }); + const typeahead = useTypeahead(floatingContext, { + listRef: labelsRef, + activeIndex, + selectedIndex, + onMatch: handleTypeaheadMatch, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ + click, + dismiss, + role, + listNav, + typeahead, + ]); + + const contextValue = useMemo( + () => ({ + open, + items, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + selectedIndex, + selectedValue, + selectedLabel, + elementsRef, + labelsRef, + popupRef, + arrowRef, + valueToLabelRef, + selectedItemRef, + alignItemWithTrigger: alignProp, + handleSelect, + mounted, + transitionProps, + }), + [ + open, + items, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + setActiveIndex, + selectedIndex, + selectedValue, + selectedLabel, + alignProp, + handleSelect, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function SelectRoot(props: SelectProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/select/select-trigger.tsx b/packages/headless/src/primitives/select/select-trigger.tsx new file mode 100644 index 00000000000..750458c18c0 --- /dev/null +++ b/packages/headless/src/primitives/select/select-trigger.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useSelectContext } from './select-context'; + +export type SelectTriggerProps = ComponentProps<'button'>; + +export const SelectTrigger = React.forwardRef( + function SelectTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useSelectContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'select-trigger', + ref: combinedRef, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/select/select-value.tsx b/packages/headless/src/primitives/select/select-value.tsx new file mode 100644 index 00000000000..8c28ece29d8 --- /dev/null +++ b/packages/headless/src/primitives/select/select-value.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import type { SelectItem } from './select-context'; +import { useSelectContext } from './select-context'; + +function resolveLabel( + value: string | undefined, + items: SelectItem[] | undefined, + valueToLabelRef: React.MutableRefObject>, +): string | null { + if (value === undefined) { + return null; + } + if (items) { + const item = items.find(i => i.value === value); + if (item) { + return item.label; + } + } + const label = valueToLabelRef.current.get(value); + if (label) { + return label; + } + return value; +} + +export interface SelectValueProps extends ComponentProps<'span'> { + placeholder?: ReactNode; +} + +export function SelectValue(props: SelectValueProps) { + const { render, placeholder, ...otherProps } = props; + const { selectedValue, selectedLabel, items, valueToLabelRef } = useSelectContext(); + + const displayText = + selectedValue !== undefined ? (selectedLabel ?? resolveLabel(selectedValue, items, valueToLabelRef)) : placeholder; + + const defaultProps = { + 'data-cl-slot': 'select-value', + children: displayText, + }; + + return renderElement({ + defaultTagName: 'span', + render, + props: mergeProps<'span'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/select/select.test.tsx b/packages/headless/src/primitives/select/select.test.tsx new file mode 100644 index 00000000000..8b2f414f65b --- /dev/null +++ b/packages/headless/src/primitives/select/select.test.tsx @@ -0,0 +1,838 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Select, type SelectItem } from './index'; + +afterEach(() => cleanup()); + +const fruits: SelectItem[] = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Cherry', value: 'cherry' }, +]; + +function renderSelect(props: Partial> = {}) { + const { children, ...rest } = props as Record; + return render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); +} + +describe('Select', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderSelect(); + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('data-cl-slot', 'select-trigger'); + }); + + it('renders value with data-cl-slot', () => { + renderSelect(); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value).toBeInTheDocument(); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderSelect({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const popup = document.querySelector('[data-cl-slot="select-popup"]'); + const options = document.querySelectorAll('[data-cl-slot="select-option"]'); + + expect(positioner).toBeInTheDocument(); + expect(popup).toBeInTheDocument(); + expect(options).toHaveLength(3); + }); + }); + + describe('ARIA attributes', () => { + it('keeps the trigger/listbox aria-controls pairing intact when a custom id is passed to the positioner', () => { + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + const trigger = screen.getByRole('combobox'); + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + + // The listbox id is owned by floating-ui: a consumer-supplied id must not + // override it, or the trigger's aria-controls pairing would silently break. + expect(positioner).not.toHaveAttribute('id', 'consumer-custom-id'); + expect(trigger.getAttribute('aria-controls')).toBe(positioner?.getAttribute('id')); + }); + }); + + describe('items prop and label resolution', () => { + it('shows placeholder when no value is selected', () => { + renderSelect(); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Pick a fruit...'); + }); + + it('resolves label from items before options mount', () => { + renderSelect({ defaultValue: 'banana' }); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Banana'); + }); + + it('resolves label for controlled value from items', () => { + renderSelect({ value: 'cherry' }); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Cherry'); + }); + + it('falls back to raw value when no items provided', () => { + render( + + + + + + + + Banana + + + + , + ); + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('banana'); + }); + + it('updates label after selection via option registry', async () => { + const user = userEvent.setup(); + // No items prop — labels only known once options mount + render( + + + + + + + + Special Item + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Special Item')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Special Item'); + }); + }); + + describe('open/close', () => { + it('opens on trigger click', async () => { + const user = userEvent.setup(); + renderSelect(); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-cl-slot="select-popup"]')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderSelect({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderSelect({ onOpenChange }); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('selection', () => { + it('selects option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + renderSelect({ onValueChange }); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + const option = screen.getByText('Banana'); + await user.click(option); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + }); + + it('displays selected label in Value after selection', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Cherry')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toContain('Cherry'); + }); + }); + + describe('controlled value', () => { + it('does not display a stale label when the controlled parent rejects the change', async () => { + const user = userEvent.setup(); + // The parent keeps `value` pinned to 'apple' and ignores onValueChange, + // so the displayed label must stay "Apple" even after clicking "Banana". + render( + {}} + > + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Banana')); + + const value = document.querySelector('[data-cl-slot="select-value"]'); + expect(value?.textContent).toBe('Apple'); + }); + }); + + describe('keyboard navigation', () => { + it('navigates options with arrow keys', async () => { + const user = userEvent.setup(); + renderSelect(); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + + it('scrolls options into view on arrow key navigation', async () => { + const manyItems = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + const user = userEvent.setup(); + render( + + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + // Navigate down through many items to force scrolling + for (let i = 0; i < 15; i++) { + await user.keyboard('{ArrowDown}'); + } + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + + // The active item should be visible within its scroll container + const scrollContainer = activeOption!.closest('div[style]') as HTMLElement; + const optionRect = activeOption!.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + + expect(optionRect.bottom).toBeLessThanOrEqual(containerRect.bottom + 1); + expect(optionRect.top).toBeGreaterThanOrEqual(containerRect.top - 1); + }); + + it('scrolls selected item into view when reopening', async () => { + const manyItems = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + const user = userEvent.setup(); + render( + + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + + , + ); + + const trigger = screen.getByRole('combobox'); + + // Open, navigate to item near the bottom, select it + await user.click(trigger); + for (let i = 0; i < 15; i++) { + await user.keyboard('{ArrowDown}'); + } + await user.keyboard('{Enter}'); + + // Reopen — the selected item should be scrolled into view + await user.click(trigger); + + const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + expect(selectedOption).toBeInTheDocument(); + + const scrollContainer = selectedOption!.closest('div[style]') as HTMLElement; + const optionRect = selectedOption!.getBoundingClientRect(); + const containerRect = scrollContainer.getBoundingClientRect(); + + expect(optionRect.bottom).toBeLessThanOrEqual(containerRect.bottom + 1); + expect(optionRect.top).toBeGreaterThanOrEqual(containerRect.top - 1); + }); + }); + + describe('option state attributes', () => { + it('marks selected option with data-cl-selected', () => { + renderSelect({ defaultValue: 'banana', defaultOpen: true }); + + const options = document.querySelectorAll('[data-cl-slot="select-option"]'); + expect(options[1]).toHaveAttribute('data-cl-selected', ''); + }); + + it('marks active option with data-cl-active', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}'); + + const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + expect(activeOption).toBeInTheDocument(); + }); + }); + + describe('disabled option', () => { + it('renders disabled option with data-cl-disabled', async () => { + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + const disabledOption = screen.getByText('Banana').closest("[data-cl-slot='select-option']"); + expect(disabledOption).toHaveAttribute('data-cl-disabled', ''); + expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not select disabled option on click', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.click(screen.getByText('Banana')); + + expect(onValueChange).not.toHaveBeenCalledWith('banana'); + }); + }); + + describe('ARIA attributes', () => { + it('options have role=option', () => { + renderSelect({ defaultOpen: true }); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + }); + + it('selected option has aria-selected=true', () => { + renderSelect({ defaultValue: 'apple', defaultOpen: true }); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderSelect(); + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + + const popup = document.querySelector('[data-cl-slot="select-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderSelect(); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderSelect({ open: true }); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderSelect({ open: false }); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).not.toBeInTheDocument(); + }); + }); + + describe('alignItemWithTrigger', () => { + it('defaults to true', () => { + // Render without explicitly setting alignItemWithTrigger + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + // The positioner should render (alignItemWithTrigger doesn't prevent rendering) + const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + expect(positioner).toBeInTheDocument(); + }); + + it('uses standard floating styles when disabled', async () => { + const user = userEvent.setup(); + renderSelect({ alignItemWithTrigger: false }); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + // Standard Floating UI positioning uses position: absolute with transform + expect(positioner.style.position).toBe('absolute'); + }); + + const manyItems: SelectItem[] = Array.from({ length: 20 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: `item-${i + 1}`, + })); + + it('aligns selected item with trigger vertically', () => { + render( + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + + const trigger = screen.getByRole('combobox'); + const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + expect(selectedOption).toBeInTheDocument(); + + const triggerRect = trigger.getBoundingClientRect(); + const selectedRect = selectedOption!.getBoundingClientRect(); + + // The selected item should be positioned near the trigger's vertical position + expect(Math.abs(selectedRect.top - triggerRect.top)).toBeLessThan(50); + }); + + // Requires real layout engine — getBoundingClientRect returns 0 in happy-dom + it.skip('repositions when ancestor scrolls', async () => { + const user = userEvent.setup(); + render( + + + + + + + + {manyItems.map(({ label, value }) => ( + + {label} + + ))} + + + + + , + ); + + await user.click(screen.getByRole('combobox')); + + const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + const initialTop = positioner.getBoundingClientRect().top; + + // Scroll the container + const scrollContainer = screen.getByTestId('scroll-container'); + scrollContainer.scrollTop = 100; + scrollContainer.dispatchEvent(new Event('scroll')); + + // autoUpdate repositions on scroll — wait for the update + await waitFor(() => { + const newTop = positioner.getBoundingClientRect().top; + expect(newTop).not.toBe(initialTop); + }); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + + it('has no violations with a selected value', async () => { + const { container } = render( + + + + + + + {fruits.map(({ label, value }) => ( + + {label} + + ))} + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + describe('option button type', () => { + it('renders options with type="button" so they do not submit a wrapping form', () => { + renderSelect({ defaultOpen: true }); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + for (const option of options) { + expect(option).toHaveAttribute('type', 'button'); + } + }); + }); + + describe('Home/End navigation honors aria-disabled value', () => { + it('treats aria-disabled="false" as enabled when pressing Home', async () => { + const user = userEvent.setup(); + render( + + + + + + + + Apple + + + Banana + + + Cherry + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{Home}'); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-disabled', 'false'); + expect(options[0]).toHaveAttribute('data-cl-active'); + }); + }); +}); diff --git a/packages/headless/src/primitives/tabs/README.md b/packages/headless/src/primitives/tabs/README.md new file mode 100644 index 00000000000..b51d40f3723 --- /dev/null +++ b/packages/headless/src/primitives/tabs/README.md @@ -0,0 +1,199 @@ +# Tabs + +A tabbed interface with automatic or manual activation, keyboard navigation, and an animated indicator. Panels are shown/hidden via the HTML `hidden` attribute (not unmounted). + +## When to Use + +- Switching between views or content sections within the same page area. +- When you need accessible tab navigation with `role="tablist"` / `role="tab"` / `role="tabpanel"`. +- Prefer Tabs over Accordion when content sections are mutually exclusive and should feel like parallel views. + +## Usage + +```tsx +import { Tabs } from '@/primitives/tabs'; + + + + Account + Security + Notifications + + + Account settings content + Security settings content + Notification preferences content +; +``` + +### Controlled + +```tsx +const [value, setValue] = useState('tab1'); + + + {/* ... */} +; +``` + +### Manual Activation + +By default, arrowing to a tab immediately activates it. Use `activationMode="manual"` to require Enter/Space: + +```tsx +{/* Arrow keys move focus, Enter/Space activates */} +``` + +### Vertical Orientation + +```tsx +{/* Arrow Up/Down navigates instead of Left/Right */} +``` + +## Parts + +| Part | Default Element | Description | +| ---------------- | --------------- | -------------------------------------------------- | +| `Tabs.Root` | — | Root context provider | +| `Tabs.List` | `` | Container for tabs (`role="tablist"`) | +| `Tabs.Tab` | `` | A tab trigger inside `Tabs.List` (`role="tab"`) | +| `Tabs.Trigger` | `` | Standalone tab trigger for use outside `Tabs.List` | +| `Tabs.Panel` | `` | Content panel (`role="tabpanel"`) | +| `Tabs.Indicator` | `` | Animated indicator tracking the active tab | + +## Props + +### `Tabs.Root` + +| Prop | Type | Default | Description | +| ---------------- | ---------------------------- | -------------- | ----------------------------------------- | +| `value` | `string` | — | Controlled active tab | +| `defaultValue` | `string` | `""` | Initial active tab (uncontrolled) | +| `onValueChange` | `(value: string) => void` | — | Called when active tab changes | +| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Arrow key direction | +| `activationMode` | `"automatic" \| "manual"` | `"automatic"` | Whether focus activates a tab immediately | + +### `Tabs.Tab` + +| Prop | Type | Default | Description | +| ---------- | --------- | ------------ | ---------------------------------------------------------- | +| `value` | `string` | **required** | Unique tab identifier, must match a Panel's `value` | +| `disabled` | `boolean` | — | Disables the tab (uses `aria-disabled`, remains focusable) | + +### `Tabs.Trigger` + +A standalone tab button for use outside `Tabs.List`. Unlike `Tabs.Tab`, it does not participate in roving tabindex keyboard navigation — it's a plain button with `onClick`. + +| Prop | Type | Default | Description | +| ---------- | --------- | ------------ | -------------------------------------------- | +| `value` | `string` | **required** | Tab identifier, must match a Panel's `value` | +| `disabled` | `boolean` | — | Disables the trigger | + +### `Tabs.Panel` + +| Prop | Type | Default | Description | +| ------------------ | --------- | ------------ | ------------------------------------------------------------------- | +| `value` | `string` | **required** | Must match a Tab's `value` | +| `shouldForceMount` | `boolean` | — | When true, keeps the panel in layout flow instead of using `hidden` | + +### `Tabs.List`, `Tabs.Indicator` + +No additional props beyond standard HTML attributes and the `render` prop. + +## Keyboard Navigation + +| Key | Action (horizontal) | Action (vertical) | +| ----------------- | -------------------------- | -------------------------- | +| `ArrowRight` | Next tab | — | +| `ArrowLeft` | Previous tab | — | +| `ArrowDown` | — | Next tab | +| `ArrowUp` | — | Previous tab | +| `Enter` / `Space` | Activate tab (manual mode) | Activate tab (manual mode) | + +## Data Attributes + +| Attribute | Applies To | Description | +| ------------------------ | ------------ | ----------------------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"tabs-tab"`, `"tabs-trigger"`) | +| `data-cl-selected` | Tab, Trigger | Active tab | +| `data-cl-disabled` | Tab, Trigger | Disabled tab | +| `data-cl-hidden` | Panel | Inactive panel | +| `data-cl-open` | Panel | Selected panel (when `shouldForceMount`) | +| `data-cl-closed` | Panel | Deselected panel (when `shouldForceMount`) | +| `data-cl-starting-style` | Panel | Enter animation frame (when `shouldForceMount`) | +| `data-cl-ending-style` | Panel | Exit animation frame (when `shouldForceMount`) | + +## CSS Variables + +### Indicator + +`Tabs.Indicator` exposes CSS custom properties for positioning and sizing: + +| CSS Variable | Description | +| ----------------- | ---------------------------------- | +| `--cl-tab-left` | Left offset of the active tab (px) | +| `--cl-tab-width` | Width of the active tab (px) | +| `--cl-tab-top` | Top offset of the active tab (px) | +| `--cl-tab-height` | Height of the active tab (px) | + +Use these to animate the indicator: + +```css +[data-cl-slot='tabs-indicator'] { + position: absolute; + left: var(--cl-tab-left); + width: var(--cl-tab-width); + transition: + left 200ms ease, + width 200ms ease; +} +``` + +The initial render suppresses the transition to prevent the indicator from animating from `0,0`. + +### Panel (with `shouldForceMount`) + +When `shouldForceMount` is set, panels expose a direction variable for directional animations: + +| CSS Variable | Description | +| ------------------------------- | -------------------------------------------------------------- | +| `--cl-tab-transition-direction` | `"1"` when navigating forward, `"-1"` when navigating backward | + +Use this to drive directional slide animations: + +```css +[data-cl-slot='tabs-panel'] { + --_direction: var(--cl-tab-transition-direction, 1); + transition: + opacity 200ms, + translate 200ms; +} +[data-cl-slot='tabs-panel'][data-cl-starting-style], +[data-cl-slot='tabs-panel'][data-cl-ending-style] { + opacity: 0; +} +[data-cl-slot='tabs-panel'][data-cl-starting-style] { + translate: calc(var(--_direction) * 8px) 0; +} +[data-cl-slot='tabs-panel'][data-cl-ending-style] { + translate: calc(var(--_direction) * -8px) 0; +} +``` + +## Important Notes + +- **`Tabs.List` must have `position: relative`** in your CSS for the indicator to position correctly. +- **Panels use the `hidden` attribute** by default — they stay in the DOM but are hidden when inactive. This preserves state in inactive panels. +- **`shouldForceMount` panels** stay in layout flow with `inert` on inactive panels. This enables CSS enter/exit animations between tabs. The initially-selected panel appears instantly (no enter animation on page load). +- **`Tabs.Trigger` vs `Tabs.Tab`**: Use `Tabs.Tab` inside `Tabs.List` for keyboard-navigable tabs with roving tabindex. Use `Tabs.Trigger` for standalone tab buttons placed anywhere in the tree (e.g., in a sidebar). +- **Disabled tabs use `aria-disabled`**, not the native `disabled` attribute, keeping them focusable for keyboard users. +- **Indicator is `aria-hidden`** — it's purely decorative. + +## ARIA + +- List: `role="tablist"` +- Tab: `role="tab"`, `aria-selected`, `aria-controls` (pointing to its panel), `aria-disabled` +- Panel: `role="tabpanel"`, `aria-labelledby` (pointing to its tab), `tabIndex={0}` diff --git a/packages/headless/src/primitives/tabs/index.ts b/packages/headless/src/primitives/tabs/index.ts new file mode 100644 index 00000000000..86038858f5b --- /dev/null +++ b/packages/headless/src/primitives/tabs/index.ts @@ -0,0 +1,10 @@ +export * as Tabs from './parts'; + +export type { + TabsIndicatorProps, + TabsListProps, + TabsPanelProps, + TabsProps, + TabsTabProps, + TabsTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/tabs/parts.ts b/packages/headless/src/primitives/tabs/parts.ts new file mode 100644 index 00000000000..81a526095d0 --- /dev/null +++ b/packages/headless/src/primitives/tabs/parts.ts @@ -0,0 +1,6 @@ +export { type TabsProps, TabsRoot as Root } from './tabs-root'; +export { type TabsListProps, TabsList as List } from './tabs-list'; +export { type TabsTabProps, TabsTab as Tab } from './tabs-tab'; +export { type TabsTriggerProps, TabsTrigger as Trigger } from './tabs-trigger'; +export { type TabsPanelProps, TabsPanel as Panel } from './tabs-panel'; +export { type TabsIndicatorProps, TabsIndicator as Indicator } from './tabs-indicator'; diff --git a/packages/headless/src/primitives/tabs/tabs-context.ts b/packages/headless/src/primitives/tabs/tabs-context.ts new file mode 100644 index 00000000000..4c4c6600527 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-context.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react'; + +export interface TabsContextValue { + value: string; + setValue: (value: string) => void; + orientation: 'horizontal' | 'vertical'; + activationMode: 'automatic' | 'manual'; + tabsId: string; + registerTab: (value: string, element: HTMLElement | null) => void; + getTabElement: (value: string) => HTMLElement | null; + listRef: React.RefObject; + direction: 1 | -1; +} + +export const TabsContext = createContext(null); + +export function useTabsContext() { + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error('Tabs compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/tabs/tabs-indicator.tsx b/packages/headless/src/primitives/tabs/tabs-indicator.tsx new file mode 100644 index 00000000000..116367a1ddc --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-indicator.tsx @@ -0,0 +1,80 @@ +'use client'; + +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export type TabsIndicatorProps = ComponentProps<'span'>; + +export function TabsIndicator(props: TabsIndicatorProps) { + const { render, ...otherProps } = props; + const { value, getTabElement, orientation, listRef } = useTabsContext(); + + const [style, setStyle] = useState({}); + const previousRectRef = useRef<{ + left: number; + top: number; + width: number; + height: number; + } | null>(null); + + useEffect(() => { + const el = getTabElement(value); + const list = listRef.current; + if (!el || !list) { + return; + } + + const measure = () => { + const tabRect = el.getBoundingClientRect(); + const listRect = list.getBoundingClientRect(); + + const newRect = { + left: tabRect.left - listRect.left, + top: tabRect.top - listRect.top, + width: tabRect.width, + height: tabRect.height, + }; + + const prev = previousRectRef.current; + previousRectRef.current = newRect; + + const sharedVars = { + ['--cl-tab-left' as string]: `${newRect.left}px`, + ['--cl-tab-width' as string]: `${newRect.width}px`, + ['--cl-tab-top' as string]: `${newRect.top}px`, + ['--cl-tab-height' as string]: `${newRect.height}px`, + ...(prev == null ? { transition: 'none' } : {}), + }; + + if (orientation === 'horizontal') { + setStyle({ position: 'absolute', left: newRect.left, width: newRect.width, ...sharedVars }); + } else { + setStyle({ position: 'absolute', top: newRect.top, height: newRect.height, ...sharedVars }); + } + }; + + measure(); + + // Keep the indicator in sync when the active tab or the list changes size + // (font load, container resize) without a tab-selection change. + const ro = new ResizeObserver(measure); + ro.observe(el); + ro.observe(list); + return () => ro.disconnect(); + }, [value, getTabElement, orientation, listRef]); + + const defaultProps = { + 'data-cl-slot': 'tabs-indicator', + 'aria-hidden': true as const, + style, + }; + + return renderElement({ + defaultTagName: 'span', + render, + props: mergeProps<'span'>(defaultProps, otherProps), + }); +} diff --git a/packages/headless/src/primitives/tabs/tabs-list.tsx b/packages/headless/src/primitives/tabs/tabs-list.tsx new file mode 100644 index 00000000000..05af1a70986 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-list.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Composite } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export type TabsListProps = ComponentProps<'div'>; + +export function TabsList(props: TabsListProps) { + const { render, children, ...otherProps } = props; + const { orientation, listRef } = useTabsContext(); + + return ( + ) => { + const defaultProps: Record = { + 'data-cl-slot': 'tabs-list', + role: 'tablist' as const, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key !== 'Home' && event.key !== 'End') { + return; + } + event.preventDefault(); + const items = Array.from(event.currentTarget.querySelectorAll('[role="tab"]:not([disabled])')); + if (items.length === 0) { + return; + } + const target = event.key === 'Home' ? items[0] : items[items.length - 1]; + target.focus(); + }, + }; + + const merged = mergeProps<'div'>( + defaultProps, + mergeProps<'div'>(otherProps, compositeProps as Record), + ); + + return renderElement({ + defaultTagName: 'div', + render, + props: merged, + }); + }} + > + {children} + + ); +} diff --git a/packages/headless/src/primitives/tabs/tabs-panel.tsx b/packages/headless/src/primitives/tabs/tabs-panel.tsx new file mode 100644 index 00000000000..dfe659c10bb --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React, { useRef } from 'react'; + +import { useTransition } from '../../hooks/use-transition'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export interface TabsPanelProps extends ComponentProps<'div'> { + value: string; + /** When true, removes `hidden` so the panel stays in layout flow. */ + shouldForceMount?: boolean; +} + +export const TabsPanel = React.forwardRef(function TabsPanel(props, ref) { + const { render, value: panelValue, shouldForceMount, ...otherProps } = props; + const { value: selectedValue, tabsId, direction } = useTabsContext(); + + const isSelected = selectedValue === panelValue; + const tabId = `${tabsId}-tab-${panelValue}`; + const panelId = `${tabsId}-panel-${panelValue}`; + + const panelRef = useRef(null); + // Merge the consumer ref with the internal panelRef so passing a ref does not + // clobber the ref the panel relies on for transition tracking. + const combinedRef = useMergeRefs([panelRef, ref]); + const { transitionProps } = useTransition({ + open: isSelected, + ref: panelRef, + }); + + // Suppress enter animation on initial mount so the initially-selected panel + // appears instantly. After the panel has been deselected once, subsequent + // selections will animate normally. Matches the Accordion pattern. + const hasBeenDeselected = useRef(false); + if (!isSelected) { + hasBeenDeselected.current = true; + } + + const effectiveTransitionProps = + shouldForceMount && !hasBeenDeselected.current + ? { ...transitionProps, 'data-cl-starting-style': undefined, style: undefined } + : transitionProps; + + const state = { hidden: !isSelected }; + + const defaultProps = { + 'data-cl-slot': 'tabs-panel', + id: panelId, + role: 'tabpanel' as const, + 'aria-labelledby': tabId, + tabIndex: 0, + // `inert` must be a truthy string, not a boolean or empty string, to stay + // correct across React 18 and 19: React 18 drops a boolean `true` and React + // 19 treats `''` as falsy. `'true'` renders the (presence-based) attribute in + // both. Matches the existing pattern in packages/ui PricingTableMatrix. + inert: !isSelected ? 'true' : undefined, + hidden: !isSelected && !shouldForceMount ? true : undefined, + ref: combinedRef, + ...(shouldForceMount + ? { + ...effectiveTransitionProps, + style: { ...effectiveTransitionProps.style, ['--cl-tab-transition-direction' as string]: String(direction) }, + } + : {}), + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the tab/panel aria pairing would silently break. + merged.id = panelId; + + return renderElement({ + defaultTagName: 'div', + render, + state, + stateAttributesMapping: { + hidden: (v: boolean) => (v ? { 'data-cl-hidden': '' } : null), + }, + props: merged, + }); +}); diff --git a/packages/headless/src/primitives/tabs/tabs-root.tsx b/packages/headless/src/primitives/tabs/tabs-root.tsx new file mode 100644 index 00000000000..181e478573c --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-root.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { TabsContext, type TabsContextValue } from './tabs-context'; + +export interface TabsProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + orientation?: 'horizontal' | 'vertical'; + activationMode?: 'automatic' | 'manual'; + children: ReactNode; +} + +export function TabsRoot(props: TabsProps) { + const { orientation = 'horizontal', activationMode = 'automatic', children } = props; + + const [value, setValueRaw] = useControllableState(props.value, props.defaultValue ?? '', props.onValueChange); + + const [direction, setDirection] = useState<1 | -1>(1); + const tabsId = useId(); + const tabElementsRef = useRef>(new Map()); + const tabOrderRef = useRef([]); + const listRef = useRef(null); + const valueRef = useRef(value); + valueRef.current = value; + + const registerTab = useCallback((tabValue: string, element: HTMLElement | null) => { + if (element) { + tabElementsRef.current.set(tabValue, element); + if (!tabOrderRef.current.includes(tabValue)) { + tabOrderRef.current.push(tabValue); + } + } else { + tabElementsRef.current.delete(tabValue); + tabOrderRef.current = tabOrderRef.current.filter(v => v !== tabValue); + } + }, []); + + const getTabElement = useCallback((tabValue: string) => { + return tabElementsRef.current.get(tabValue) ?? null; + }, []); + + const setValue = useCallback( + (newValue: string) => { + const prevIndex = tabOrderRef.current.indexOf(valueRef.current); + const nextIndex = tabOrderRef.current.indexOf(newValue); + if (prevIndex !== -1 && nextIndex !== -1 && nextIndex !== prevIndex) { + setDirection(nextIndex > prevIndex ? 1 : -1); + } + setValueRaw(newValue); + }, + [setValueRaw], + ); + + const contextValue = useMemo( + () => ({ + value, + setValue, + orientation, + activationMode, + tabsId, + registerTab, + getTabElement, + listRef, + direction, + }), + [value, setValue, orientation, activationMode, tabsId, registerTab, getTabElement, direction], + ); + + return {children}; +} diff --git a/packages/headless/src/primitives/tabs/tabs-tab.tsx b/packages/headless/src/primitives/tabs/tabs-tab.tsx new file mode 100644 index 00000000000..e610e91c188 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-tab.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { CompositeItem, useMergeRefs } from '@floating-ui/react'; +import React, { useLayoutEffect, useRef } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export interface TabsTabProps extends ComponentProps<'button'> { + value: string; + disabled?: boolean; +} + +export const TabsTab = React.forwardRef(function TabsTab(props, ref) { + const { render, value: tabValue, disabled, children, ...otherProps } = props; + const { value: selectedValue, setValue, activationMode, tabsId, registerTab } = useTabsContext(); + + const isSelected = selectedValue === tabValue; + const tabId = `${tabsId}-tab-${tabValue}`; + const panelId = `${tabsId}-panel-${tabValue}`; + const internalRef = useRef(null); + const combinedRef = useMergeRefs([internalRef, ref]); + + useLayoutEffect(() => { + registerTab(tabValue, internalRef.current); + return () => registerTab(tabValue, null); + }, [tabValue, registerTab]); + + const state = { + selected: isSelected, + disabled: !!disabled, + }; + + return ( + ) => { + const defaultProps: Record = { + 'data-cl-slot': 'tabs-tab', + id: tabId, + role: 'tab' as const, + type: 'button' as const, + 'aria-selected': isSelected, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + }; + + if (activationMode === 'automatic') { + defaultProps.onFocus = () => { + if (!disabled) { + setValue(tabValue); + } + }; + } else { + defaultProps.onClick = () => { + if (!disabled) { + setValue(tabValue); + } + }; + defaultProps.onKeyDown = (event: React.KeyboardEvent) => { + if (!disabled && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + setValue(tabValue); + } + }; + } + + // Merge: defaultProps first, then consumer props, then composite props last + // (composite needs to win on tabIndex, data-active, onFocus, ref) + const merged = mergeProps<'button'>( + mergeProps<'button'>(defaultProps, otherProps), + compositeProps as Record, + ); + + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the tab/panel aria pairing would silently break. + merged.id = tabId; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: merged, + }); + }} + > + {children} + + ); +}); diff --git a/packages/headless/src/primitives/tabs/tabs-trigger.tsx b/packages/headless/src/primitives/tabs/tabs-trigger.tsx new file mode 100644 index 00000000000..74b4ef72ce9 --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs-trigger.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React, { useLayoutEffect, useRef } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTabsContext } from './tabs-context'; + +export interface TabsTriggerProps extends ComponentProps<'button'> { + value: string; + disabled?: boolean; +} + +export const TabsTrigger = React.forwardRef(function TabsTrigger(props, ref) { + const { render, value: tabValue, disabled, ...otherProps } = props; + const { value: selectedValue, setValue, tabsId, registerTab } = useTabsContext(); + const triggerRef = useRef(null); + const combinedRef = useMergeRefs([triggerRef, ref]); + + const isSelected = selectedValue === tabValue; + const tabId = `${tabsId}-tab-${tabValue}`; + const panelId = `${tabsId}-panel-${tabValue}`; + + useLayoutEffect(() => { + registerTab(tabValue, triggerRef.current); + return () => registerTab(tabValue, null); + }, [tabValue, registerTab]); + + const state = { + selected: isSelected, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'tabs-trigger', + ref: combinedRef, + id: tabId, + role: 'tab' as const, + type: 'button' as const, + 'aria-selected': isSelected, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + onClick: () => { + if (!disabled) { + setValue(tabValue); + } + }, + }; + + const merged = mergeProps<'button'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the tab/panel aria pairing would silently break. + merged.id = tabId; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: merged, + }); +}); diff --git a/packages/headless/src/primitives/tabs/tabs.test.tsx b/packages/headless/src/primitives/tabs/tabs.test.tsx new file mode 100644 index 00000000000..24a8ec960dc --- /dev/null +++ b/packages/headless/src/primitives/tabs/tabs.test.tsx @@ -0,0 +1,618 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Tabs } from './index'; + +afterEach(() => cleanup()); + +function renderTabs(props: Partial> = {}) { + return render( + + + Account + Settings + Billing + + Account content + Settings content + Billing content + , + ); +} + +describe('Tabs', () => { + describe('slot attributes', () => { + it('renders list with data-cl-slot', () => { + renderTabs(); + expect(document.querySelector('[data-cl-slot="tabs-list"]')).toBeInTheDocument(); + }); + + it('renders tabs with data-cl-slot', () => { + renderTabs(); + const tabs = document.querySelectorAll('[data-cl-slot="tabs-tab"]'); + expect(tabs).toHaveLength(3); + }); + + it('renders panels with data-cl-slot', () => { + renderTabs(); + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + expect(panels).toHaveLength(3); + }); + }); + + describe('ARIA attributes', () => { + it('list has role=tablist', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toBeInTheDocument(); + }); + + it('list has aria-orientation', () => { + renderTabs(); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal'); + }); + + it('vertical orientation sets aria-orientation', () => { + renderTabs({ orientation: 'vertical' }); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('tabs have role=tab', () => { + renderTabs(); + expect(screen.getAllByRole('tab')).toHaveLength(3); + }); + + it('selected tab has aria-selected=true', () => { + renderTabs(); + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'false'); + }); + + it('panels have role=tabpanel', () => { + renderTabs(); + // Only the selected panel is visible + expect(screen.getByRole('tabpanel')).toBeInTheDocument(); + }); + + it('tab has aria-controls linking to panel', () => { + renderTabs(); + const tab = screen.getByText('Account'); + const panelId = tab.getAttribute('aria-controls'); + expect(panelId).toBeTruthy(); + expect(document.getElementById(panelId!)).toHaveTextContent('Account content'); + }); + + it('panel has aria-labelledby linking to tab', () => { + renderTabs(); + const panel = screen.getByRole('tabpanel'); + const tabId = panel.getAttribute('aria-labelledby'); + expect(tabId).toBeTruthy(); + expect(document.getElementById(tabId!)).toHaveTextContent('Account'); + }); + + it('keeps tab/panel association intact when a custom id is passed to the tab', () => { + render( + + + + Account + + + Account content + , + ); + const tab = screen.getByRole('tab', { name: 'Account' }); + const panel = screen.getByRole('tabpanel'); + + // The wired ids are owned by the primitive: a consumer-supplied id must + // not silently break the aria pairing between tab and panel. + expect(tab).toHaveAttribute('aria-controls', panel.getAttribute('id')); + expect(panel).toHaveAttribute('aria-labelledby', tab.getAttribute('id')); + }); + }); + + describe('selection', () => { + it('shows selected panel content', () => { + renderTabs(); + expect(screen.getByText('Account content')).toBeVisible(); + expect(screen.getByText('Settings content')).not.toBeVisible(); + }); + + it('selects tab on click', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Settings')); + + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings content')).toBeVisible(); + }); + + it('marks selected tab with data-cl-selected', () => { + renderTabs(); + expect(screen.getByText('Account')).toHaveAttribute('data-cl-selected', ''); + expect(screen.getByText('Settings').hasAttribute('data-cl-selected')).toBe(false); + }); + + it('calls onValueChange on selection', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + renderTabs({ onValueChange }); + + await user.click(screen.getByText('Settings')); + + expect(onValueChange).toHaveBeenCalledWith('tab2'); + }); + }); + + describe('controlled value', () => { + it('respects controlled value prop', () => { + renderTabs({ value: 'tab2' }); + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings content')).toBeVisible(); + }); + + it('does not change internally when controlled', async () => { + const user = userEvent.setup(); + renderTabs({ value: 'tab1' }); + + await user.click(screen.getByText('Settings')); + + // Still on tab1 since controlled + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('keyboard navigation — automatic activation', () => { + it('moves focus with ArrowRight', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).toBe(screen.getByText('Settings')); + // Automatic mode: focus triggers selection + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + }); + + it('moves focus with ArrowLeft', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Settings')); + await user.keyboard('{ArrowLeft}'); + + expect(document.activeElement).toBe(screen.getByText('Account')); + }); + + it('wraps around at the end (loop)', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Billing')); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).toBe(screen.getByText('Account')); + }); + + it('wraps around at the start', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowLeft}'); + + expect(document.activeElement).toBe(screen.getByText('Billing')); + }); + + it('uses ArrowDown/ArrowUp for vertical orientation', async () => { + const user = userEvent.setup(); + renderTabs({ orientation: 'vertical' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowDown}'); + + expect(document.activeElement).toBe(screen.getByText('Settings')); + }); + + it('moves focus to first tab with Home', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Billing')); + await user.keyboard('{Home}'); + + expect(document.activeElement).toBe(screen.getByText('Account')); + }); + + it('moves focus to last tab with End', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{End}'); + + expect(document.activeElement).toBe(screen.getByText('Billing')); + }); + + it('Tab key moves focus from tab to panel', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Account')); + await user.tab(); + + const panel = screen.getByRole('tabpanel'); + expect(document.activeElement).toBe(panel); + }); + + it('Home skips disabled tabs', async () => { + const user = userEvent.setup(); + render( + + + + First + + Second + Third + + Panel 1 + Panel 2 + Panel 3 + , + ); + + await user.click(screen.getByText('Third')); + await user.keyboard('{Home}'); + + expect(document.activeElement).toBe(screen.getByText('Second')); + }); + }); + + describe('keyboard navigation — manual activation', () => { + it('moves focus without selecting', async () => { + const user = userEvent.setup(); + renderTabs({ activationMode: 'manual' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).toBe(screen.getByText('Settings')); + // Manual mode: focus does NOT select + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'false'); + }); + + it('selects on Enter', async () => { + const user = userEvent.setup(); + renderTabs({ activationMode: 'manual' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}{Enter}'); + + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + }); + + it('selects on Space', async () => { + const user = userEvent.setup(); + renderTabs({ activationMode: 'manual' }); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight} '); + + expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('disabled tab', () => { + function renderWithDisabled() { + return render( + + + Account + + Settings + + Billing + + Account content + Settings content + Billing content + , + ); + } + + it('disabled tab has data-cl-disabled', () => { + renderWithDisabled(); + expect(screen.getByText('Settings')).toHaveAttribute('data-cl-disabled', ''); + }); + + it('disabled tab has aria-disabled', () => { + renderWithDisabled(); + expect(screen.getByText('Settings')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('skips disabled tab during keyboard navigation', async () => { + const user = userEvent.setup(); + renderWithDisabled(); + + await user.click(screen.getByText('Account')); + await user.keyboard('{ArrowRight}'); + + // Should skip Settings (disabled) and land on Billing + expect(document.activeElement).toBe(screen.getByText('Billing')); + }); + + it('does not select disabled tab on click', async () => { + const user = userEvent.setup(); + renderWithDisabled(); + + await user.click(screen.getByText('Settings')); + + expect(screen.getByText('Account')).toHaveAttribute('aria-selected', 'true'); + }); + }); + + describe('Tabs.Indicator', () => { + function renderWithIndicator() { + return render( + + + Account + Settings + + + Account content + Settings content + , + ); + } + + it('renders with data-cl-slot', () => { + renderWithIndicator(); + expect(document.querySelector('[data-cl-slot="tabs-indicator"]')).toBeInTheDocument(); + }); + + it('has position absolute', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + expect(indicator.style.position).toBe('absolute'); + }); + + it('has aria-hidden', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + expect(indicator).toHaveAttribute('aria-hidden', 'true'); + }); + + it('sets --cl-tab-width and --cl-tab-left CSS vars', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + // In a real browser, getBoundingClientRect returns actual measurements + expect(indicator.style.getPropertyValue('--cl-tab-width')).toBeTruthy(); + expect(indicator.style.getPropertyValue('--cl-tab-left')).toBeTruthy(); + }); + + it('updates position when tab changes', async () => { + const user = userEvent.setup(); + renderWithIndicator(); + + const indicator = screen.getByTestId('indicator'); + + await user.click(screen.getByText('Settings')); + + // Verify the effect ran and style properties are set + expect(indicator.style.position).toBe('absolute'); + expect(indicator.style.getPropertyValue('--cl-tab-width')).toBeDefined(); + }); + + it('skips transition on initial render', () => { + renderWithIndicator(); + const indicator = screen.getByTestId('indicator'); + expect(indicator.style.transition).toBe('none'); + }); + }); + + describe('Tabs.Indicator resize tracking (B1)', () => { + it('re-measures the active tab when it resizes without a tab change', () => { + // Capture every ResizeObserver the tree creates so the test can drive it. + const observers: Array<{ cb: ResizeObserverCallback; targets: Element[] }> = []; + class MockResizeObserver { + cb: ResizeObserverCallback; + targets: Element[] = []; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + observers.push(this); + } + observe(el: Element) { + this.targets.push(el); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', MockResizeObserver); + + try { + render( + + + Account + Settings + + + Account content + Settings content + , + ); + + const indicator = screen.getByTestId('indicator'); + const list = document.querySelector('[data-cl-slot="tabs-list"]') as HTMLElement; + + // The indicator should have registered an observer for its active tab. + expect(observers.length).toBeGreaterThan(0); + + // Simulate the active tab growing (e.g. font load / container resize) + // with no tab-selection change. + const rect = (left: number, width: number) => + ({ left, top: 0, right: left + width, bottom: 20, width, height: 20, x: left, y: 0, toJSON() {} }) as DOMRect; + list.getBoundingClientRect = () => rect(0, 400); + for (const tab of document.querySelectorAll('[data-cl-slot="tabs-tab"]')) { + (tab as HTMLElement).getBoundingClientRect = () => rect(0, 250); + } + + // Fire every observer the tree created. + act(() => { + for (const o of observers) { + o.cb([], o as unknown as ResizeObserver); + } + }); + + expect(indicator.style.getPropertyValue('--cl-tab-width')).toBe('250px'); + } finally { + vi.unstubAllGlobals(); + } + }); + }); + + describe('panel visibility', () => { + it('hides non-selected panels with hidden attribute', () => { + renderTabs(); + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + const visible = Array.from(panels).filter(p => !p.hasAttribute('hidden')); + const hidden = Array.from(panels).filter(p => p.hasAttribute('hidden')); + + expect(visible).toHaveLength(1); + expect(hidden).toHaveLength(2); + }); + + it('non-selected panels have data-cl-hidden', () => { + renderTabs(); + const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"][data-cl-hidden]'); + expect(panels).toHaveLength(2); + }); + }); + + describe('roving tabindex', () => { + it('selected tab has tabIndex=0, others have tabIndex=-1', () => { + renderTabs(); + expect(screen.getByText('Account')).toHaveAttribute('tabindex', '0'); + expect(screen.getByText('Settings')).toHaveAttribute('tabindex', '-1'); + expect(screen.getByText('Billing')).toHaveAttribute('tabindex', '-1'); + }); + + it('tabIndex updates when selection changes', async () => { + const user = userEvent.setup(); + renderTabs(); + + await user.click(screen.getByText('Settings')); + + expect(screen.getByText('Account')).toHaveAttribute('tabindex', '-1'); + expect(screen.getByText('Settings')).toHaveAttribute('tabindex', '0'); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations', async () => { + const { container } = renderTabs(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations with vertical orientation', async () => { + const { container } = renderTabs({ orientation: 'vertical' }); + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + describe('consumer ref forwarding', () => { + it('forwards a consumer ref on Tab (CompositeItem shape)', () => { + const ref = createRef(); + render( + + + + One + + + content + , + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + expect(ref.current).toHaveAttribute('data-cl-slot', 'tabs-tab'); + }); + }); + + describe('inert state', () => { + it('marks deselected panels inert and leaves the selected panel interactive', () => { + renderTabs({ defaultValue: 'tab1' }); + + const selected = screen.getByText('Account content'); + const deselected = screen.getByText('Settings content'); + + expect(selected).not.toHaveAttribute('inert'); + expect(deselected).toHaveAttribute('inert'); + }); + }); + + describe('transition direction', () => { + function renderForceMounted() { + return render( + + + One + Two + + + One content + + + Two content + + , + ); + } + + it('does not flip direction backward when re-selecting the active tab', async () => { + const user = userEvent.setup(); + renderForceMounted(); + + const panel = screen.getByText('Two content'); + + // Moving forward to tab2 sets direction = 1. + await user.click(screen.getByRole('tab', { name: 'Two' })); + expect(panel.style.getPropertyValue('--cl-tab-transition-direction')).toBe('1'); + + // Re-selecting the already-active tab must not flip direction to -1. + await user.click(screen.getByRole('tab', { name: 'Two' })); + expect(panel.style.getPropertyValue('--cl-tab-transition-direction')).toBe('1'); + }); + }); +}); diff --git a/packages/headless/src/primitives/tooltip/README.md b/packages/headless/src/primitives/tooltip/README.md new file mode 100644 index 00000000000..73567689805 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/README.md @@ -0,0 +1,116 @@ +# Tooltip + +A floating label that appears on hover or focus. Non-interactive, used for supplementary descriptions. No focus trapping — tooltips never receive focus. + +## When to Use + +- Describing icon buttons, truncated text, or any element that benefits from a short label. +- When the content is display-only (no interactive elements inside). +- Prefer Tooltip over Popover when the content is a simple text label that should appear on hover/focus and disappear immediately. + +## Usage + +```tsx +import { Tooltip } from '@/primitives/tooltip'; + + + + + + + + Settings + + + +; +``` + +### Controlled + +```tsx +const [open, setOpen] = useState(false); + + + {/* ... */} +; +``` + +### Custom Delay + +```tsx + + {/* Opens after 500ms hover, closes 100ms after leaving */} + +``` + +## Parts + +| Part | Default Element | Description | +| -------------------- | --------------- | ------------------------------------------------------------------------ | +| `Tooltip.Root` | — | Root context provider | +| `Tooltip.Group` | — | Shares an open/close delay across grouped tooltips for instant switching | +| `Tooltip.Trigger` | `` | Element that triggers the tooltip on hover/focus | +| `Tooltip.Portal` | — | Portals children (accepts `root` prop) | +| `Tooltip.Positioner` | `` | Floating positioned container | +| `Tooltip.Popup` | `` | The visible tooltip content | +| `Tooltip.Arrow` | `` | Optional arrow pointing at the trigger | + +## Props + +### `Tooltip.Root` + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ------- | ------------------------------------ | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | +| `placement` | `Placement` | `"top"` | Floating UI placement | +| `sideOffset` | `number` | `4` | Gap between trigger and tooltip (px) | +| `delay` | `number` | `200` | Hover open delay (ms) | +| `closeDelay` | `number` | `0` | Hover close delay (ms) | + +### `Tooltip.Trigger`, `Tooltip.Positioner`, `Tooltip.Popup` + +No additional props beyond standard HTML attributes and the `render` prop. + +### `Tooltip.Arrow` + +Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically. + +## Open/Close Behavior + +- **Hover**: Opens after `delay` ms, closes after `closeDelay` ms. +- **Focus**: Opens on keyboard focus (`:focus-visible`), closes on blur. +- **Dismiss**: Closes on Escape key. +- **No click handling** — tooltips are triggered by hover and focus only. + +## Data Attributes + +| Attribute | Applies To | Description | +| --------------------------------- | ----------------- | ---------------------------------------- | +| `data-cl-slot` | All parts | Part identifier (e.g. `"tooltip-popup"`) | +| `data-cl-open` / `data-cl-closed` | Trigger | Open state | +| `data-cl-side` | Positioner, Arrow | Resolved placement side | + +## Positioning + +Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. Repositions automatically on scroll/resize via `autoUpdate`. + +## Important Notes + +- **No `FloatingFocusManager`** — tooltips do not receive or trap focus. This is correct per ARIA guidelines. +- **Nested tooltips are supported** via `FloatingTree`. +- **`Tooltip.Trigger` wraps its child** — if your trigger is already a button, the `render` prop can forward props to it instead of wrapping. +- **For tooltip clusters** (e.g. toolbar buttons), wrap them in `Tooltip.Group` to share an open/close delay so moving between triggers switches tooltips instantly. Accepts `delay` (`number | { open?, close? }`) and `timeoutMs` props. + +## ARIA + +- Popup: `role="tooltip"` +- Trigger: `aria-describedby` (pointing to the tooltip) diff --git a/packages/headless/src/primitives/tooltip/index.ts b/packages/headless/src/primitives/tooltip/index.ts new file mode 100644 index 00000000000..3cfd72b6a84 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/index.ts @@ -0,0 +1,11 @@ +export * as Tooltip from './parts'; + +export type { + TooltipArrowProps, + TooltipGroupProps, + TooltipPopupProps, + TooltipPortalProps, + TooltipPositionerProps, + TooltipProps, + TooltipTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/tooltip/parts.ts b/packages/headless/src/primitives/tooltip/parts.ts new file mode 100644 index 00000000000..29cab6119b3 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/parts.ts @@ -0,0 +1,7 @@ +export { type TooltipProps, TooltipRoot as Root } from './tooltip-root'; +export { type TooltipTriggerProps, TooltipTrigger as Trigger } from './tooltip-trigger'; +export { type TooltipPortalProps, TooltipPortal as Portal } from './tooltip-portal'; +export { type TooltipPositionerProps, TooltipPositioner as Positioner } from './tooltip-positioner'; +export { type TooltipPopupProps, TooltipPopup as Popup } from './tooltip-popup'; +export { type TooltipArrowProps, TooltipArrow as Arrow } from './tooltip-arrow'; +export { type TooltipGroupProps, TooltipGroup as Group } from './tooltip-group'; diff --git a/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx b/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx new file mode 100644 index 00000000000..5b9662f412a --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FloatingArrow, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { useTooltipContext } from './tooltip-context'; + +export type TooltipArrowProps = Omit, 'context'>; + +export const TooltipArrow = React.forwardRef(function TooltipArrow(props, ref) { + const { floatingContext, arrowRef, placement } = useTooltipContext(); + // Merge the consumer ref with the primitive-owned arrowRef so passing a ref + // does not clobber the ref FloatingArrow relies on for positioning. + const combinedRef = useMergeRefs([arrowRef, ref]); + const side = placement.split('-')[0]; + + return ( + + ); +}); diff --git a/packages/headless/src/primitives/tooltip/tooltip-context.ts b/packages/headless/src/primitives/tooltip/tooltip-context.ts new file mode 100644 index 00000000000..85838af9882 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-context.ts @@ -0,0 +1,34 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface TooltipContextValue { + open: boolean; + floatingContext: FloatingContext; + refs: ExtendedRefs; + floatingStyles: CSSProperties; + placement: Placement; + getReferenceProps: UseInteractionsReturn['getReferenceProps']; + getFloatingProps: UseInteractionsReturn['getFloatingProps']; + popupRef: React.RefObject; + arrowRef: React.MutableRefObject; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const TooltipContext = createContext(null); + +export function useTooltipContext() { + const ctx = useContext(TooltipContext); + if (!ctx) { + throw new Error('Tooltip compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-group.tsx b/packages/headless/src/primitives/tooltip/tooltip-group.tsx new file mode 100644 index 00000000000..21b8535083b --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-group.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { FloatingDelayGroup } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +export interface TooltipGroupProps { + /** Shared delay config for grouped tooltips. Default: { open: 200, close: 100 } */ + delay?: number | { open?: number; close?: number }; + /** Time in ms before the group resets to non-instant phase. Default: 300 */ + timeoutMs?: number; + children: ReactNode; +} + +export function TooltipGroup(props: TooltipGroupProps) { + const { delay = { open: 200, close: 100 }, timeoutMs = 300, children } = props; + return ( + + {children} + + ); +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-popup.tsx b/packages/headless/src/primitives/tooltip/tooltip-popup.tsx new file mode 100644 index 00000000000..8ba242a0c53 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-popup.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTooltipContext } from './tooltip-context'; + +export type TooltipPopupProps = ComponentProps<'div'>; + +export const TooltipPopup = React.forwardRef(function TooltipPopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useTooltipContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'tooltip-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/tooltip/tooltip-portal.tsx b/packages/headless/src/primitives/tooltip/tooltip-portal.tsx new file mode 100644 index 00000000000..07a6d8f3055 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode, RefObject } from 'react'; + +import { useTooltipContext } from './tooltip-context'; + +export interface TooltipPortalProps { + children: ReactNode; + root?: HTMLElement | null | RefObject; +} + +export function TooltipPortal(props: TooltipPortalProps) { + const { mounted } = useTooltipContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx new file mode 100644 index 00000000000..2724158a456 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTooltipContext } from './tooltip-context'; + +export type TooltipPositionerProps = ComponentProps<'div'>; + +export const TooltipPositioner = React.forwardRef( + function TooltipPositioner(props, ref) { + const { render, ...otherProps } = props; + const { mounted, refs, floatingStyles, placement, getFloatingProps } = useTooltipContext(); + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const side = placement.split('-')[0]; + const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>; + const wiredId = floatingProps.id; + + const defaultProps = { + 'data-cl-slot': 'tooltip-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by floating-ui: it pairs with the trigger's aria-describedby. + // A consumer-supplied id must not override it, or the aria pairing would silently break. + merged.id = wiredId; + + return renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: merged, + }); + }, +); diff --git a/packages/headless/src/primitives/tooltip/tooltip-root.tsx b/packages/headless/src/primitives/tooltip/tooltip-root.tsx new file mode 100644 index 00000000000..75d9f356a5c --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-root.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + shift, + useDelayGroup, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { type ReactNode, useMemo, useRef } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { TooltipContext, type TooltipContextValue } from './tooltip-context'; + +export interface TooltipProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + /** Delay in ms before the tooltip opens on hover. Default: 200 */ + delay?: number; + /** Delay in ms before the tooltip closes on hover out. Default: 0 */ + closeDelay?: number; + children: ReactNode; +} + +function TooltipInner(props: TooltipProps) { + const nodeId = useFloatingNodeId(); + + const { placement: placementProp = 'top', sideOffset = 4, delay = 200, closeDelay = 0, children } = props; + + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + + const arrowRef = useRef(null); + const popupRef = useRef(null); + + const { + refs, + floatingStyles, + context: floatingContext, + placement, + } = useFloating({ + nodeId, + open, + onOpenChange: setOpen, + placement: placementProp, + middleware: [ + offset(sideOffset), + flip({ + crossAxis: placementProp.includes('-'), + fallbackAxisSideDirection: 'start', + padding: 5, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + cssVars({ sideOffset }), + ], + whileElementsMounted: autoUpdate, + }); + + const { mounted, transitionProps } = useTransition({ + open, + ref: popupRef, + }); + + useDelayGroup(floatingContext, { id: nodeId }); + + const hover = useHover(floatingContext, { + move: false, + delay: { open: delay, close: closeDelay }, + }); + const focus = useFocus(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext, { role: 'tooltip' }); + + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + + const contextValue = useMemo( + () => ({ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + popupRef, + arrowRef, + mounted, + transitionProps, + }), + [ + open, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + mounted, + transitionProps, + ], + ); + + return ( + + {children} + + ); +} + +export function TooltipRoot(props: TooltipProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx new file mode 100644 index 00000000000..1f54df30f71 --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useTooltipContext } from './tooltip-context'; + +export type TooltipTriggerProps = ComponentProps<'button'>; + +export const TooltipTrigger = React.forwardRef( + function TooltipTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, refs, getReferenceProps } = useTooltipContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + type: 'button' as const, + 'data-cl-slot': 'tooltip-trigger', + ref: combinedRef, + ...(getReferenceProps() as React.ComponentPropsWithRef<'button'>), + }; + + return renderElement({ + defaultTagName: 'button', + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/tooltip/tooltip.test.tsx b/packages/headless/src/primitives/tooltip/tooltip.test.tsx new file mode 100644 index 00000000000..0a6fd637ffa --- /dev/null +++ b/packages/headless/src/primitives/tooltip/tooltip.test.tsx @@ -0,0 +1,353 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { axe } from '../../test-utils/axe'; +import { Tooltip } from './index'; + +afterEach(() => cleanup()); + +function renderTooltip(props: Partial> = {}) { + return render( + + Hover me + + Tooltip content + + , + ); +} + +describe('Tooltip', () => { + describe('slot attributes', () => { + it('renders trigger with data-cl-slot', () => { + renderTooltip(); + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-slot', 'tooltip-trigger'); + }); + + it('renders all parts with correct slot attributes when open', () => { + renderTooltip({ defaultOpen: true }); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + }); + + describe('open/close', () => { + it('opens on hover', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + + it('closes on unhover', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + + await user.unhover(trigger); + + const triggerEl = screen.getByRole('button', { name: 'Hover me' }); + expect(triggerEl).toHaveAttribute('data-cl-closed', ''); + }); + + it('opens on focus', async () => { + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await act(() => { + trigger.focus(); + }); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + renderTooltip({ defaultOpen: true }); + + await user.keyboard('{Escape}'); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + + it('calls onOpenChange when toggled', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderTooltip({ onOpenChange }); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + describe('controlled open', () => { + it('respects controlled open prop', () => { + renderTooltip({ open: true }); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); + }); + + it('does not open when controlled open is false', async () => { + const user = userEvent.setup(); + renderTooltip({ open: false }); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + }); + }); + + describe('ARIA attributes', () => { + it('trigger has tooltip role association', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + // aria-describedby must reference the tooltip positioner's id + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toBeInTheDocument(); + const positionerId = positioner!.getAttribute('id'); + expect(positionerId).toBeTruthy(); + expect(trigger.getAttribute('aria-describedby')).toBe(positionerId); + }); + + it('tooltip content has role=tooltip', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('focus stays on trigger when tooltip is open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + // Tooltip must not steal focus — active element should not be inside the tooltip + const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); + expect(popup).not.toContainElement(document.activeElement as HTMLElement); + }); + + it('keeps trigger/positioner aria-describedby intact when a custom id is passed to the positioner', async () => { + const user = userEvent.setup(); + render( + + Hover me + + Tooltip content + + , + ); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toBeInTheDocument(); + // The wired id is owned by floating-ui: a consumer-supplied id must not + // silently break the aria-describedby pairing between trigger and positioner. + const positionerId = positioner!.getAttribute('id'); + expect(positionerId).not.toBe('consumer-custom-id'); + expect(trigger.getAttribute('aria-describedby')).toBe(positionerId); + }); + }); + + describe('animation lifecycle', () => { + it('positioner is not rendered when closed', () => { + renderTooltip(); + expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + }); + + it('applies data-cl-open on popup when open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); + expect(popup).toHaveAttribute('data-cl-open', ''); + }); + + it('positioner has data-cl-side', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side'); + }); + }); + + describe('placement', () => { + it('accepts custom placement', () => { + renderTooltip({ defaultOpen: true, placement: 'bottom-end' }); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + }); + + it('defaults to top placement', () => { + renderTooltip({ defaultOpen: true }); + + const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-cl-side', 'top'); + }); + }); + + describe('trigger state attributes', () => { + it('trigger has data-cl-open when tooltip is visible', async () => { + const user = userEvent.setup(); + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + await user.hover(trigger); + + expect(trigger).toHaveAttribute('data-cl-open', ''); + }); + + it('trigger has data-cl-closed when tooltip is hidden', () => { + renderTooltip(); + + const trigger = screen.getByRole('button', { name: 'Hover me' }); + expect(trigger).toHaveAttribute('data-cl-closed', ''); + }); + }); + + describe('content rendering', () => { + it('renders children content when open', async () => { + const user = userEvent.setup(); + renderTooltip(); + + await user.hover(screen.getByRole('button', { name: 'Hover me' })); + + expect(screen.getByText('Tooltip content')).toBeInTheDocument(); + }); + + it('does not render content when closed', () => { + renderTooltip(); + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument(); + }); + }); + + describe('accessibility (axe)', () => { + it('has no violations when closed', async () => { + const { container } = renderTooltip(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('has no violations when open', async () => { + renderTooltip({ defaultOpen: true }); + expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); + }); + }); + + describe('Tooltip.Group', () => { + function renderTooltipGroup() { + return render( + + + Button A + + Tooltip A + + + + Button B + + Tooltip B + + + , + ); + } + + it('renders grouped tooltips', () => { + renderTooltipGroup(); + + expect(screen.getByRole('button', { name: 'Button A' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Button B' })).toBeInTheDocument(); + }); + + it('opens tooltip within a group on hover', async () => { + const user = userEvent.setup(); + renderTooltipGroup(); + + const triggerA = screen.getByRole('button', { name: 'Button A' }); + await user.hover(triggerA); + + await waitFor(() => { + expect(screen.getByText('Tooltip A')).toBeInTheDocument(); + }); + }); + + it('switches between grouped tooltips without full delay', async () => { + const user = userEvent.setup(); + renderTooltipGroup(); + + const triggerA = screen.getByRole('button', { name: 'Button A' }); + const triggerB = screen.getByRole('button', { name: 'Button B' }); + + // Open first tooltip (wait for it to appear) + await user.hover(triggerA); + await waitFor(() => { + expect(screen.getByText('Tooltip A')).toBeInTheDocument(); + }); + + // Move to second tooltip — group instant phase should show it quickly + await user.unhover(triggerA); + await user.hover(triggerB); + + await waitFor(() => { + expect(screen.getByText('Tooltip B')).toBeInTheDocument(); + }); + + // First tooltip should no longer be visible + expect(screen.queryByText('Tooltip A')).not.toBeInTheDocument(); + }); + }); + + describe('Arrow ref', () => { + it('merges a consumer ref with the internal arrow ref', () => { + const ref = createRef(); + render( + + Hover me + + + Tooltip content + + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toHaveAttribute('data-cl-slot', 'tooltip-arrow'); + }); + }); +}); diff --git a/packages/headless/src/test-utils/jest-dom.d.ts b/packages/headless/src/test-utils/jest-dom.d.ts new file mode 100644 index 00000000000..69f12cf866b --- /dev/null +++ b/packages/headless/src/test-utils/jest-dom.d.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom/vitest'; +import './vitest-axe'; diff --git a/packages/headless/src/test-utils/vitest-axe.d.ts b/packages/headless/src/test-utils/vitest-axe.d.ts new file mode 100644 index 00000000000..6c19867bb5a --- /dev/null +++ b/packages/headless/src/test-utils/vitest-axe.d.ts @@ -0,0 +1,10 @@ +import 'vitest'; + +import type { AxeMatchers } from 'vitest-axe/matchers'; + +declare module 'vitest' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Assertion<_T = unknown> extends AxeMatchers {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface AsymmetricMatchersContaining extends AxeMatchers {} +} diff --git a/packages/headless/src/utils/css-vars.test.ts b/packages/headless/src/utils/css-vars.test.ts new file mode 100644 index 00000000000..4d29ee60643 --- /dev/null +++ b/packages/headless/src/utils/css-vars.test.ts @@ -0,0 +1,269 @@ +import type { MiddlewareState } from '@floating-ui/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { cssVars } from './css-vars'; + +// Build a minimal MiddlewareState mock for testing the middleware fn +function createMockState( + overrides: { + placement?: string; + referenceWidth?: number; + referenceHeight?: number; + floatingWidth?: number; + floatingHeight?: number; + arrowX?: number; + arrowY?: number; + arrowElWidth?: number; + arrowElHeight?: number; + overflow?: { top: number; right: number; bottom: number; left: number }; + } = {}, +): MiddlewareState { + const { + placement = 'bottom', + referenceWidth = 100, + referenceHeight = 40, + floatingWidth = 200, + floatingHeight = 150, + arrowX, + arrowY, + arrowElWidth = 0, + arrowElHeight = 0, + } = overrides; + + const style = { + setProperty: vi.fn(), + }; + + // Mock arrow element inside floating + const arrowEl = arrowElWidth || arrowElHeight ? { clientWidth: arrowElWidth, clientHeight: arrowElHeight } : null; + + const floating = { + style, + querySelector: vi.fn(() => arrowEl), + } as unknown as HTMLElement; + + return { + placement, + elements: { + floating, + reference: document.createElement('div'), + }, + rects: { + reference: { width: referenceWidth, height: referenceHeight, x: 0, y: 0 }, + floating: { width: floatingWidth, height: floatingHeight, x: 0, y: 0 }, + }, + middlewareData: { + arrow: arrowX != null || arrowY != null ? { x: arrowX ?? 0, y: arrowY ?? 0, centerOffset: 0 } : {}, + }, + platform: { + getElementRects: vi.fn(), + getDimensions: vi.fn(), + getClippingRect: vi.fn(async () => ({ + width: 1024, + height: 768, + x: 0, + y: 0, + })), + convertOffsetParentRelativeRectToViewportRelativeRect: vi.fn(async ({ rect }: { rect: unknown }) => rect), + }, + x: 0, + y: 0, + initialPlacement: placement, + strategy: 'absolute', + } as unknown as MiddlewareState; +} + +// Helper to extract setProperty calls into a map +function getVars(state: MiddlewareState): Map { + const style = state.elements.floating.style; + const calls = (style.setProperty as ReturnType).mock.calls as [string, string][]; + return new Map(calls.map(([name, value]) => [name, value])); +} + +describe('cssVars middleware', () => { + it("has name 'cssVars'", () => { + const mw = cssVars(); + expect(mw.name).toBe('cssVars'); + }); + + describe('--cl-anchor-width and --cl-anchor-height', () => { + it('sets anchor dimensions from reference rects', async () => { + const mw = cssVars(); + const state = createMockState({ + referenceWidth: 120, + referenceHeight: 36, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-width')).toBe('120px'); + expect(vars.get('--cl-anchor-height')).toBe('36px'); + }); + + it('handles zero-size reference', async () => { + const mw = cssVars(); + const state = createMockState({ + referenceWidth: 0, + referenceHeight: 0, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-anchor-width')).toBe('0px'); + expect(vars.get('--cl-anchor-height')).toBe('0px'); + }); + }); + + describe('--cl-available-width and --cl-available-height', () => { + it('sets available dimensions as CSS vars', async () => { + const mw = cssVars(); + const state = createMockState({ + floatingWidth: 200, + floatingHeight: 150, + }); + await mw.fn(state); + + const vars = getVars(state); + // Values are set (exact numbers depend on detectOverflow's padding) + expect(vars.has('--cl-available-width')).toBe(true); + expect(vars.has('--cl-available-height')).toBe(true); + expect(vars.get('--cl-available-width')).toMatch(/^\d+px$/); + expect(vars.get('--cl-available-height')).toMatch(/^\d+px$/); + }); + }); + + describe('--cl-transform-origin', () => { + it('centers on anchor when no arrow (bottom)', async () => { + const mw = cssVars({ sideOffset: 8 }); + // Reference: x=0, width=100 → center at 50. Floating: x=0. + // transformX = 0 + 100/2 - 0 = 50 + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px -8px'); + }); + + it('centers on anchor when no arrow (top)', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'top', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px calc(100% + 4px)'); + }); + + it('centers on anchor when no arrow (left)', async () => { + const mw = cssVars({ sideOffset: 6 }); + const state = createMockState({ + placement: 'left', + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformY = 0 + 40/2 - 0 = 20 + expect(vars.get('--cl-transform-origin')).toBe('calc(100% + 6px) 20px'); + }); + + it('centers on anchor when no arrow (right)', async () => { + const mw = cssVars({ sideOffset: 6 }); + const state = createMockState({ + placement: 'right', + referenceHeight: 40, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('-6px 20px'); + }); + + it('handles alignment variants (e.g. bottom-start)', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom-start', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + // Side is still "bottom", centers on anchor + expect(vars.get('--cl-transform-origin')).toBe('50px -4px'); + }); + + it('uses arrow position when arrow is present', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'bottom', + arrowX: 50, + arrowElWidth: 12, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformX = arrowX + arrowWidth/2 = 50 + 6 = 56 + expect(vars.get('--cl-transform-origin')).toBe('56px -4px'); + }); + + it('uses arrow Y position on left/right placement', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ + placement: 'right', + arrowY: 30, + arrowElHeight: 10, + }); + await mw.fn(state); + + const vars = getVars(state); + // transformY = arrowY + arrowHeight/2 = 30 + 5 = 35 + expect(vars.get('--cl-transform-origin')).toBe('-4px 35px'); + }); + + it('defaults sideOffset to 0 when not provided', async () => { + const mw = cssVars(); + const state = createMockState({ + placement: 'bottom', + referenceWidth: 100, + }); + await mw.fn(state); + + const vars = getVars(state); + expect(vars.get('--cl-transform-origin')).toBe('50px 0px'); + }); + }); + + describe('return value', () => { + it('returns empty object (no position changes)', async () => { + const mw = cssVars(); + const state = createMockState(); + const result = await mw.fn(state); + expect(result).toEqual({}); + }); + }); + + describe('all five CSS vars are set', () => { + it('sets exactly 5 CSS custom properties', async () => { + const mw = cssVars({ sideOffset: 4 }); + const state = createMockState({ placement: 'bottom' }); + await mw.fn(state); + + const style = state.elements.floating.style; + const calls = (style.setProperty as ReturnType).mock.calls as [string, string][]; + const varNames = calls.map(([name]) => name); + + expect(varNames).toEqual([ + '--cl-anchor-width', + '--cl-anchor-height', + '--cl-available-width', + '--cl-available-height', + '--cl-transform-origin', + ]); + }); + }); +}); diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts new file mode 100644 index 00000000000..67c973bbfd2 --- /dev/null +++ b/packages/headless/src/utils/css-vars.ts @@ -0,0 +1,76 @@ +import { detectOverflow, type Middleware } from '@floating-ui/react'; + +/** + * Positioning middleware that sets CSS custom properties on the floating element: + * + * - `--cl-anchor-width` – reference element width (px) + * - `--cl-anchor-height` – reference element height (px) + * - `--cl-available-width` – available width between anchor and viewport edge (px) + * - `--cl-available-height` – available height between anchor and viewport edge (px) + * - `--cl-transform-origin` – CSS transform-origin pointing back toward the anchor + * + * Place **after** `arrow()` so arrow position data is available for transform-origin. + */ +export function cssVars(opts?: { sideOffset?: number }): Middleware { + return { + name: 'cssVars', + async fn(state) { + const { elements, rects, middlewareData, placement } = state; + const style = elements.floating.style; + const sideOffset = opts?.sideOffset ?? 0; + + // Anchor dimensions + style.setProperty('--cl-anchor-width', `${rects.reference.width}px`); + style.setProperty('--cl-anchor-height', `${rects.reference.height}px`); + + // Available space + const overflow = await detectOverflow(state, { padding: 5 }); + const side = placement.split('-')[0] as 'top' | 'bottom' | 'left' | 'right'; + + const availableHeight = + side === 'top' + ? rects.floating.height - overflow.top + : side === 'bottom' + ? rects.floating.height - overflow.bottom + : rects.floating.height - Math.max(overflow.top, 0) - Math.max(overflow.bottom, 0); + + const availableWidth = + side === 'left' + ? rects.floating.width - overflow.left + : side === 'right' + ? rects.floating.width - overflow.right + : rects.floating.width - Math.max(overflow.left, 0) - Math.max(overflow.right, 0); + + style.setProperty('--cl-available-width', `${availableWidth}px`); + style.setProperty('--cl-available-height', `${availableHeight}px`); + + // Transform origin — points back toward the anchor + const arrowEl = elements.floating.querySelector("[data-cl-slot$='-arrow']"); + + let transformX: number; + let transformY: number; + + if (arrowEl) { + const arrowX = middlewareData.arrow?.x ?? 0; + const arrowY = middlewareData.arrow?.y ?? 0; + transformX = arrowX + arrowEl.clientWidth / 2; + transformY = arrowY + arrowEl.clientHeight / 2; + } else { + // No arrow — use the anchor's center relative to the floating element + transformX = rects.reference.x + rects.reference.width / 2 - state.x; + transformY = rects.reference.y + rects.reference.height / 2 - state.y; + } + + const originMap: Record = { + top: `${transformX}px calc(100% + ${sideOffset}px)`, + bottom: `${transformX}px ${-sideOffset}px`, + left: `calc(100% + ${sideOffset}px) ${transformY}px`, + right: `${-sideOffset}px ${transformY}px`, + }; + + style.setProperty('--cl-transform-origin', originMap[side]); + + return {}; + }, + }; +} diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index 53fd01599c8..ebfd4caefaa 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -1 +1,2 @@ +export { cssVars } from './css-vars'; export { type ComponentProps, mergeProps, type RenderProp, renderElement } from './render-element'; diff --git a/packages/headless/tsconfig.json b/packages/headless/tsconfig.json index c4540461007..2a7544581d4 100644 --- a/packages/headless/tsconfig.json +++ b/packages/headless/tsconfig.json @@ -10,7 +10,7 @@ "moduleResolution": "bundler", "moduleDetection": "force", "module": "preserve", - "lib": ["ES2023", "DOM"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "isolatedModules": true, "forceConsistentCasingInFileNames": true, @@ -19,5 +19,5 @@ "noEmit": true }, "include": ["src"], - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] + "exclude": ["node_modules"] } diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 99ff3925b72..8e7f6f2f831 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -1,3 +1,4 @@ +import preserveDirectives from 'rollup-plugin-preserve-directives'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; @@ -12,6 +13,13 @@ export default defineConfig({ lib: { entry: { 'primitives/accordion/index': 'src/primitives/accordion/index.ts', + 'primitives/tabs/index': 'src/primitives/tabs/index.ts', + 'primitives/tooltip/index': 'src/primitives/tooltip/index.ts', + 'primitives/popover/index': 'src/primitives/popover/index.ts', + 'primitives/select/index': 'src/primitives/select/index.ts', + 'primitives/menu/index': 'src/primitives/menu/index.ts', + 'primitives/autocomplete/index': 'src/primitives/autocomplete/index.ts', + 'primitives/collapsible/index': 'src/primitives/collapsible/index.ts', 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'utils/index': 'src/utils/index.ts', 'hooks/index': 'src/hooks/index.ts', @@ -24,10 +32,23 @@ export default defineConfig({ }, rollupOptions: { external: ['react', 'react-dom', 'react/jsx-runtime', '@floating-ui/react'], + // Preserve module-level directives such as `'use client'`. Rollup otherwise + // strips them when bundling (emitting a warning), which would drop the + // client boundary for React Server Component consumers of the primitives. + plugins: [preserveDirectives()], output: { preserveModules: true, preserveModulesRoot: 'src', }, + // Rollup still warns that it ignored the directives during bundling even + // though preserveDirectives re-attaches them to the output chunks. Silence + // the now-expected noise (recommended by the plugin). + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return; + } + warn(warning); + }, }, sourcemap: true, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c8386e7ee7..0605508b533 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -675,6 +675,9 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + rollup-plugin-preserve-directives: + specifier: ^0.4.0 + version: 0.4.0(rollup@4.60.2) typescript: specifier: catalog:repo version: 5.8.3 @@ -12705,6 +12708,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-preserve-directives@0.4.0: + resolution: {integrity: sha512-gx4nBxYm5BysmEQS+e2tAMrtFxrGvk+Pe5ppafRibQi0zlW7VYAbEGk6IKDw9sJGPdFWgVTE0o4BU4cdG0Fylg==} + peerDependencies: + rollup: 2.x || 3.x || 4.x + rollup-plugin-visualizer@7.0.1: resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} engines: {node: '>=22'} @@ -29526,6 +29534,12 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + rollup-plugin-preserve-directives@0.4.0(rollup@4.60.2): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.2) + magic-string: 0.30.21 + rollup: 4.60.2 + rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-beta.47(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(rollup@4.60.2): dependencies: open: 11.0.0
Popover content