Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-composite-navigation.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
8 changes: 5 additions & 3 deletions packages/mosaic/docs/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions packages/mosaic/src/components/action-bar/action-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<div ref={anchor} />
<ActionBar.Root
open
anchor={anchor}
aria-label='Bulk actions'
>
<ActionBar.Action>Change role</ActionBar.Action>
<button type='button'>Plain</button>
<ActionBar.Action>Remove</ActionBar.Action>
</ActionBar.Root>
</>
);
}
const user = userEvent.setup();
render(<WithPlainControl />);
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(<BulkActions />);
Expand All @@ -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(<BulkActions />);
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() {
Expand Down Expand Up @@ -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 (
<>
<div ref={anchor} />
<ActionBar.Root
open
anchor={anchor}
aria-label='Bulk actions'
onFocus={onFocus}
onBlur={onBlur}
>
<ActionBar.Action>Remove</ActionBar.Action>
</ActionBar.Root>
<button type='button'>After</button>
</>
);
}
const user = userEvent.setup();
render(<Bar />);

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);
Expand Down
148 changes: 79 additions & 69 deletions packages/mosaic/src/components/action-bar/action-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {
autoUpdate,
Composite,
CompositeItem,
FloatingFocusManager,
getOverflowAncestors,
hide,
Expand All @@ -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';
Expand Down Expand Up @@ -76,19 +82,24 @@ const Root = React.forwardRef<HTMLDivElement, ActionBarRootProps>(function Actio
ref,
) {
const barRef = React.useRef<HTMLDivElement | null>(null);
const [bar, setBar] = React.useState<HTMLDivElement | null>(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('');
React.useLayoutEffect(() => {
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 ?? '');
Expand Down Expand Up @@ -194,53 +205,30 @@ const Root = React.forwardRef<HTMLDivElement, ActionBarRootProps>(function Actio
stylex.props(reset.base, styles.positioner, positionerXstyle),
)}
>
<Composite
orientation='horizontal'
loop
render={(compositeProps: React.HTMLAttributes<HTMLElement>) => {
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: (
<div
role='presentation'
onKeyDown={guardPortalledKeys}
{...stylex.props(styles.items)}
>
{open ? children : openChildrenRef.current}
</div>
),
},
});
<Bar
render={render}
barRefs={[barRef, setBar]}
onCompositeKeyDown={composite.handleKeyDown}
transitionStatus={transitionStatus}
open={open}
onFocusWithinChange={within => {
focusWithinRef.current = within;
}}
/>
{...mergeStyleProps(
themeProps('action-bar', { open }),
stylex.props(reset.base, styles.bar, xstyle),
rest,
)}
aria-describedby={countId}
>
<div
role='presentation'
onKeyDown={guardPortalledKeys}
{...stylex.props(styles.items)}
>
<CompositeItemsProvider value={items}>{heldChildren}</CompositeItemsProvider>
</div>
</Bar>
</div>
</FloatingFocusManager>
</Portal>
Expand All @@ -249,28 +237,50 @@ const Root = React.forwardRef<HTMLDivElement, ActionBarRootProps>(function Actio
);
});

interface BarProps extends MosaicComponentProps<'div'> {
barRefs: React.Ref<HTMLDivElement>[];
onCompositeKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
open: boolean;
transitionStatus: ReturnType<typeof useTransitionStatus>['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<HTMLButtonElement, ActionBarActionProps>(function ActionBarAction(
{ color, xstyle, ...rest },
ref,
) {
const compositeItem = useCompositeItem();
const mergedRef = useMergeRefs([compositeItem, ref]);
return (
<CompositeItem
render={(itemProps: React.HTMLAttributes<HTMLElement>) => {
const { ref: itemRef, ...merged } = mergeProps<'button'>(rest, itemProps);
return (
<Button
variant='ghost'
size='md'
color={color}
{...merged}
// 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.
ref={useMergeRefs<unknown>([ref, isRef(itemRef) ? itemRef : undefined])}
xstyle={[color === 'negative' && styles.destructive, xstyle]}
/>
);
}}
<Button
variant='ghost'
size='md'
color={color}
{...rest}
ref={mergedRef}
xstyle={[color === 'negative' && styles.destructive, xstyle]}
/>
);
});
Expand Down
Loading
Loading