diff --git a/.changeset/mosaic-composite-navigation.md b/.changeset/mosaic-composite-navigation.md
new file mode 100644
index 00000000000..c134c18c7d1
--- /dev/null
+++ b/.changeset/mosaic-composite-navigation.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Fix keyboard navigation in tabs and accordions. The tab stop now lands on the selected tab even when it isn't the first, and arrow keys pressed in a field inside an accordion panel stay with that field. `ActionBar.Root` now calls `onFocus` and `onBlur` handlers passed by consumers.
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index dd34d96395c..226589e4770 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -59,6 +59,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| An avatar or logo file picker and its rejection errors | `AVATAR_UPLOAD`, `AVATAR_REJECTION_ERRORS` (`src/utils/avatar-upload.ts`) |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) |
+| Arrow-key navigation with one tab stop across a group of items | `useComposite`, with `useCompositeItems` and `useCompositeItem` (`src/primitives/hooks/`) |
| A panel that animates open and closed to its measured size | `usePanelSize` (`src/primitives/hooks/`) |
| An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) |
| Styles several components share | `src/styles/*.styles.ts` |
diff --git a/packages/mosaic/docs/headless.md b/packages/mosaic/docs/headless.md
index cbe49407a7a..683b4737068 100644
--- a/packages/mosaic/docs/headless.md
+++ b/packages/mosaic/docs/headless.md
@@ -2,8 +2,10 @@
`packages/mosaic/src/primitives/` is the unstyled, accessible primitive layer
inside Mosaic, one folder per primitive (Dialog, Menu, Select, …). Every part emits **zero
-styles** — positioning, keyboard nav, focus management, dismiss, and ARIA are
-delegated to `@floating-ui/react`; all appearance is applied externally via
+styles** — positioning, popup list navigation, focus management, dismiss, and ARIA are
+delegated to `@floating-ui/react`, and roving-tabindex groups use `useComposite`,
+with items registering through `useCompositeItem`
+(`primitives/hooks/use-composite.ts`); all appearance is applied externally via
`data-*` selectors and consumer classNames.
Internal to `@clerk/mosaic`: import by relative path; there is no subpath export.
@@ -143,7 +145,7 @@ or the transform origin won't follow the arrow.
Read the direction with `useDirection()` from `primitives/direction-provider`, never from the DOM
`dir` attribute or computed style. It returns `ltr` outside a `DirectionProvider`. Use
`readingDirectionKeys(direction)` for the previous and next arrow keys, or pass
-`rtl: direction === 'rtl'` to floating-ui's `Composite` or `useListNavigation`
+`rtl: direction === 'rtl'` to `useComposite` or floating-ui's `useListNavigation`
(`tabs/tabs-list.tsx`). `MosaicProvider` renders the `DirectionProvider` from its `direction` prop.
## Testing
diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx
index d85a0955249..89c4d412103 100644
--- a/packages/mosaic/src/components/action-bar/action-bar.test.tsx
+++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx
@@ -164,6 +164,32 @@ describe('Mosaic ActionBar', () => {
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
+ it('leaves a control that is not an action out of arrow key navigation', async () => {
+ function WithPlainControl() {
+ const anchor = useAnchor();
+ return (
+ <>
+
+
+ Change role
+ Plain
+ Remove
+
+ >
+ );
+ }
+ const user = userEvent.setup();
+ render( );
+ screen.getByRole('button', { name: 'Change role' }).focus();
+ await user.keyboard('{ArrowRight}');
+ expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus();
+ expect(screen.getByRole('button', { name: 'Plain' })).not.toHaveAttribute('tabindex');
+ });
+
it('moves between controls with the arrow keys, wrapping at the ends', async () => {
const user = userEvent.setup();
render( );
@@ -190,6 +216,14 @@ describe('Mosaic ActionBar', () => {
expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus();
});
+ it('leaves Home and End alone', async () => {
+ const user = userEvent.setup();
+ render( );
+ screen.getByRole('button', { name: 'Remove' }).focus();
+ await user.keyboard('{Home}{End}');
+ expect(screen.getByRole('button', { name: 'Remove' })).toHaveFocus();
+ });
+
it('leaves arrow keys inside a portalled menu to the menu', async () => {
const user = userEvent.setup();
function WithMenu() {
@@ -269,6 +303,38 @@ describe('Mosaic ActionBar', () => {
await userEvent.click(screen.getByRole('button', { name: 'Clear selection' }));
expect(onDismiss).toHaveBeenCalledOnce();
});
+ it('calls focus handlers passed to the root as focus enters and leaves the bar', async () => {
+ const onFocus = vi.fn();
+ const onBlur = vi.fn();
+ function Bar() {
+ const anchor = useAnchor();
+ return (
+ <>
+
+
+ Remove
+
+ After
+ >
+ );
+ }
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(onBlur).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole('button', { name: 'After' }));
+ expect(onBlur).toHaveBeenCalledTimes(1);
+ });
+
describe('with a menu', () => {
function ChangeRole() {
const [selected, setSelected] = React.useState(true);
diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx
index 2d97a731238..ab3ac3db0b7 100644
--- a/packages/mosaic/src/components/action-bar/action-bar.tsx
+++ b/packages/mosaic/src/components/action-bar/action-bar.tsx
@@ -1,7 +1,5 @@
import {
autoUpdate,
- Composite,
- CompositeItem,
FloatingFocusManager,
getOverflowAncestors,
hide,
@@ -16,14 +14,22 @@ import * as stylex from '@stylexjs/stylex';
import React from 'react';
import { X } from '../../icons/glyphs/x';
-import { useAnimationsFinished, useTransitionStatus } from '../../primitives/hooks';
+import {
+ CompositeItemsProvider,
+ useAnimationsFinished,
+ useComposite,
+ useCompositeItem,
+ useCompositeItems,
+ useTransitionStatus,
+} from '../../primitives/hooks';
import { Portal } from '../../primitives/portal';
-import { isElement, isRef, mergeProps, useRender } from '../../primitives/utils';
+import { isElement, mergeProps, useRender } from '../../primitives/utils';
import { getComputedStyle } from '../../primitives/utils/dom';
import type { MosaicComponentProps, XStyle } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
import { tabularNumbersStyle } from '../../styles/typography.styles';
+import { useHeldMessage } from '../../utils/feedback';
import type { ButtonProps } from '../button';
import { Button } from '../button';
import { Icon } from '../icon';
@@ -76,12 +82,16 @@ const Root = React.forwardRef(function Actio
ref,
) {
const barRef = React.useRef(null);
+ const [bar, setBar] = React.useState(null);
+ const items = useCompositeItems();
+ const composite = useComposite(bar, {
+ getItems: items.getItems,
+ orientation: 'horizontal',
+ homeEnd: false,
+ });
const countId = React.useId();
const context = React.useMemo(() => ({ countId }), [countId]);
- const openChildrenRef = React.useRef(children);
- if (open) {
- openChildrenRef.current = children;
- }
+ const heldChildren = useHeldMessage(children, open);
const wasOpenRef = React.useRef(false);
const [status, setStatus] = React.useState('');
@@ -89,6 +99,7 @@ const Root = React.forwardRef(function Actio
const wasOpen = wasOpenRef.current;
wasOpenRef.current = open;
if (!open) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- The live region must commit empty before its text arrives, or a bar mounted open is never announced.
setStatus('');
} else if (!wasOpen) {
setStatus(openAnnouncement ?? announcement ?? '');
@@ -194,53 +205,30 @@ const Root = React.forwardRef(function Actio
stylex.props(reset.base, styles.positioner, positionerXstyle),
)}
>
- ) => {
- const { ref: compositeRef, ...merged } = mergeProps<'div'>(
- {
- 'aria-describedby': countId,
- ...mergeStyleProps(
- themeProps('action-bar', { open }),
- stylex.props(reset.base, styles.bar, xstyle),
- rest,
- ),
- },
- compositeProps,
- );
- // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's Composite calls this render callback synchronously during its own render, so the hook keeps a stable position.
- return useRender({
- defaultTagName: 'div',
- render,
- ref: [barRef, isRef(compositeRef) ? compositeRef : undefined],
- props: {
- ...merged,
- ...(transitionStatus === 'starting'
- ? { 'data-starting-style': '', style: { transition: 'none' } }
- : null),
- ...(transitionStatus === 'ending' ? { 'data-ending-style': '' } : null),
- 'data-open': open,
- role: 'toolbar',
- onFocus: () => {
- focusWithinRef.current = true;
- },
- onBlur: () => {
- focusWithinRef.current = false;
- },
- children: (
-
- {open ? children : openChildrenRef.current}
-
- ),
- },
- });
+ {
+ focusWithinRef.current = within;
}}
- />
+ {...mergeStyleProps(
+ themeProps('action-bar', { open }),
+ stylex.props(reset.base, styles.bar, xstyle),
+ rest,
+ )}
+ aria-describedby={countId}
+ >
+
+ {heldChildren}
+
+
@@ -249,28 +237,50 @@ const Root = React.forwardRef(function Actio
);
});
+interface BarProps extends MosaicComponentProps<'div'> {
+ barRefs: React.Ref[];
+ onCompositeKeyDown: React.KeyboardEventHandler;
+ open: boolean;
+ transitionStatus: ReturnType['transitionStatus'];
+ onFocusWithinChange: (within: boolean) => void;
+}
+
+function Bar({ render, barRefs, onCompositeKeyDown, open, transitionStatus, onFocusWithinChange, ...props }: BarProps) {
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref: barRefs,
+ props: {
+ ...mergeProps<'div'>(props, {
+ onKeyDown: onCompositeKeyDown,
+ onFocus: () => onFocusWithinChange(true),
+ onBlur: () => onFocusWithinChange(false),
+ }),
+ ...(transitionStatus === 'starting' ? { 'data-starting-style': '', style: { transition: 'none' } } : null),
+ ...(transitionStatus === 'ending' ? { 'data-ending-style': '' } : null),
+ 'data-open': open,
+ role: 'toolbar',
+ 'aria-orientation': 'horizontal',
+ },
+ });
+}
+
export type ActionBarActionProps = ButtonProps;
const Action = React.forwardRef(function ActionBarAction(
{ color, xstyle, ...rest },
ref,
) {
+ const compositeItem = useCompositeItem();
+ const mergedRef = useMergeRefs([compositeItem, ref]);
return (
- ) => {
- const { ref: itemRef, ...merged } = mergeProps<'button'>(rest, itemProps);
- return (
- ([ref, isRef(itemRef) ? itemRef : undefined])}
- xstyle={[color === 'negative' && styles.destructive, xstyle]}
- />
- );
- }}
+
);
});
diff --git a/packages/mosaic/src/primitives/accordion/accordion-root.tsx b/packages/mosaic/src/primitives/accordion/accordion-root.tsx
index 2adddfc4c21..a486e420d2a 100644
--- a/packages/mosaic/src/primitives/accordion/accordion-root.tsx
+++ b/packages/mosaic/src/primitives/accordion/accordion-root.tsx
@@ -1,10 +1,10 @@
'use client';
-import { Composite } from '@floating-ui/react';
-import React, { type ReactNode, useCallback, useId, useMemo } from 'react';
+import React, { type ReactNode, useCallback, useId, useMemo, useState } from 'react';
+import { CompositeItemsProvider, useComposite, useCompositeItems } from '../hooks/use-composite';
import { useControllableState } from '../hooks/use-controllable-state';
-import { type ComponentProps, isRef, mergeProps, useRender } from '../utils';
+import { type ComponentProps, mergeProps, useRender } from '../utils';
import { AccordionContext, type AccordionContextValue } from './accordion-context';
export interface AccordionProps extends ComponentProps<'div'> {
@@ -47,46 +47,24 @@ export const AccordionRoot = React.forwardRef(fu
[value, toggle, disabled, accordionId],
);
- return (
-
- ) => {
- // aria-orientation is injected by Composite but is not valid on a
- // generic (no widget role). Strip it to avoid an axe violation.
- const { 'aria-orientation': _ao, ...restCompositeProps } = compositeProps;
-
- const defaultProps: Record
= {
- onKeyDown: (event: React.KeyboardEvent) => {
- if (event.key !== 'Home' && event.key !== 'End') {
- return;
- }
- event.preventDefault();
- const items = Array.from(
- event.currentTarget.querySelectorAll('[aria-expanded]:not([disabled])'),
- );
- (event.key === 'Home' ? items.at(0) : items.at(-1))?.focus();
- },
- };
+ const [element, setElement] = useState(null);
- const merged = mergeProps<'div'>(defaultProps, mergeProps<'div'>(otherProps, restCompositeProps));
+ const items = useCompositeItems();
+ const composite = useComposite(element, {
+ getItems: items.getItems,
+ orientation: 'vertical',
+ });
- // Composite may inject a ref via compositeProps; hand it to useRender's ref
- // param (which owns ref-merging) instead of leaving it in props, where
- // useRender's merged ref would overwrite it.
- const { ref: compositeRef, ...mergedProps } = merged;
-
- // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's Composite calls this render callback synchronously during its own render, so the hook keeps a stable position.
- return useRender({
- defaultTagName: 'div',
- render,
- ref: [isRef(compositeRef) ? compositeRef : null, ref],
- props: mergedProps,
- });
- }}
- >
- {children}
-
+ return (
+
+
+ {useRender({
+ defaultTagName: 'div',
+ render,
+ ref: [setElement, ref],
+ props: mergeProps<'div'>({ onKeyDown: composite.handleKeyDown, children }, otherProps),
+ })}
+
);
});
diff --git a/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx b/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx
index 7dddcf23a59..da00856d9c0 100644
--- a/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx
+++ b/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx
@@ -1,65 +1,52 @@
'use client';
-import { CompositeItem } from '@floating-ui/react';
import React from 'react';
-import { type ComponentProps, isRef, mergeProps, useRender } from '../utils';
+import { useCompositeItem } from '../hooks/use-composite';
+import { type ComponentProps, mergeProps, useRender } from '../utils';
import { useAccordionContext, useAccordionItemContext } from './accordion-context';
export type AccordionTriggerProps = ComponentProps<'button'>;
export const AccordionTrigger = React.forwardRef(
function AccordionTrigger(props, ref) {
- const { render, children, ...otherProps } = props;
+ const { render, ...otherProps } = props;
const ctx = useAccordionContext();
const { itemValue, open, disabled, triggerId, panelId } = useAccordionItemContext();
- const state = { open, disabled };
-
- return (
- ) => {
- const defaultProps: Record = {
- id: triggerId,
- type: 'button' as const,
- 'aria-expanded': open,
- 'aria-controls': panelId,
- 'aria-disabled': disabled || undefined,
- onClick: () => {
- if (!disabled) {
- ctx.toggle(itemValue);
- }
- },
- };
-
- const merged = mergeProps<'button'>(mergeProps<'button'>(defaultProps, otherProps), compositeProps);
+ const compositeItem = useCompositeItem();
- // 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;
-
- // CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
- // useRender's ref param (which owns ref-merging) instead of leaving it in props,
- // where useRender's merged ref would overwrite it and break focus navigation.
- const { ref: compositeRef, ...mergedProps } = merged;
+ const state = { open, disabled };
- // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's CompositeItem calls this render callback synchronously during its own render, so the hook keeps a stable position.
- return useRender({
- defaultTagName: 'button',
- render,
- ref: [isRef(compositeRef) ? compositeRef : null, ref],
- state,
- stateAttributesMapping: {
- open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
- disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
- },
- props: mergedProps,
- });
- }}
- >
- {children}
-
- );
+ const defaultProps: Record = {
+ type: 'button' as const,
+ disabled,
+ 'aria-expanded': open,
+ 'aria-controls': panelId,
+ 'aria-disabled': disabled || undefined,
+ onClick: () => {
+ if (!disabled) {
+ ctx.toggle(itemValue);
+ }
+ },
+ };
+
+ 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 useRender({
+ defaultTagName: 'button',
+ render,
+ ref: [compositeItem, ref],
+ state,
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
+ disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+ },
+ props: merged,
+ });
},
);
diff --git a/packages/mosaic/src/primitives/accordion/accordion.test.tsx b/packages/mosaic/src/primitives/accordion/accordion.test.tsx
index 76e2a94052b..c637b90e96e 100644
--- a/packages/mosaic/src/primitives/accordion/accordion.test.tsx
+++ b/packages/mosaic/src/primitives/accordion/accordion.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen } from '@testing-library/react';
+import { cleanup, fireEvent, 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';
@@ -296,6 +296,48 @@ describe('Accordion', () => {
});
describe('keyboard navigation', () => {
+ it('keeps a nested accordion separate from its parent', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ Outer 1
+
+
+
+
+
+ Inner 1
+
+
+
+
+ Inner 2
+
+
+
+
+
+
+
+ Outer 2
+
+
+ ,
+ );
+
+ screen.getByRole('button', { name: 'Outer 1' }).focus();
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('button', { name: 'Outer 2' })).toHaveFocus();
+
+ screen.getByRole('button', { name: 'Inner 1' }).focus();
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('button', { name: 'Inner 2' })).toHaveFocus();
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('button', { name: 'Inner 1' })).toHaveFocus();
+ });
+
it('moves focus down with ArrowDown', async () => {
const user = userEvent.setup();
renderAccordion();
@@ -474,4 +516,29 @@ describe('Accordion', () => {
expect(refs.header.current).toBe(screen.getByRole('heading'));
expect(refs.trigger.current).toBe(screen.getByRole('button', { name: 'Section 1' }));
});
+
+ it('leaves arrow keys inside a panel to the field that has focus', async () => {
+ render(
+
+
+
+ Section 1
+
+
+
+
+
+
+
+ Section 2
+
+ Content 2
+
+ ,
+ );
+ const notes = screen.getByRole('textbox', { name: 'Notes' });
+ await userEvent.type(notes, 'a{Enter}b');
+ expect(fireEvent.keyDown(notes, { key: 'ArrowUp' })).toBe(true);
+ expect(notes).toHaveFocus();
+ });
});
diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-composite.test.tsx b/packages/mosaic/src/primitives/hooks/__tests__/use-composite.test.tsx
new file mode 100644
index 00000000000..582993bb0ef
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/__tests__/use-composite.test.tsx
@@ -0,0 +1,598 @@
+import { act, cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React, { useState } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ CompositeItemsProvider,
+ type CompositeKeyEvent,
+ useComposite,
+ useCompositeItem,
+ useCompositeItems,
+ type UseCompositeOptions,
+} from '../use-composite';
+
+afterEach(() => cleanup());
+
+interface ListProps extends Partial {
+ items?: { name: string; disabled?: boolean; ariaDisabled?: boolean }[];
+ selected?: string;
+}
+
+const THREE = [{ name: 'One' }, { name: 'Two' }, { name: 'Three' }];
+
+function List({ items = THREE, selected, ...options }: ListProps) {
+ const [element, setElement] = useState(null);
+ const composite = useComposite(element, {
+ getItems: () => Array.from(element?.querySelectorAll('[data-item]') ?? []),
+ preferred: {
+ key: selected,
+ getItem: () => element?.querySelector(`[data-name="${selected}"]`) ?? undefined,
+ },
+ ...options,
+ });
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- Test host; the keys come from the focusable items inside it.
+
+ {items.map(item => (
+
+ {item.name}
+
+ ))}
+
+
+ );
+}
+
+const button = (name: string) => screen.getByRole('button', { name });
+
+function tabStops() {
+ return screen
+ .getAllByRole('button')
+ .filter(element => element.getAttribute('tabindex') === '0')
+ .map(element => element.textContent);
+}
+
+describe('useComposite', () => {
+ describe('tab stop', () => {
+ it('has one tab stop, on the first item by default', () => {
+ render(
);
+ expect(tabStops()).toEqual(['One']);
+ expect(button('Two')).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('starts on the preferred item', () => {
+ render(
);
+ expect(tabStops()).toEqual(['Two']);
+ });
+
+ it('skips a disabled first item', () => {
+ render(
);
+ expect(tabStops()).toEqual(['Two']);
+ });
+
+ it('does not rest on a preferred item that cannot take focus', () => {
+ render(
+
,
+ );
+ expect(tabStops()).toEqual(['Two']);
+ });
+
+ it('follows focus from a click', async () => {
+ render(
);
+ await userEvent.click(button('Three'));
+ expect(tabStops()).toEqual(['Three']);
+ });
+
+ it('follows a change of selection', () => {
+ const { rerender } = render(
);
+ rerender(
);
+ expect(tabStops()).toEqual(['Three']);
+ });
+ });
+
+ describe('keys', () => {
+ it('moves along the horizontal axis and ignores the other', async () => {
+ render(
);
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Two')).toHaveFocus();
+ await userEvent.keyboard('{ArrowDown}');
+ expect(button('Two')).toHaveFocus();
+ });
+
+ it('moves along the vertical axis and ignores the other', async () => {
+ render(
);
+ button('One').focus();
+ await userEvent.keyboard('{ArrowDown}');
+ expect(button('Two')).toHaveFocus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Two')).toHaveFocus();
+ });
+
+ it('flips horizontal arrows only in RTL', async () => {
+ render(
+
,
+ );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(button('Two')).toHaveFocus();
+ await userEvent.keyboard('{ArrowDown}');
+ expect(button('Three')).toHaveFocus();
+ });
+
+ it('loops by default and stops at the edges without loop', async () => {
+ const { unmount } = render(
);
+ button('One').focus();
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(button('Three')).toHaveFocus();
+ unmount();
+
+ render(
+
,
+ );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(button('One')).toHaveFocus();
+ });
+
+ it('goes to the first and last enabled items with Home and End', async () => {
+ render(
+
,
+ );
+ button('Two').focus();
+ await userEvent.keyboard('{End}');
+ expect(button('Three')).toHaveFocus();
+ await userEvent.keyboard('{Home}');
+ expect(button('Two')).toHaveFocus();
+ });
+
+ it('skips disabled items', async () => {
+ render(
+
,
+ );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Four')).toHaveFocus();
+ });
+
+ it('does nothing when every item is disabled', async () => {
+ const onNavigate = vi.fn();
+ render(
+
,
+ );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}{End}');
+ expect(button('One')).toHaveFocus();
+ expect(onNavigate).not.toHaveBeenCalled();
+ });
+
+ it.each(['{Control>}', '{Meta>}', '{Shift>}', '{Alt>}'])('still moves with %s held', async modifier => {
+ render(
);
+ button('One').focus();
+ await userEvent.keyboard(`${modifier}{ArrowRight}`);
+ expect(button('Two')).toHaveFocus();
+ });
+
+ it('can leave Home and End alone', async () => {
+ render(
+
,
+ );
+ button('Two').focus();
+ await userEvent.keyboard('{Home}{End}');
+ expect(button('Two')).toHaveFocus();
+ });
+
+ it('leaves keys from a nested field alone', async () => {
+ render(
);
+ const input = screen.getByRole('textbox');
+ await userEvent.type(input, 'abc');
+ await userEvent.keyboard('{ArrowLeft}{Home}');
+ expect(input).toHaveFocus();
+ expect(input).toHaveProperty('selectionStart', 0);
+ });
+
+ it.each([
+ ['arrows', '{ArrowRight}', false],
+ ['arrows', '{End}', true],
+ ['all', '{End}', false],
+ ['none', '{ArrowRight}', true],
+ ] as const)('with stopPropagation %s, %s reaching a parent is %s', async (mode, key, reaches) => {
+ const onParent = vi.fn();
+ render(
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- Test host; the keys come from the focusable items inside it.
+
+
+
,
+ );
+ button('One').focus();
+ await userEvent.keyboard(key);
+ expect(onParent).toHaveBeenCalledTimes(reaches ? 1 : 0);
+ });
+
+ it('ignores a modified key that is not a navigation key', async () => {
+ render(
);
+ button('One').focus();
+ await userEvent.keyboard('{Control>}c{/Control}');
+ expect(button('One')).toHaveFocus();
+ });
+
+ it('keeps a nested composite separate from its parent', async () => {
+ function Group({ names, children }: { names: string[]; children?: React.ReactNode }) {
+ const [element, setElement] = useState(null);
+ const composite = useComposite(element, {
+ orientation: 'horizontal',
+ getItems: () => Array.from(element?.querySelectorAll(':scope > button') ?? []),
+ });
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- Test host; the keys come from the focusable items inside it.
+
+ {names.map(name => (
+ {name}
+ ))}
+ {children}
+
+ );
+ }
+ render(
+
+
+ ,
+ );
+ await userEvent.click(button('Inner one'));
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Inner two')).toHaveFocus();
+ await userEvent.keyboard('{Home}');
+ expect(button('Inner one')).toHaveFocus();
+ expect(button('Outer one')).toHaveAttribute('tabindex', '0');
+ });
+
+ it('reports the item it moved to', async () => {
+ const onNavigate = vi.fn();
+ render(
+
,
+ );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(onNavigate).toHaveBeenCalledExactlyOnceWith(button('Two'));
+ });
+
+ it('lets getNextItem take over a key or refuse it', async () => {
+ const getNextItem = (event: CompositeKeyEvent, _target: HTMLElement, defaultNext: () => HTMLElement | null) => {
+ if (event.key === 'ArrowRight') {
+ return button('Three');
+ }
+ return event.key === 'ArrowLeft' ? null : defaultNext();
+ };
+ render(
+
,
+ );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Three')).toHaveFocus();
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(button('Three')).toHaveFocus();
+ await userEvent.keyboard('{Home}');
+ expect(button('One')).toHaveFocus();
+ });
+ });
+
+ describe('changing items', () => {
+ it('moves focus to the nearest item when the focused item is removed', async () => {
+ const { rerender } = render(
);
+ button('Two').focus();
+ rerender(
+
,
+ );
+ await act(async () => {});
+ expect(button('Three')).toHaveFocus();
+ expect(tabStops()).toEqual(['Three']);
+ });
+
+ it('does not move focus on removal unless asked', async () => {
+ const { rerender } = render(
);
+ button('Two').focus();
+ rerender(
);
+ await act(async () => {});
+ expect(document.body).toHaveFocus();
+ });
+
+ it('does not take focus when an unfocused active item is removed', async () => {
+ const { rerender } = render(
);
+ await userEvent.click(button('Two'));
+ await userEvent.click(screen.getByRole('textbox'));
+ await userEvent.tab();
+ const focused = document.activeElement;
+ rerender(
+
,
+ );
+ await act(async () => {});
+ expect(document.activeElement).toBe(focused);
+ expect(tabStops()).toEqual(['Three']);
+ });
+
+ it('moves the tab stop when the active item becomes disabled', () => {
+ const { rerender } = render(
);
+ rerender(
);
+ expect(tabStops()).toEqual(['Two']);
+ });
+
+ it('tabs back to a neighbour after the active item was removed', async () => {
+ const { rerender } = render(
);
+ await userEvent.click(button('Two'));
+ screen.getByRole('textbox').focus();
+ rerender(
);
+ await act(async () => {});
+ await userEvent.tab({ shift: true });
+ expect(button('Three')).toHaveFocus();
+ });
+
+ it('does not take focus when turned back on after focus left', async () => {
+ const { rerender } = render(
);
+ await userEvent.click(button('Two'));
+ rerender(
+
,
+ );
+ const input = screen.getByRole('textbox');
+ input.focus();
+ rerender(
);
+ await act(async () => {});
+ expect(input).toHaveFocus();
+ });
+
+ it('keeps the active item when turned off and on', async () => {
+ const { rerender } = render(
);
+ await userEvent.click(button('Two'));
+ rerender(
);
+ rerender(
);
+ expect(tabStops()).toEqual(['Two']);
+ });
+
+ it('does not take focus when mounted again after a move', async () => {
+ const view = (open: boolean) => (
+ <>
+ {open &&
}
+ Outside
+ >
+ );
+ const { rerender } = render(view(true));
+ await userEvent.click(button('One'));
+ await userEvent.keyboard('{ArrowRight}');
+ rerender(view(false));
+ button('Outside').focus();
+ rerender(view(true));
+ await act(async () => {});
+ expect(button('Outside')).toHaveFocus();
+ });
+
+ it('keeps the active item when items are reordered', async () => {
+ const { rerender } = render(
);
+ await userEvent.click(button('Two'));
+ rerender(
);
+ expect(tabStops()).toEqual(['Two']);
+ });
+ });
+
+ describe('registered items', () => {
+ function Item({ name }: { name: string }) {
+ const ref = useCompositeItem();
+ return {name} ;
+ }
+
+ it('finds an item by its current value', () => {
+ function Valued({ value, selected }: { value: string; selected: string }) {
+ const [element, setElement] = useState(null);
+ const items = useCompositeItems();
+ useComposite(element, {
+ getItems: items.getItems,
+ preferred: { key: selected, getItem: () => items.getItem(selected) },
+ });
+ return (
+
+
+
+
+
+
+ );
+ }
+ function ValuedItem({ name, value }: { name: string; value: string }) {
+ const ref = useCompositeItem(value);
+ return {name} ;
+ }
+ const { rerender } = render(
+ ,
+ );
+ expect(tabStops()).toEqual(['Two']);
+ rerender(
+ ,
+ );
+ expect(tabStops()).toEqual(['One']);
+ rerender(
+ ,
+ );
+ expect(tabStops()).toEqual(['Two']);
+ });
+
+ function Group({ names }: { names: string[] }) {
+ const [element, setElement] = useState(null);
+ const items = useCompositeItems();
+ const composite = useComposite(element, { getItems: items.getItems, orientation: 'horizontal' });
+ return (
+
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- Test host; the keys come from the focusable items inside it. */}
+
+ {names.map(name => (
+
+ ))}
+
+
+ );
+ }
+
+ it('has a tab stop on the first commit', () => {
+ render( );
+ expect(tabStops()).toEqual(['One']);
+ });
+
+ it('follows document order when an item mounts in the middle', async () => {
+ const { rerender } = render( );
+ rerender( );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Two')).toHaveFocus();
+ });
+
+ it('follows document order when items are reordered', async () => {
+ const { rerender } = render( );
+ rerender( );
+ button('Three').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Two')).toHaveFocus();
+ });
+
+ it('forgets an item that unmounts', async () => {
+ const { rerender } = render( );
+ rerender( );
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Three')).toHaveFocus();
+ });
+
+ it('keeps an unregistered control out of navigation', async () => {
+ function Mixed() {
+ const [element, setElement] = useState(null);
+ const items = useCompositeItems();
+ const composite = useComposite(element, { getItems: items.getItems, orientation: 'horizontal' });
+ return (
+
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- Test host; the keys come from the focusable items inside it. */}
+
+
+ Other
+
+
+
+ );
+ }
+ render( );
+ expect(button('Other')).not.toHaveAttribute('tabindex');
+ button('One').focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(button('Two')).toHaveFocus();
+ });
+ });
+
+ describe('tabindex ownership', () => {
+ it('restores original tabindexes when disabled', () => {
+ function Host({ enabled }: { enabled: boolean }) {
+ const [element, setElement] = useState(null);
+ useComposite(element, {
+ enabled,
+ getItems: () => Array.from(element?.querySelectorAll('button') ?? []),
+ });
+ return (
+
+ One
+ Two
+
+ );
+ }
+ const { rerender } = render( );
+ expect(button('One')).toHaveAttribute('tabindex', '0');
+ expect(button('Two')).toHaveAttribute('tabindex', '-1');
+ rerender( );
+ expect(button('One')).toHaveAttribute('tabindex', '-1');
+ expect(button('Two')).not.toHaveAttribute('tabindex');
+ });
+ });
+});
diff --git a/packages/mosaic/src/primitives/hooks/index.ts b/packages/mosaic/src/primitives/hooks/index.ts
index 9c8f15624bc..156ae7e0f08 100644
--- a/packages/mosaic/src/primitives/hooks/index.ts
+++ b/packages/mosaic/src/primitives/hooks/index.ts
@@ -1,4 +1,15 @@
export { useAnimationsFinished } from './use-animations-finished';
+export {
+ type CompositeItems,
+ CompositeItemsProvider,
+ type CompositeKeyEvent,
+ type SetTabIndex,
+ useComposite,
+ useCompositeItem,
+ useCompositeItems,
+ type UseCompositeOptions,
+ type UseCompositeReturn,
+} from './use-composite';
export { useControllableState } from './use-controllable-state';
export { useFormattedInput, type UseFormattedInputOptions } from './use-formatted-input';
export { useLayoutAnimation } from './use-layout-animation';
diff --git a/packages/mosaic/src/primitives/hooks/use-composite.ts b/packages/mosaic/src/primitives/hooks/use-composite.ts
new file mode 100644
index 00000000000..2477e29e3b6
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/use-composite.ts
@@ -0,0 +1,324 @@
+import { activeElement, contains, getDocument, useLatestRef } from '@floating-ui/react/utils';
+import React from 'react';
+
+export type SetTabIndex = (element: HTMLElement, tabIndex: 0 | -1 | null) => void;
+
+export type CompositeKeyEvent = Pick<
+ KeyboardEvent,
+ 'key' | 'target' | 'ctrlKey' | 'metaKey' | 'preventDefault' | 'stopPropagation'
+>;
+
+export interface UseCompositeOptions {
+ getItems: () => HTMLElement[];
+ orientation?: 'horizontal' | 'vertical' | 'both';
+ loop?: boolean;
+ rtl?: boolean;
+ enabled?: boolean;
+ preferred?: { getItem: () => HTMLElement | undefined; key?: unknown };
+ getNextItem?: (
+ event: CompositeKeyEvent,
+ target: HTMLElement,
+ defaultNext: () => HTMLElement | null,
+ ) => HTMLElement | null;
+ resolveFocus?: (target: HTMLElement) => HTMLElement | null;
+ syncTabIndexes?: (active: HTMLElement | null, setTabIndex: SetTabIndex) => void;
+ onNavigate?: (item: HTMLElement) => void;
+ homeEnd?: boolean;
+ recoverFocus?: boolean;
+ stopPropagation?: 'arrows' | 'all' | 'none';
+}
+
+export interface UseCompositeReturn {
+ setActive: (item: HTMLElement) => void;
+ handleKeyDown: (event: CompositeKeyEvent) => void;
+}
+
+export interface CompositeItems {
+ getItems: () => HTMLElement[];
+ getItem: (value: string) => HTMLElement | undefined;
+ getValue: (item: HTMLElement) => string | undefined;
+ register: (item: HTMLElement, value: string | undefined) => void;
+ unregister: (item: HTMLElement) => void;
+}
+
+const CompositeItemsContext = React.createContext({
+ getItems: () => [],
+ getItem: () => undefined,
+ getValue: () => undefined,
+ register: () => {},
+ unregister: () => {},
+});
+
+export const CompositeItemsProvider = CompositeItemsContext.Provider;
+
+export function useCompositeItems(): CompositeItems {
+ const [items] = React.useState(createCompositeItems);
+ return items;
+}
+
+export function useCompositeItem(value?: string): React.RefCallback {
+ const { register, unregister } = React.useContext(CompositeItemsContext);
+ const registeredRef = React.useRef(null);
+ return React.useCallback(
+ (item: HTMLElement | null) => {
+ if (registeredRef.current) {
+ unregister(registeredRef.current);
+ }
+ registeredRef.current = item;
+ if (item) {
+ register(item, value);
+ }
+ },
+ [register, unregister, value],
+ );
+}
+
+export function useComposite(element: HTMLElement | null, options: UseCompositeOptions): UseCompositeReturn {
+ const { enabled = true, preferred } = options;
+ const preferredKey = preferred?.key;
+ const optionsRef = useLatestRef(options);
+ const activeRef = React.useRef(null);
+ const activeIndexRef = React.useRef(0);
+ const focusedRef = React.useRef(false);
+ const originalTabIndexes = React.useRef(new Map());
+
+ const setTabIndex = React.useCallback((target, tabIndex) => {
+ if (!originalTabIndexes.current.has(target)) {
+ originalTabIndexes.current.set(target, target.getAttribute('tabindex'));
+ }
+ if (tabIndex === null) {
+ target.removeAttribute('tabindex');
+ } else {
+ target.tabIndex = tabIndex;
+ }
+ }, []);
+
+ const sync = React.useCallback(() => {
+ if (!element) {
+ return;
+ }
+ const { getItems, preferred: preferredOption, syncTabIndexes, recoverFocus } = optionsRef.current;
+ const items = getItems();
+ const lostActive = !isUsable(activeRef.current, items);
+ if (lostActive) {
+ const preferredItem = preferredOption?.getItem();
+ activeRef.current = isUsable(preferredItem, items) ? preferredItem : nearestItem(items, activeIndexRef.current);
+ }
+ const active = activeRef.current;
+ if (active) {
+ activeIndexRef.current = items.indexOf(active);
+ }
+ forgetDisconnected(originalTabIndexes.current);
+ if (syncTabIndexes) {
+ syncTabIndexes(active, setTabIndex);
+ } else {
+ for (const item of items) {
+ setTabIndex(item, item === active ? 0 : -1);
+ }
+ }
+ if (recoverFocus && lostActive && focusedRef.current && !contains(element, activeElement(getDocument(element)))) {
+ active?.focus();
+ }
+ }, [element, optionsRef, setTabIndex]);
+
+ const setActive = React.useCallback(
+ (item: HTMLElement) => {
+ activeRef.current = item;
+ sync();
+ },
+ [sync],
+ );
+
+ React.useLayoutEffect(() => {
+ activeRef.current = null;
+ }, [preferredKey]);
+
+ React.useLayoutEffect(() => {
+ if (enabled) {
+ sync();
+ }
+ });
+
+ React.useLayoutEffect(() => {
+ if (!enabled || !element) {
+ return;
+ }
+ const originals = originalTabIndexes.current;
+ const observer = new MutationObserver(sync);
+ observer.observe(element, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['disabled', 'aria-disabled'],
+ });
+
+ const onFocusIn = (event: FocusEvent) => {
+ const { target } = event;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ focusedRef.current = true;
+ const { getItems, resolveFocus } = optionsRef.current;
+ const item = resolveFocus ? resolveFocus(target) : getItems().includes(target) ? target : null;
+ if (item) {
+ setActive(item);
+ }
+ };
+
+ const onFocusOut = (event: FocusEvent) => {
+ const { target, relatedTarget } = event;
+ if (
+ target instanceof Element &&
+ target.isConnected &&
+ !(relatedTarget instanceof Element && contains(element, relatedTarget))
+ ) {
+ focusedRef.current = false;
+ }
+ };
+
+ element.addEventListener('focusin', onFocusIn);
+ element.addEventListener('focusout', onFocusOut);
+ return () => {
+ observer.disconnect();
+ element.removeEventListener('focusin', onFocusIn);
+ element.removeEventListener('focusout', onFocusOut);
+ restoreTabIndexes(originals);
+ };
+ }, [enabled, element, optionsRef, sync, setActive]);
+
+ const handleKeyDown = React.useCallback(
+ (event: CompositeKeyEvent) => {
+ const { target } = event;
+ const current = optionsRef.current;
+ if (current.enabled === false || !(target instanceof HTMLElement)) {
+ return;
+ }
+ const defaultNext = () => defaultNextItem(event, target, current);
+ const next = current.getNextItem ? current.getNextItem(event, target, defaultNext) : defaultNext();
+ if (!next) {
+ return;
+ }
+ event.preventDefault();
+ if (stopsPropagation(event.key, current.stopPropagation)) {
+ event.stopPropagation();
+ }
+ setActive(next);
+ next.focus();
+ current.onNavigate?.(next);
+ },
+ [optionsRef, setActive],
+ );
+
+ return React.useMemo(() => ({ setActive, handleKeyDown }), [setActive, handleKeyDown]);
+}
+
+function createCompositeItems(): CompositeItems {
+ const items = new Map();
+ const getItems = () => Array.from(items.keys()).sort(sortByDocumentPosition);
+ return {
+ getItems,
+ getItem: value => getItems().find(item => items.get(item) === value),
+ getValue: item => items.get(item),
+ register: (item, value) => {
+ items.set(item, value);
+ },
+ unregister: item => {
+ items.delete(item);
+ },
+ };
+}
+
+function sortByDocumentPosition(a: HTMLElement, b: HTMLElement) {
+ const position = a.compareDocumentPosition(b);
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
+ return -1;
+ }
+ if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
+ return 1;
+ }
+ return 0;
+}
+
+function defaultNextItem(event: CompositeKeyEvent, target: HTMLElement, options: UseCompositeOptions) {
+ const { getItems, orientation = 'both', loop = true, rtl = false, homeEnd = true } = options;
+ const items = getItems();
+ const index = items.indexOf(target);
+ if (index === -1) {
+ return null;
+ }
+ const enabled = items.filter(item => !isDisabled(item));
+ switch (stepFor(event.key, orientation, rtl, homeEnd)) {
+ case 'first':
+ return enabled[0] ?? null;
+ case 'last':
+ return enabled.at(-1) ?? null;
+ case 'next':
+ return items.slice(index + 1).find(item => !isDisabled(item)) ?? (loop ? (enabled[0] ?? null) : null);
+ case 'previous':
+ return items.slice(0, index).findLast(item => !isDisabled(item)) ?? (loop ? (enabled.at(-1) ?? null) : null);
+ case null:
+ return null;
+ }
+}
+
+function stopsPropagation(key: string, mode: UseCompositeOptions['stopPropagation'] = 'arrows') {
+ return mode === 'all' || (mode === 'arrows' && key.startsWith('Arrow'));
+}
+
+function stepFor(
+ key: string,
+ orientation: NonNullable,
+ rtl: boolean,
+ homeEnd: boolean,
+) {
+ const horizontal = orientation !== 'vertical';
+ const vertical = orientation !== 'horizontal';
+ if (homeEnd && key === 'Home') {
+ return 'first';
+ }
+ if (homeEnd && key === 'End') {
+ return 'last';
+ }
+ if ((horizontal && key === (rtl ? 'ArrowLeft' : 'ArrowRight')) || (vertical && key === 'ArrowDown')) {
+ return 'next';
+ }
+ if ((horizontal && key === (rtl ? 'ArrowRight' : 'ArrowLeft')) || (vertical && key === 'ArrowUp')) {
+ return 'previous';
+ }
+ return null;
+}
+
+function isUsable(item: HTMLElement | null | undefined, items: HTMLElement[]): item is HTMLElement {
+ return !!item && items.includes(item) && !item.matches(':disabled');
+}
+
+function isDisabled(item: HTMLElement) {
+ return item.matches(':disabled, [aria-disabled="true"]');
+}
+
+function nearestItem(items: HTMLElement[], index: number) {
+ const byDistance = items
+ .map((item, position) => ({ item, distance: Math.abs(position - index) }))
+ .sort((a, b) => a.distance - b.distance)
+ .map(({ item }) => item);
+ return byDistance.find(item => !isDisabled(item)) ?? byDistance.find(item => !item.matches(':disabled')) ?? null;
+}
+
+function forgetDisconnected(originals: Map) {
+ for (const target of originals.keys()) {
+ if (!target.isConnected) {
+ originals.delete(target);
+ }
+ }
+}
+
+function restoreTabIndexes(originals: Map) {
+ for (const [target, tabIndex] of originals) {
+ if (tabIndex === null) {
+ target.removeAttribute('tabindex');
+ } else {
+ target.setAttribute('tabindex', tabIndex);
+ }
+ }
+ originals.clear();
+}
diff --git a/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx b/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx
index 297b63508eb..8b50183db45 100644
--- a/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx
+++ b/packages/mosaic/src/primitives/segmented-control/segmented-control-item.tsx
@@ -1,9 +1,9 @@
'use client';
-import { CompositeItem } from '@floating-ui/react';
import React from 'react';
-import { type ComponentProps, isRef, mergeProps, useRender } from '../utils';
+import { useCompositeItem } from '../hooks/use-composite';
+import { type ComponentProps, mergeProps, useRender } from '../utils';
import { useSegmentedControlContext } from './segmented-control-context';
export interface SegmentedControlItemProps extends ComponentProps<'button'> {
@@ -13,9 +13,11 @@ export interface SegmentedControlItemProps extends ComponentProps<'button'> {
export const SegmentedControlItem = React.forwardRef(
function SegmentedControlItem(props, ref) {
- const { render, value: itemValue, disabled: disabledProp, children, ...otherProps } = props;
+ const { render, value: itemValue, disabled: disabledProp, ...otherProps } = props;
const { value: selectedValue, select, disabled: groupDisabled } = useSegmentedControlContext();
+ const compositeItem = useCompositeItem(itemValue);
+
const disabled = groupDisabled || !!disabledProp;
const isSelected = selectedValue === itemValue;
@@ -24,46 +26,29 @@ export const SegmentedControlItem = React.forwardRef) => {
- const defaultProps: Record = {
- role: 'radio' as const,
- type: 'button' as const,
- 'aria-checked': isSelected,
- 'aria-disabled': disabled || undefined,
- value: itemValue,
- onClick: () => {
- if (!disabled) {
- select(itemValue);
- }
- },
- };
-
- const merged = mergeProps<'button'>(mergeProps<'button'>(defaultProps, otherProps), compositeProps);
- const { ref: compositeRef, ...mergedProps } = merged;
- const orderedProps = {
- ...(isSelected ? { 'data-selected': '' } : null),
- ...(disabled ? { 'data-disabled': '' } : null),
- ...mergedProps,
- };
+ const defaultProps: Record = {
+ role: 'radio' as const,
+ type: 'button' as const,
+ 'aria-checked': isSelected,
+ 'aria-disabled': disabled || undefined,
+ value: itemValue,
+ onClick: () => {
+ if (!disabled) {
+ select(itemValue);
+ }
+ },
+ };
- // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's Composite calls this render callback synchronously during its own render, so the hook keeps a stable position.
- return useRender({
- defaultTagName: 'button',
- render,
- ref: [isRef(compositeRef) ? compositeRef : undefined, ref],
- state,
- stateAttributesMapping: {
- selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
- disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
- },
- props: orderedProps,
- });
- }}
- >
- {children}
-
- );
+ return useRender({
+ defaultTagName: 'button',
+ render,
+ ref: [compositeItem, ref],
+ state,
+ stateAttributesMapping: {
+ selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
+ disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+ },
+ props: mergeProps<'button'>(defaultProps, otherProps),
+ });
},
);
diff --git a/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx b/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx
index a642dfa3f26..80a4aadaa40 100644
--- a/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx
+++ b/packages/mosaic/src/primitives/segmented-control/segmented-control-root.tsx
@@ -1,11 +1,11 @@
'use client';
-import { Composite } from '@floating-ui/react';
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useDirection } from '../direction-provider';
+import { CompositeItemsProvider, useComposite, useCompositeItems } from '../hooks/use-composite';
import { useControllableState } from '../hooks/use-controllable-state';
-import { type ComponentProps, isRef, mergeProps, useRender } from '../utils';
+import { type ComponentProps, mergeProps, useRender } from '../utils';
import {
SegmentedControlContext,
type SegmentedControlContextValue,
@@ -25,13 +25,11 @@ export const SegmentedControlRoot = React.forwardRef('forward');
- const [activeIndex, setActiveIndex] = useState(0);
const readingDirection = useDirection();
- const rootRef = useRef(null);
+ const [element, setElement] = useState(null);
const selectedIndexRef = useRef(-1);
- const navigatedIndexRef = useRef(null);
- const getRadios = () => Array.from(rootRef.current?.querySelectorAll('[role="radio"]') ?? []);
+ const items = useCompositeItems();
const select = useCallback(
(nextValue: string) => {
@@ -42,25 +40,28 @@ export const SegmentedControlRoot = React.forwardRef items.getItem(value) },
+ orientation: 'horizontal',
+ loop: false,
+ rtl: readingDirection === 'rtl',
+ onNavigate: item => {
+ const nextValue = items.getValue(item);
+ if (nextValue !== undefined) {
+ select(nextValue);
+ }
+ },
+ });
+
useLayoutEffect(() => {
- const index = getRadios().findIndex(radio => radio.getAttribute('value') === value);
+ const index = items.getItems().findIndex(item => items.getValue(item) === value);
const previousIndex = selectedIndexRef.current;
selectedIndexRef.current = index;
- if (index === -1) {
- return;
- }
- if (previousIndex !== -1 && previousIndex !== index) {
+ if (index !== -1 && previousIndex !== -1 && previousIndex !== index) {
setDirection(index > previousIndex ? 'forward' : 'backward');
}
- setActiveIndex(index);
- }, [value]);
-
- const onNavigate = (index: number) => {
- setActiveIndex(index);
- if (navigatedIndexRef.current !== null) {
- navigatedIndexRef.current = index;
- }
- };
+ }, [value, items]);
const contextValue = useMemo(
() => ({ value, select, disabled, direction }),
@@ -71,52 +72,27 @@ export const SegmentedControlRoot = React.forwardRef
- ) => {
- const { onKeyDown: compositeKeyDown, ...otherCompositeProps } = compositeProps;
- const defaultProps: Record = {
- role: 'radiogroup' as const,
- onKeyDown: (event: React.KeyboardEvent) => {
- navigatedIndexRef.current = -1;
- if (event.key === 'Home' || event.key === 'End') {
- event.preventDefault();
- const enabled = getRadios().filter(radio => radio.getAttribute('aria-disabled') !== 'true');
- (event.key === 'Home' ? enabled[0] : enabled.at(-1))?.focus();
- }
- compositeKeyDown?.(event);
- const index = navigatedIndexRef.current;
- navigatedIndexRef.current = null;
- const nextValue = getRadios()[index]?.getAttribute('value');
- if (nextValue != null) {
- select(nextValue);
- }
- },
- 'aria-disabled': disabled || undefined,
- };
-
- const merged = mergeProps<'div'>(defaultProps, mergeProps<'div'>(otherProps, otherCompositeProps));
- const { ref: compositeRef, ...mergedProps } = merged;
-
- // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's Composite calls this render callback synchronously during its own render, so the hook keeps a stable position.
- return useRender({
- defaultTagName: 'div',
- render,
- ref: [isRef(compositeRef) ? compositeRef : undefined, rootRef, ref],
- state,
- stateAttributesMapping: {
- disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+
+ {useRender({
+ defaultTagName: 'div',
+ render,
+ ref: [setElement, ref],
+ state,
+ stateAttributesMapping: {
+ disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+ },
+ props: mergeProps<'div'>(
+ {
+ role: 'radiogroup',
+ 'aria-orientation': 'horizontal',
+ onKeyDown: composite.handleKeyDown,
+ 'aria-disabled': disabled || undefined,
+ children,
},
- props: mergedProps,
- });
- }}
- >
- {children}
-
+ otherProps,
+ ),
+ })}
+
);
},
diff --git a/packages/mosaic/src/primitives/table/table.test.tsx b/packages/mosaic/src/primitives/table/table.test.tsx
index 27d433372e5..b8f1db12263 100644
--- a/packages/mosaic/src/primitives/table/table.test.tsx
+++ b/packages/mosaic/src/primitives/table/table.test.tsx
@@ -1249,6 +1249,50 @@ describe('Table', () => {
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
+ it('leaves focus alone when navigation comes back after focus left', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render( );
+
+ await tabIntoTable(user);
+ rerender( );
+ screen.getByRole('button', { name: 'After' }).focus();
+ rerender( );
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
+ });
+
+ it('keeps the tab stop on the same row when navigation comes back', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render( );
+
+ await tabIntoTable(user);
+ await user.keyboard('{End}');
+ rerender( );
+ rerender( );
+
+ expect(row('Charlie')).toHaveAttribute('tabindex', '0');
+ });
+
+ it('leaves focus outside the table when rows return after the table emptied', async () => {
+ const user = userEvent.setup();
+ const { rerender } = render( );
+
+ await tabIntoTable(user);
+ rerender(
+ ,
+ );
+ await new Promise(resolve => setTimeout(resolve, 0));
+ screen.getByRole('button', { name: 'After' }).focus();
+ rerender( );
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
+ });
+
it('keeps a tab stop when the focused row becomes disabled', async () => {
const user = userEvent.setup();
const { rerender } = render( );
diff --git a/packages/mosaic/src/primitives/table/use-grid-navigation.ts b/packages/mosaic/src/primitives/table/use-grid-navigation.ts
index 173cf8ac52f..c11f9dcb732 100644
--- a/packages/mosaic/src/primitives/table/use-grid-navigation.ts
+++ b/packages/mosaic/src/primitives/table/use-grid-navigation.ts
@@ -1,6 +1,11 @@
-import { contains } from '@floating-ui/react/utils';
import React from 'react';
+import {
+ type CompositeKeyEvent,
+ type SetTabIndex,
+ useComposite,
+ type UseCompositeReturn,
+} from '../hooks/use-composite';
import { isRtl, isTextEntry } from './table-dom';
const WIDGET_SELECTOR = 'button, a[href], input, select, textarea, [tabindex]';
@@ -13,165 +18,112 @@ interface Position {
const ROW_COLUMN = -1;
export function useGridNavigation(table: HTMLTableElement | null, enabled: boolean, excluded: WeakSet) {
- const activeRef = React.useRef(null);
const positionRef = React.useRef(null);
const editingRef = React.useRef(null);
- const focusedRef = React.useRef(false);
- const originalTabIndexes = React.useRef(new Map());
- const setTabIndex = React.useCallback((element: HTMLElement, tabIndex: number | null) => {
- if (!originalTabIndexes.current.has(element)) {
- originalTabIndexes.current.set(element, element.getAttribute('tabindex'));
- }
- if (tabIndex === null) {
- element.removeAttribute('tabindex');
- } else {
- element.tabIndex = tabIndex;
- }
- }, []);
-
- const sync = React.useCallback(() => {
- if (!table) {
- return;
- }
- const lostActive = !isActivatable(table, activeRef.current);
- if (lostActive) {
- activeRef.current = itemAt(table, positionRef.current ?? initialPosition(table, excluded), excluded) ?? null;
- }
- for (const element of originalTabIndexes.current.keys()) {
- if (!element.isConnected) {
- originalTabIndexes.current.delete(element);
- }
- }
- for (const row of Array.from(table.rows)) {
- syncRow(row, activeRef.current, editingRef.current, excluded, setTabIndex);
- }
- if (lostActive && focusedRef.current && !contains(table, table.ownerDocument.activeElement)) {
- activeRef.current?.focus();
- }
- }, [table, excluded, setTabIndex]);
-
- const activate = React.useCallback(
- (element: HTMLElement, position: Position, editing: HTMLTableCellElement | null) => {
- activeRef.current = element;
- positionRef.current = position;
- editingRef.current = editing;
- sync();
+ const composite: UseCompositeReturn = useComposite(table, {
+ enabled,
+ recoverFocus: true,
+ stopPropagation: 'all',
+ getItems: () => (table ? gridItems(table, excluded) : []),
+ preferred: {
+ getItem: () =>
+ table ? itemAt(table, positionRef.current ?? initialPosition(table, excluded), excluded) : undefined,
},
- [sync],
- );
-
- const leaveCell = React.useCallback(
- (target: Element) => {
- const cell = table ? owningCell(table, target) : null;
- if (!cell || editingRef.current !== cell) {
- return false;
- }
- activate(cell, positionOf(cell), null);
- cell.focus();
- return true;
- },
- [table, activate],
- );
-
- React.useLayoutEffect(() => {
- if (enabled) {
- sync();
- }
- });
-
- React.useLayoutEffect(() => {
- if (!enabled || !table) {
- return;
- }
- const originals = originalTabIndexes.current;
- const observer = new MutationObserver(sync);
- observer.observe(table, { childList: true, subtree: true });
-
- const moveTo = (position: Position) => {
- const element = itemAt(table, position, excluded);
- if (element) {
- activate(element, positionOf(element), null);
- element.focus();
- }
- };
-
- const onFocusIn = (event: FocusEvent) => {
- const { target } = event;
- if (!(target instanceof HTMLElement)) {
- return;
+ resolveFocus: target => {
+ if (!table) {
+ return null;
}
- focusedRef.current = true;
if (target instanceof HTMLTableRowElement && isRowItem(target, excluded) && target.closest('table') === table) {
- activate(target, positionOf(target), null);
- return;
+ editingRef.current = null;
+ return target;
}
const cell = owningCell(table, target);
if (!cell) {
- return;
+ return null;
}
if (target === cell || target === focusTarget(cell, excluded)) {
- activate(target, positionOf(cell), null);
- } else {
- activate(cell, positionOf(cell), cell);
- }
- };
-
- const onFocusOut = (event: FocusEvent) => {
- const { target, relatedTarget } = event;
- if (
- target instanceof Element &&
- target.isConnected &&
- !(relatedTarget instanceof Element && contains(table, relatedTarget))
- ) {
- focusedRef.current = false;
+ editingRef.current = null;
+ return target;
}
- };
-
- const onKeyDown = (event: KeyboardEvent) => {
- const { target } = event;
- if (event.defaultPrevented || event.altKey || !(target instanceof HTMLElement)) {
- return;
+ editingRef.current = cell;
+ return cell;
+ },
+ getNextItem: (event, target) => {
+ if (!table) {
+ return null;
}
const row = target instanceof HTMLTableRowElement && target.closest('table') === table ? target : null;
const cell = row ? null : owningCell(table, target);
-
if (cell && editingRef.current === cell) {
- return;
+ return null;
}
-
if (cell && target === cell && (event.key === 'Enter' || event.key === 'F2')) {
const [firstWidget] = cellWidgets(cell, excluded);
if (firstWidget) {
event.preventDefault();
- activate(cell, positionOf(cell), cell);
+ editingRef.current = cell;
+ composite.setActive(cell);
firstWidget.focus();
}
+ return null;
+ }
+ const position = row
+ ? rowMove(table, row, event, excluded)
+ : cell
+ ? cellMove(table, cell, event, excluded)
+ : null;
+ const next = position ? itemAt(table, position, excluded) : null;
+ if (next) {
+ editingRef.current = null;
+ }
+ return next ?? null;
+ },
+ syncTabIndexes: (active, setTabIndex) => {
+ if (!table) {
return;
}
+ if (active) {
+ positionRef.current = positionOf(active);
+ }
+ for (const row of Array.from(table.rows)) {
+ syncRow(row, active, editingRef.current, excluded, setTabIndex);
+ }
+ },
+ });
- const next = row ? rowMove(table, row, event, excluded) : cell ? cellMove(table, cell, event, excluded) : null;
- if (!next) {
- return;
+ React.useLayoutEffect(() => {
+ if (!enabled || !table) {
+ return;
+ }
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!event.defaultPrevented && !event.altKey) {
+ composite.handleKeyDown(event);
}
- event.preventDefault();
- event.stopPropagation();
- moveTo(next);
};
-
- table.addEventListener('focusin', onFocusIn);
- table.addEventListener('focusout', onFocusOut);
table.addEventListener('keydown', onKeyDown, true);
- return () => {
- observer.disconnect();
- table.removeEventListener('focusin', onFocusIn);
- table.removeEventListener('focusout', onFocusOut);
- table.removeEventListener('keydown', onKeyDown, true);
- restoreTabIndexes(originals);
- };
- }, [enabled, table, excluded, sync, activate]);
+ return () => table.removeEventListener('keydown', onKeyDown, true);
+ }, [enabled, table, composite]);
- return leaveCell;
+ return (target: Element) => {
+ const cell = table ? owningCell(table, target) : null;
+ if (!cell || editingRef.current !== cell) {
+ return false;
+ }
+ editingRef.current = null;
+ composite.setActive(cell);
+ cell.focus();
+ return true;
+ };
+}
+
+function gridItems(table: HTMLTableElement, excluded: WeakSet) {
+ return Array.from(table.rows)
+ .filter(row => !isDisabledRow(row))
+ .flatMap(row => [
+ ...(isRowItem(row, excluded) ? [row] : []),
+ ...Array.from(row.cells).flatMap(cell => [cell, focusTarget(cell, excluded)]),
+ ]);
}
function syncRow(
@@ -179,7 +131,7 @@ function syncRow(
active: HTMLElement | null,
editing: HTMLTableCellElement | null,
excluded: WeakSet,
- setTabIndex: (element: HTMLElement, tabIndex: number | null) => void,
+ setTabIndex: SetTabIndex,
) {
const disabled = isDisabledRow(row);
if (isRowItem(row, excluded)) {
@@ -193,21 +145,10 @@ function syncRow(
}
}
-function restoreTabIndexes(originals: Map) {
- for (const [element, tabIndex] of originals) {
- if (tabIndex === null) {
- element.removeAttribute('tabindex');
- } else {
- element.setAttribute('tabindex', tabIndex);
- }
- }
- originals.clear();
-}
-
function rowMove(
table: HTMLTableElement,
row: HTMLTableRowElement,
- event: KeyboardEvent,
+ event: CompositeKeyEvent,
excluded: WeakSet,
): Position | null {
const { forward, backward } = arrowKeys(table);
@@ -238,7 +179,7 @@ function rowMove(
function cellMove(
table: HTMLTableElement,
cell: HTMLTableCellElement,
- event: KeyboardEvent,
+ event: CompositeKeyEvent,
excluded: WeakSet,
): Position | null {
const { forward, backward } = arrowKeys(table);
@@ -367,11 +308,6 @@ function arrowKeys(table: HTMLTableElement) {
: { forward: 'ArrowRight', backward: 'ArrowLeft' };
}
-function isActivatable(table: HTMLTableElement, element: HTMLElement | null) {
- const row = element?.closest('tr');
- return !!element?.isConnected && contains(table, element) && !!row && !isDisabledRow(row);
-}
-
function rovingTabIndex(active: boolean) {
return active ? 0 : -1;
}
diff --git a/packages/mosaic/src/primitives/tabs/tabs-list.tsx b/packages/mosaic/src/primitives/tabs/tabs-list.tsx
index 6abe1a2f349..14c01648df5 100644
--- a/packages/mosaic/src/primitives/tabs/tabs-list.tsx
+++ b/packages/mosaic/src/primitives/tabs/tabs-list.tsx
@@ -1,53 +1,40 @@
'use client';
-import { Composite } from '@floating-ui/react';
-import React from 'react';
+import React, { useState } from 'react';
import { useDirection } from '../direction-provider';
-import { type ComponentProps, isRef, mergeProps, useRender } from '../utils';
+import { CompositeItemsProvider, useComposite, useCompositeItems } from '../hooks/use-composite';
+import { type ComponentProps, mergeProps, useRender } from '../utils';
import { useTabsContext } from './tabs-context';
export type TabsListProps = ComponentProps<'div'>;
export const TabsList = React.forwardRef(function TabsList(props, ref) {
- const { render, children, ...otherProps } = props;
- const { orientation } = useTabsContext();
+ const { render, ...otherProps } = props;
+ const { orientation, value } = useTabsContext();
const direction = useDirection();
+ const [element, setElement] = useState(null);
- return (
- ) => {
- const defaultProps: Record = {
- 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])'));
- (event.key === 'Home' ? items.at(0) : items.at(-1))?.focus();
- },
- };
-
- const merged = mergeProps<'div'>(defaultProps, mergeProps<'div'>(otherProps, compositeProps));
+ const items = useCompositeItems();
- // Composite may inject a ref via compositeProps; hand it to useRender's ref
- // param (which owns ref-merging) instead of leaving it in props, where
- // useRender's merged ref would overwrite it.
- const { ref: compositeRef, ...mergedProps } = merged;
+ const composite = useComposite(element, {
+ getItems: items.getItems,
+ preferred: { key: value, getItem: () => items.getItem(value) },
+ orientation,
+ rtl: orientation === 'horizontal' && direction === 'rtl',
+ });
- // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's Composite calls this render callback synchronously during its own render, so the hook keeps a stable position.
- return useRender({
- defaultTagName: 'div',
- render,
- ref: [isRef(compositeRef) ? compositeRef : null, ref],
- props: mergedProps,
- });
- }}
- >
- {children}
-
+ return (
+
+ {useRender({
+ defaultTagName: 'div',
+ render,
+ ref: [setElement, ref],
+ props: mergeProps<'div'>(
+ { role: 'tablist', 'aria-orientation': orientation, onKeyDown: composite.handleKeyDown },
+ otherProps,
+ ),
+ })}
+
);
});
diff --git a/packages/mosaic/src/primitives/tabs/tabs-tab.tsx b/packages/mosaic/src/primitives/tabs/tabs-tab.tsx
index 0d16f583948..ede15e4d43d 100644
--- a/packages/mosaic/src/primitives/tabs/tabs-tab.tsx
+++ b/packages/mosaic/src/primitives/tabs/tabs-tab.tsx
@@ -1,9 +1,9 @@
'use client';
-import { CompositeItem } from '@floating-ui/react';
import React, { useCallback } from 'react';
-import { type ComponentProps, isRef, mergeProps, useRender } from '../utils';
+import { useCompositeItem } from '../hooks/use-composite';
+import { type ComponentProps, mergeProps, useRender } from '../utils';
import { useTabsContext } from './tabs-context';
export interface TabsTabProps extends ComponentProps<'button'> {
@@ -12,9 +12,11 @@ export interface TabsTabProps extends ComponentProps<'button'> {
}
export const TabsTab = React.forwardRef(function TabsTab(props, ref) {
- const { render, value: tabValue, disabled, children, ...otherProps } = props;
+ const { render, value: tabValue, disabled, ...otherProps } = props;
const { value: selectedValue, setValue, activationMode, tabsId, registerTab } = useTabsContext();
+ const compositeItem = useCompositeItem(tabValue);
+
const isSelected = selectedValue === tabValue;
const tabId = `${tabsId}-tab-${tabValue}`;
const panelId = `${tabsId}-panel-${tabValue}`;
@@ -28,78 +30,50 @@ export const TabsTab = React.forwardRef(functio
disabled: !!disabled,
};
- return (
- ) => {
- const defaultProps: Record = {
- 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);
+ const defaultProps: Record = {
+ role: 'tab' as const,
+ type: 'button' as const,
+ disabled,
+ 'aria-selected': isSelected,
+ 'aria-controls': panelId,
+ 'aria-disabled': disabled || undefined,
+ };
- // 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;
+ 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);
+ }
+ };
+ }
- // CompositeItem injects its roving-tabindex ref via compositeProps; hand it to
- // useRender's ref param (which owns ref-merging) instead of leaving it in props,
- // where useRender's merged ref would overwrite it and break focus navigation.
- const { ref: compositeRef, ...mergedProps } = merged;
+ const merged = mergeProps<'button'>(defaultProps, otherProps);
- // The state markers lead: React writes changed attributes in key order, and Chrome flushes
- // style when `tabindex` changes on the focused element, so a `data-selected` written after
- // the roving `tabindex` is missing from that flush and a CSS anchor named on it snaps for a
- // recalc. Only the key position is claimed here; `useRender` sets the values.
- const orderedProps = {
- ...(isSelected ? { 'data-selected': '' } : null),
- ...(disabled ? { 'data-disabled': '' } : null),
- ...mergedProps,
- };
+ // 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;
- // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's Composite calls this render callback synchronously during its own render, so the hook keeps a stable position.
- return useRender({
- defaultTagName: 'button',
- render,
- ref: [register, isRef(compositeRef) ? compositeRef : undefined, ref],
- state,
- stateAttributesMapping: {
- selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
- disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
- },
- props: orderedProps,
- });
- }}
- >
- {children}
-
- );
+ return useRender({
+ defaultTagName: 'button',
+ render,
+ ref: [compositeItem, register, ref],
+ state,
+ stateAttributesMapping: {
+ selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
+ disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+ },
+ props: merged,
+ });
});
diff --git a/packages/mosaic/src/primitives/tabs/tabs.test.tsx b/packages/mosaic/src/primitives/tabs/tabs.test.tsx
index a33db5a9a5b..06553bf7bee 100644
--- a/packages/mosaic/src/primitives/tabs/tabs.test.tsx
+++ b/packages/mosaic/src/primitives/tabs/tabs.test.tsx
@@ -29,6 +29,42 @@ function renderTabs(props: Partial> = {})
}
describe('Tabs', () => {
+ describe('item registration', () => {
+ it('leaves a standalone trigger inside the list out of keyboard navigation', async () => {
+ render(
+
+
+ Account
+ Settings
+ Billing
+
+ ,
+ );
+ screen.getByRole('tab', { name: 'Account' }).focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(screen.getByRole('tab', { name: 'Billing' })).toHaveFocus();
+ });
+
+ it('keeps a tab in keyboard navigation when its role is overridden', async () => {
+ render(
+
+
+ Account
+
+ Settings
+
+
+ ,
+ );
+ screen.getByRole('tab', { name: 'Account' }).focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(screen.getByRole('button', { name: 'Settings' })).toHaveFocus();
+ });
+ });
+
describe('ARIA attributes', () => {
it('list has role=tablist', () => {
renderTabs();
@@ -541,6 +577,12 @@ describe('Tabs', () => {
expect(screen.getByText('Billing')).toHaveAttribute('tabindex', '-1');
});
+ it('puts the tab stop on a selected tab that is not first', () => {
+ renderTabs({ defaultValue: 'tab2' });
+ expect(screen.getByText('Settings')).toHaveAttribute('tabindex', '0');
+ expect(screen.getByText('Account')).toHaveAttribute('tabindex', '-1');
+ });
+
it('tabIndex updates when selection changes', async () => {
const user = userEvent.setup();
renderTabs();
@@ -811,4 +853,52 @@ describe('Tabs', () => {
);
expect(ref.current).toBe(screen.getByRole('tablist'));
});
+
+ describe('consumer key handlers', () => {
+ function HandlerTabs({ onTab, onParent }: { onTab?: (key: string) => void; onParent?: (key: string) => void }) {
+ return (
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- Test host; the keys come from the focusable items inside it.
+ onParent?.(event.key)}>
+
+
+ onTab?.(event.key)}
+ >
+ A
+
+ B
+ C
+
+
+
+ );
+ }
+
+ it('calls a tab onKeyDown for navigation keys', async () => {
+ const onTab = vi.fn();
+ render( );
+ screen.getByRole('tab', { name: 'A' }).focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(onTab).toHaveBeenCalledWith('ArrowRight');
+ expect(screen.getByRole('tab', { name: 'B' })).toHaveFocus();
+ });
+
+ it('stops arrows at the list but lets End reach a parent', async () => {
+ const onParent = vi.fn();
+ render( );
+ screen.getByRole('tab', { name: 'A' }).focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(onParent).not.toHaveBeenCalled();
+ await userEvent.keyboard('{End}');
+ expect(onParent).toHaveBeenCalledWith('End');
+ });
+
+ it.each(['{Shift>}', '{Control>}', '{Meta>}', '{Alt>}'])('moves with %s held', async modifier => {
+ render( );
+ screen.getByRole('tab', { name: 'A' }).focus();
+ await userEvent.keyboard(`${modifier}{ArrowRight}`);
+ expect(screen.getByRole('tab', { name: 'B' })).toHaveFocus();
+ });
+ });
});
diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx b/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx
index 0b01bbddcbe..68abc19cb6e 100644
--- a/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx
+++ b/packages/mosaic/src/primitives/tag-input/tag-input-root.tsx
@@ -2,6 +2,8 @@
import React, { type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
+import { readingDirectionKeys, useDirection } from '../direction-provider';
+import { useComposite } from '../hooks/use-composite';
import { useControllableState } from '../hooks/use-controllable-state';
import { type ComponentProps, mergeProps, useRender } from '../utils';
import {
@@ -68,6 +70,31 @@ export const TagInputRoot = React.forwardRef(func
}
}, []);
+ const [element, setElement] = useState(null);
+ const direction = useDirection();
+ const { previousKey, nextKey } = readingDirectionKeys(direction);
+ const presentTags = () => value.flatMap(tagValue => tagRefs.current.get(tagValue) ?? []);
+
+ const composite = useComposite(element, {
+ getItems: presentTags,
+ orientation: 'horizontal',
+ loop: false,
+ rtl: direction === 'rtl',
+ stopPropagation: 'none',
+ syncTabIndexes: () => {},
+ getNextItem: (event, target, defaultNext) => {
+ const tags = presentTags();
+ if (event.key === nextKey && target === tags.at(-1)) {
+ return inputRef.current;
+ }
+ if (event.key === previousKey && target === tags[0]) {
+ event.preventDefault();
+ return null;
+ }
+ return defaultNext();
+ },
+ });
+
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
@@ -182,13 +209,13 @@ export const TagInputRoot = React.forwardRef(func
{useRender({
defaultTagName: 'div',
render,
- ref: forwardedRef,
+ ref: [setElement, forwardedRef],
state,
stateAttributesMapping: {
disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
empty: (v: boolean) => (v ? { 'data-empty': '' } : null),
},
- props: mergeProps<'div'>(defaultProps, otherProps),
+ props: mergeProps<'div'>({ ...defaultProps, onKeyDown: composite.handleKeyDown }, otherProps),
})}
);
diff --git a/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx b/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx
index bb863a7194f..08f9806a1cb 100644
--- a/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx
+++ b/packages/mosaic/src/primitives/tag-input/tag-input-tag.tsx
@@ -3,7 +3,6 @@
import { inertProps } from '@clerk/shared/inert';
import React, { useCallback, useEffect, useRef } from 'react';
-import { readingDirectionKeys, useDirection } from '../direction-provider';
import { useTransition } from '../hooks/use-transition';
import { type ComponentProps, mergeProps, useRender } from '../utils';
import { TagContext, useTagInputContext } from './tag-input-context';
@@ -15,8 +14,7 @@ export interface TagInputTagProps extends ComponentProps<'div'> {
export const TagInputTag = React.forwardRef(
function TagInputTag(props, forwardedRef) {
const { render, value: tagValue, ...otherProps } = props;
- const { value, tags, disabled, remove, focusInput, focusTag, registerTag, onTagExited } = useTagInputContext();
- const { previousKey, nextKey } = readingDirectionKeys(useDirection());
+ const { value, tags, disabled, remove, registerTag, onTagExited } = useTagInputContext();
const present = value.includes(tagValue);
const invalid = tags.find(tag => tag.value === tagValue)?.invalid ?? false;
@@ -53,40 +51,11 @@ export const TagInputTag = React.forwardRef(
if (event.target !== event.currentTarget) {
return;
}
- const index = value.indexOf(tagValue);
-
- switch (event.key) {
- case previousKey:
- event.preventDefault();
- focusTag(value[index - 1]);
- return;
- case nextKey: {
- event.preventDefault();
- const next = value[index + 1];
- if (next === undefined) {
- focusInput();
- } else {
- focusTag(next);
- }
- return;
+ if (event.key === 'Backspace' || event.key === 'Delete') {
+ event.preventDefault();
+ if (!disabled) {
+ remove(tagValue, event.key === 'Backspace' ? 'previous' : 'next');
}
- case 'Home':
- event.preventDefault();
- focusTag(value[0]);
- return;
- case 'End':
- event.preventDefault();
- focusTag(value[value.length - 1]);
- return;
- case 'Backspace':
- case 'Delete':
- event.preventDefault();
- if (!disabled) {
- remove(tagValue, event.key === 'Backspace' ? 'previous' : 'next');
- }
- return;
- default:
- return;
}
},
};
diff --git a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx
index ccac1b0fdaa..47d068bd3c6 100644
--- a/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx
+++ b/packages/mosaic/src/primitives/tag-input/tag-input.test.tsx
@@ -310,6 +310,13 @@ describe('TagInput', () => {
expect(input()).toHaveValue('x');
});
+ it('claims the previous key on the first tag, without letting it scroll the page', async () => {
+ render( );
+ tag('a').focus();
+ expect(fireEvent.keyDown(tag('a'), { key: 'ArrowLeft' })).toBe(false);
+ expect(tag('a')).toHaveFocus();
+ });
+
it('moves between tags and back to the input with arrow keys', async () => {
const user = userEvent.setup();
render( );