From 64251fcb36b4ce6c51b1ab5b07244cc1a78e99d4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Nov 2026 07:56:31 -0500 Subject: [PATCH 1/3] refactor(mosaic): replace Floating UI Composite with useComposite --- .changeset/mosaic-composite-navigation.md | 5 + packages/mosaic/AGENTS.md | 1 + packages/mosaic/docs/headless.md | 8 +- .../components/action-bar/action-bar.test.tsx | 34 + .../src/components/action-bar/action-bar.tsx | 141 +++-- .../primitives/accordion/accordion-root.tsx | 60 +- .../accordion/accordion-trigger.tsx | 83 +-- .../primitives/accordion/accordion.test.tsx | 66 ++ .../hooks/__tests__/use-composite.test.tsx | 598 ++++++++++++++++++ packages/mosaic/src/primitives/hooks/index.ts | 11 + .../src/primitives/hooks/use-composite.ts | 324 ++++++++++ .../segmented-control-item.tsx | 71 +-- .../segmented-control-root.tsx | 106 ++-- .../src/primitives/table/table.test.tsx | 44 ++ .../primitives/table/use-grid-navigation.ts | 234 +++---- .../mosaic/src/primitives/tabs/tabs-list.tsx | 63 +- .../mosaic/src/primitives/tabs/tabs-tab.tsx | 120 ++-- .../mosaic/src/primitives/tabs/tabs.test.tsx | 90 +++ .../primitives/tag-input/tag-input-root.tsx | 31 +- .../primitives/tag-input/tag-input-tag.tsx | 41 +- .../primitives/tag-input/tag-input.test.tsx | 7 + 21 files changed, 1575 insertions(+), 563 deletions(-) create mode 100644 .changeset/mosaic-composite-navigation.md create mode 100644 packages/mosaic/src/primitives/hooks/__tests__/use-composite.test.tsx create mode 100644 packages/mosaic/src/primitives/hooks/use-composite.ts diff --git a/.changeset/mosaic-composite-navigation.md b/.changeset/mosaic-composite-navigation.md new file mode 100644 index 00000000000..7ef3135be60 --- /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. diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index ffb60db78fe..218feef811c 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -56,6 +56,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | | 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/`) | | 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..a1c9a53d65d 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() { diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index 2d97a731238..1c78f97c68c 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,9 +14,16 @@ 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'; @@ -76,6 +81,13 @@ 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); @@ -194,53 +206,32 @@ 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} + > +
+ + {open ? children : openChildrenRef.current} + +
+
@@ -249,28 +240,48 @@ 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 }), + ...(transitionStatus === 'starting' ? { 'data-starting-style': '', style: { transition: 'none' } } : null), + ...(transitionStatus === 'ending' ? { 'data-ending-style': '' } : null), + 'data-open': open, + role: 'toolbar', + 'aria-orientation': 'horizontal', + onFocus: () => onFocusWithinChange(true), + onBlur: () => onFocusWithinChange(false), + }, + }); +} + 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 ( -