Skip to content

Commit 7095120

Browse files
feat(mosaic): follow DirectionProvider for arrow-key navigation (#10420)
1 parent 4126767 commit 7095120

22 files changed

Lines changed: 213 additions & 55 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
Add a `direction` prop to `MosaicProvider` (`'ltr' | 'rtl'`, default `'ltr'`). Arrow-key navigation in tabs, segmented controls, one-time code inputs, tag inputs, and nested menus now follows this prop instead of the nearest `dir` attribute. Pages that render right-to-left should pass `direction="rtl"`.

‎packages/mosaic/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ State styling uses real class + attribute-selector specificity — no `&&` boost
7474

7575
## MosaicProvider
7676

77-
Mosaic components need no provider to render or to be styled — the stylesheet and the `--cl-*` tokens do that work. `MosaicProvider` exists for three things: per-name icon glyph overrides, localization, and the toast region (it mounts `ToastProvider`).
77+
Mosaic components need no provider to render or to be styled — the stylesheet and the `--cl-*` tokens do that work. `MosaicProvider` exists for four things: per-name icon glyph overrides, localization, the reading direction keyboard navigation follows (it mounts `DirectionProvider`), and the toast region (it mounts `ToastProvider`).
7878

7979
```tsx
8080
import { MosaicProvider } from './mosaic-provider';

‎packages/mosaic/docs/headless.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ Any new part with an exit transition needs one of the two;
133133
animated element). Add `cssVars()` to floating-ui middleware **after** `arrow()`,
134134
or the transform origin won't follow the arrow.
135135

136+
## Reading direction
137+
138+
Read the direction with `useDirection()` from `primitives/direction-provider`, never from the DOM
139+
`dir` attribute or computed style. It returns `ltr` outside a `DirectionProvider`. Use
140+
`readingDirectionKeys(direction)` for the previous and next arrow keys, or pass
141+
`rtl: direction === 'rtl'` to floating-ui's `Composite` or `useListNavigation`
142+
(`tabs/tabs-list.tsx`). `MosaicProvider` renders the `DirectionProvider` from its `direction` prop.
143+
136144
## Testing
137145

138146
Primitive tests run on **happy-dom** (the `primitives` project in Mosaic's

‎packages/mosaic/src/__tests__/mosaic-provider.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { MosaicIconOverrides } from '../icons/overrides';
66
import { useMosaicIcons } from '../icons/overrides';
77
import { useLocale, useMessages } from '../localization';
88
import { MosaicProvider } from '../mosaic-provider';
9+
import { useDirection } from '../primitives/direction-provider';
910

1011
describe('MosaicProvider icons', () => {
1112
it('exposes the icon overrides via useMosaicIcons', () => {
@@ -49,3 +50,19 @@ describe('MosaicProvider localization', () => {
4950
expect(result.current).toBe('de');
5051
});
5152
});
53+
54+
describe('MosaicProvider direction', () => {
55+
it('passes the direction prop to useDirection', () => {
56+
const { result } = renderHook(() => useDirection(), {
57+
wrapper: ({ children }) => React.createElement(MosaicProvider, { direction: 'rtl' }, children),
58+
});
59+
expect(result.current).toBe('rtl');
60+
});
61+
62+
it('defaults to ltr', () => {
63+
const { result } = renderHook(() => useDirection(), {
64+
wrapper: ({ children }) => React.createElement(MosaicProvider, {}, children),
65+
});
66+
expect(result.current).toBe('ltr');
67+
});
68+
});

‎packages/mosaic/src/components/segmented-control/segmented-control.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
33
import React from 'react';
44
import { describe, expect, it, vi } from 'vitest';
55

6+
import { DirectionProvider } from '../../primitives/direction-provider';
67
import { SegmentedControl } from './segmented-control';
78

89
function BillingPeriod(props: Partial<React.ComponentProps<typeof SegmentedControl.Root>>) {
@@ -58,9 +59,9 @@ describe('Mosaic SegmentedControl', () => {
5859

5960
it('flips the arrow keys in a right-to-left layout', async () => {
6061
render(
61-
<div dir='rtl'>
62+
<DirectionProvider direction='rtl'>
6263
<BillingPeriod defaultValue='monthly' />
63-
</div>,
64+
</DirectionProvider>,
6465
);
6566

6667
await userEvent.tab();

‎packages/mosaic/src/mosaic-provider.tsx‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import type { MosaicLocalization } from './localization';
88
import { MosaicLocalizationProvider, resolveLocalization } from './localization';
99
import type { ModalSurfaces } from './modals/modal.types';
1010
import { ModalHost } from './modals/modal-host';
11+
import type { TextDirection } from './primitives/direction-provider';
12+
import { DirectionProvider } from './primitives/direction-provider';
1113

1214
export interface MosaicProviderProps {
1315
children: React.ReactNode;
16+
/** Reading direction that arrow-key navigation follows. Set it to match the page's `dir`. Defaults to `ltr`. */
17+
direction?: TextDirection;
1418
/** Per-name icon glyph overrides, applied to every `<Icon>` below this provider. */
1519
icons?: MosaicIconOverrides;
1620
/** Locale and strings for everything Mosaic renders; see `MosaicLocalization`. */
@@ -20,21 +24,23 @@ export interface MosaicProviderProps {
2024
// Exported Mosaic components annotate a React return type on purpose. The package sets
2125
// `jsxImportSource: '@emotion/react'`, so an inferred return leaks Emotion's `JSX.Element` into the
2226
// published `.d.ts`, which React 19 consumers reject as a JSX element type.
23-
export function MosaicProvider({ children, icons, localization }: MosaicProviderProps): React.ReactElement {
27+
export function MosaicProvider({ children, direction, icons, localization }: MosaicProviderProps): React.ReactElement {
2428
const iconsValue = React.useMemo(() => icons ?? {}, [icons]);
2529
const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]);
2630
const [now] = React.useState(() => new Date());
2731

2832
return (
29-
<MosaicIconsProvider value={iconsValue}>
30-
<MosaicLocalizationProvider value={localizationValue}>
31-
<MosaicNowProvider value={now}>
32-
<ToastProvider>
33-
<ModalHost surfaces={modalSurfaces}>{children}</ModalHost>
34-
</ToastProvider>
35-
</MosaicNowProvider>
36-
</MosaicLocalizationProvider>
37-
</MosaicIconsProvider>
33+
<DirectionProvider direction={direction}>
34+
<MosaicIconsProvider value={iconsValue}>
35+
<MosaicLocalizationProvider value={localizationValue}>
36+
<MosaicNowProvider value={now}>
37+
<ToastProvider>
38+
<ModalHost surfaces={modalSurfaces}>{children}</ModalHost>
39+
</ToastProvider>
40+
</MosaicNowProvider>
41+
</MosaicLocalizationProvider>
42+
</MosaicIconsProvider>
43+
</DirectionProvider>
3844
);
3945
}
4046

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { renderHook } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { DirectionProvider, useDirection } from './index';
6+
7+
describe('DirectionProvider', () => {
8+
it('defaults to ltr without a provider', () => {
9+
const { result } = renderHook(() => useDirection());
10+
expect(result.current).toBe('ltr');
11+
});
12+
13+
it('defaults to ltr when no direction is given', () => {
14+
const { result } = renderHook(() => useDirection(), {
15+
wrapper: ({ children }) => <DirectionProvider>{children}</DirectionProvider>,
16+
});
17+
expect(result.current).toBe('ltr');
18+
});
19+
20+
it('provides the direction to descendants', () => {
21+
const { result } = renderHook(() => useDirection(), {
22+
wrapper: ({ children }) => <DirectionProvider direction='rtl'>{children}</DirectionProvider>,
23+
});
24+
expect(result.current).toBe('rtl');
25+
});
26+
27+
it('lets a nested provider override an outer one', () => {
28+
const { result } = renderHook(() => useDirection(), {
29+
wrapper: ({ children }) => (
30+
<DirectionProvider direction='rtl'>
31+
<DirectionProvider direction='ltr'>{children}</DirectionProvider>
32+
</DirectionProvider>
33+
),
34+
});
35+
expect(result.current).toBe('ltr');
36+
});
37+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client';
2+
3+
import type { ReactNode } from 'react';
4+
import React from 'react';
5+
6+
export type TextDirection = 'ltr' | 'rtl';
7+
8+
export interface DirectionProviderProps {
9+
/** The reading direction that keyboard navigation follows below this provider. Defaults to `ltr`. */
10+
direction?: TextDirection;
11+
children: ReactNode;
12+
}
13+
14+
const DirectionContext = React.createContext<TextDirection | undefined>(undefined);
15+
16+
export function DirectionProvider({ direction = 'ltr', children }: DirectionProviderProps): React.ReactElement {
17+
return <DirectionContext.Provider value={direction}>{children}</DirectionContext.Provider>;
18+
}
19+
20+
export function useDirection(): TextDirection {
21+
return React.useContext(DirectionContext) ?? 'ltr';
22+
}
23+
24+
export function readingDirectionKeys(direction: TextDirection) {
25+
return {
26+
previousKey: direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft',
27+
nextKey: direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight',
28+
};
29+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { DirectionProvider, readingDirectionKeys, useDirection } from './direction-provider';
2+
export type { DirectionProviderProps, TextDirection } from './direction-provider';

‎packages/mosaic/src/primitives/menu/menu-root.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '@floating-ui/react';
2626
import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react';
2727

28+
import { useDirection } from '../direction-provider';
2829
import { useControllableState } from '../hooks/use-controllable-state';
2930
import { useReturnFocus } from '../hooks/use-return-focus';
3031
import { useTransition } from '../hooks/use-transition';
@@ -71,6 +72,7 @@ function MenuInner(props: MenuProps) {
7172
// has a parent node id too, and treating that as nesting makes it hover-open, side-placed, and
7273
// unclickable by mouse.
7374
const isNested = parentId != null && parentContext != null;
75+
const direction = useDirection();
7476

7577
const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);
7678

@@ -179,6 +181,7 @@ function MenuInner(props: MenuProps) {
179181
listRef: elementsRef,
180182
activeIndex,
181183
nested: isNested,
184+
rtl: direction === 'rtl',
182185
onNavigate: setActiveIndex,
183186
});
184187
const typeahead = useTypeahead(floatingContext, {

0 commit comments

Comments
 (0)