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 + + 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 + + + + ); + } + 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 ( -