Skip to content
Merged
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-direction-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

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"`.
2 changes: 1 addition & 1 deletion packages/mosaic/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ State styling uses real class + attribute-selector specificity — no `&&` boost

## MosaicProvider

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`).
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`).

```tsx
import { MosaicProvider } from './mosaic-provider';
Expand Down
8 changes: 8 additions & 0 deletions packages/mosaic/docs/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ Any new part with an exit transition needs one of the two;
animated element). Add `cssVars()` to floating-ui middleware **after** `arrow()`,
or the transform origin won't follow the arrow.

## Reading direction

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`
(`tabs/tabs-list.tsx`). `MosaicProvider` renders the `DirectionProvider` from its `direction` prop.

## Testing

Primitive tests run on **happy-dom** (the `primitives` project in Mosaic's
Expand Down
17 changes: 17 additions & 0 deletions packages/mosaic/src/__tests__/mosaic-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { MosaicIconOverrides } from '../icons/overrides';
import { useMosaicIcons } from '../icons/overrides';
import { useLocale, useMessages } from '../localization';
import { MosaicProvider } from '../mosaic-provider';
import { useDirection } from '../primitives/direction-provider';

describe('MosaicProvider icons', () => {
it('exposes the icon overrides via useMosaicIcons', () => {
Expand Down Expand Up @@ -49,3 +50,19 @@ describe('MosaicProvider localization', () => {
expect(result.current).toBe('de');
});
});

describe('MosaicProvider direction', () => {
it('passes the direction prop to useDirection', () => {
const { result } = renderHook(() => useDirection(), {
wrapper: ({ children }) => React.createElement(MosaicProvider, { direction: 'rtl' }, children),
});
expect(result.current).toBe('rtl');
});

it('defaults to ltr', () => {
const { result } = renderHook(() => useDirection(), {
wrapper: ({ children }) => React.createElement(MosaicProvider, {}, children),
});
expect(result.current).toBe('ltr');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';

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

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

it('flips the arrow keys in a right-to-left layout', async () => {
render(
<div dir='rtl'>
<DirectionProvider direction='rtl'>
<BillingPeriod defaultValue='monthly' />
</div>,
</DirectionProvider>,
);

await userEvent.tab();
Expand Down
26 changes: 16 additions & 10 deletions packages/mosaic/src/mosaic-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import type { MosaicLocalization } from './localization';
import { MosaicLocalizationProvider, resolveLocalization } from './localization';
import type { ModalSurfaces } from './modals/modal.types';
import { ModalHost } from './modals/modal-host';
import type { TextDirection } from './primitives/direction-provider';
import { DirectionProvider } from './primitives/direction-provider';

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

return (
<MosaicIconsProvider value={iconsValue}>
<MosaicLocalizationProvider value={localizationValue}>
<MosaicNowProvider value={now}>
<ToastProvider>
<ModalHost surfaces={modalSurfaces}>{children}</ModalHost>
</ToastProvider>
</MosaicNowProvider>
</MosaicLocalizationProvider>
</MosaicIconsProvider>
<DirectionProvider direction={direction}>
<MosaicIconsProvider value={iconsValue}>
<MosaicLocalizationProvider value={localizationValue}>
<MosaicNowProvider value={now}>
<ToastProvider>
<ModalHost surfaces={modalSurfaces}>{children}</ModalHost>
</ToastProvider>
</MosaicNowProvider>
</MosaicLocalizationProvider>
</MosaicIconsProvider>
</DirectionProvider>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';

import { DirectionProvider, useDirection } from './index';

describe('DirectionProvider', () => {
it('defaults to ltr without a provider', () => {
const { result } = renderHook(() => useDirection());
expect(result.current).toBe('ltr');
});

it('defaults to ltr when no direction is given', () => {
const { result } = renderHook(() => useDirection(), {
wrapper: ({ children }) => <DirectionProvider>{children}</DirectionProvider>,
});
expect(result.current).toBe('ltr');
});

it('provides the direction to descendants', () => {
const { result } = renderHook(() => useDirection(), {
wrapper: ({ children }) => <DirectionProvider direction='rtl'>{children}</DirectionProvider>,
});
expect(result.current).toBe('rtl');
});

it('lets a nested provider override an outer one', () => {
const { result } = renderHook(() => useDirection(), {
wrapper: ({ children }) => (
<DirectionProvider direction='rtl'>
<DirectionProvider direction='ltr'>{children}</DirectionProvider>
</DirectionProvider>
),
});
expect(result.current).toBe('ltr');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import type { ReactNode } from 'react';
import React from 'react';

export type TextDirection = 'ltr' | 'rtl';

export interface DirectionProviderProps {
/** The reading direction that keyboard navigation follows below this provider. Defaults to `ltr`. */
direction?: TextDirection;
children: ReactNode;
}

const DirectionContext = React.createContext<TextDirection | undefined>(undefined);

export function DirectionProvider({ direction = 'ltr', children }: DirectionProviderProps): React.ReactElement {
return <DirectionContext.Provider value={direction}>{children}</DirectionContext.Provider>;
}

export function useDirection(): TextDirection {
return React.useContext(DirectionContext) ?? 'ltr';
}

export function readingDirectionKeys(direction: TextDirection) {
Comment thread
alexcarpenter marked this conversation as resolved.
return {
previousKey: direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft',
nextKey: direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight',
};
}
2 changes: 2 additions & 0 deletions packages/mosaic/src/primitives/direction-provider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { DirectionProvider, readingDirectionKeys, useDirection } from './direction-provider';
export type { DirectionProviderProps, TextDirection } from './direction-provider';
3 changes: 3 additions & 0 deletions packages/mosaic/src/primitives/menu/menu-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '@floating-ui/react';
import { type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react';

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

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

Expand Down Expand Up @@ -179,6 +181,7 @@ function MenuInner(props: MenuProps) {
listRef: elementsRef,
activeIndex,
nested: isNested,
rtl: direction === 'rtl',
onNavigate: setActiveIndex,
});
const typeahead = useTypeahead(floatingContext, {
Expand Down
38 changes: 38 additions & 0 deletions packages/mosaic/src/primitives/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DirectionProvider } from '../direction-provider';
import { Popover } from '../popover';
import { axe } from '../test-utils/axe';
import { Menu } from './index';
Expand Down Expand Up @@ -736,6 +737,43 @@ describe('Menu', () => {
expect(screen.getByText('Share')).toHaveAttribute('data-closed', '');
expect(screen.getByText('Actions')).toHaveAttribute('data-open', '');
});

it('opens and closes a submenu with the reading-direction arrow keys in RTL', async () => {
const user = userEvent.setup();
render(
<DirectionProvider direction='rtl'>
<Menu.Root>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Root>
<Menu.Trigger>Share</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Email'>Email</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
</DirectionProvider>,
);

await user.click(screen.getByText('Actions'));
await new Promise(r => requestAnimationFrame(r));
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowRight}');
expect(screen.getByText('Share')).not.toHaveAttribute('data-open');

await user.keyboard('{ArrowLeft}');
expect(screen.getByText('Share')).toHaveAttribute('data-open', '');

await new Promise(r => requestAnimationFrame(r));
await user.keyboard('{ArrowRight}');
expect(screen.getByText('Share')).toHaveAttribute('data-closed', '');
expect(screen.getByText('Actions')).toHaveAttribute('data-open', '');
});
});

describe('inside a popover', () => {
Expand Down
3 changes: 1 addition & 2 deletions packages/mosaic/src/primitives/otp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,7 @@ default element.
Focus can't skip past the first empty slot: clicking, arrowing, or tabbing onto an empty slot beyond
it snaps focus back to the first empty slot (so an empty field always focuses the first slot).

Arrow keys follow reading order. Under `dir="rtl"` (resolved from the nearest ancestor with an
explicit `dir`), `ArrowLeft` moves to the next slot and `ArrowRight` to the previous one; `Home`/`End`
Arrow keys follow reading order. Under `<DirectionProvider direction="rtl">`, `ArrowLeft` moves to the next slot and `ArrowRight` to the previous one; `Home`/`End`
and the `Ctrl`/`Cmd` boundary jumps stay logical (first / last-entered).

## Data Attributes
Expand Down
9 changes: 2 additions & 7 deletions packages/mosaic/src/primitives/otp/otp-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { useCallback } from 'react';

import { readingDirectionKeys, useDirection } from '../direction-provider';
import { type ComponentProps, mergeProps, useRender } from '../utils';
import { useOtpContext } from './otp-context';
import { inputModeForPattern, removeAt, replaceAt, sanitize } from './otp-utils';
Expand Down Expand Up @@ -31,6 +32,7 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
onSlotFocus,
onSlotBlur,
} = useOtpContext();
const { previousKey, nextKey } = readingDirectionKeys(useDirection());

const char = value[index] ?? '';

Expand Down Expand Up @@ -126,13 +128,6 @@ export const OtpInput = React.forwardRef<HTMLInputElement, OtpInputProps>(functi
// Ctrl/Cmd (without Alt) turns navigation and delete into boundary actions.
const boundaryModifier = (event.ctrlKey || event.metaKey) && !event.altKey;

// Resolve direction from the nearest ancestor with an explicit `dir` so
// arrow keys follow reading order: in RTL, Left moves to the next slot and
// Right to the previous one.
const isRtl = (event.currentTarget.closest('[dir]')?.getAttribute('dir') ?? '').toLowerCase() === 'rtl';
const previousKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
const nextKey = isRtl ? 'ArrowLeft' : 'ArrowRight';

if (event.key === previousKey) {
event.preventDefault();
focus(boundaryModifier ? 0 : index - 1);
Expand Down
13 changes: 7 additions & 6 deletions packages/mosaic/src/primitives/otp/otp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { DirectionProvider } from '../direction-provider';
import { axe } from '../test-utils/axe';
import { Otp } from './index';

Expand Down Expand Up @@ -239,15 +240,15 @@ describe('Otp', () => {
expect(inputs()[0]).toHaveFocus();
});

it('swaps ArrowLeft/ArrowRight under dir="rtl"', async () => {
it('swaps ArrowLeft/ArrowRight in RTL', async () => {
const user = userEvent.setup();
render(
<div dir='rtl'>
<DirectionProvider direction='rtl'>
<Harness
length={4}
defaultValue='1234'
/>
</div>,
</DirectionProvider>,
);

await user.click(inputs()[1]);
Expand All @@ -258,15 +259,15 @@ describe('Otp', () => {
expect(inputs()[1]).toHaveFocus();
});

it('swaps Ctrl/Cmd + arrows under dir="rtl"', async () => {
it('swaps Ctrl/Cmd + arrows in RTL', async () => {
const user = userEvent.setup();
render(
<div dir='rtl'>
<DirectionProvider direction='rtl'>
<Harness
length={4}
defaultValue='123'
/>
</div>,
</DirectionProvider>,
);

await user.click(inputs()[1]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Composite } from '@floating-ui/react';
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';

import { useDirection } from '../direction-provider';
import { useControllableState } from '../hooks/use-controllable-state';
import { type ComponentProps, isRef, mergeProps, useRender } from '../utils';
import {
Expand All @@ -25,7 +26,7 @@ export const SegmentedControlRoot = React.forwardRef<HTMLDivElement, SegmentedCo
const [value, setValue] = useControllableState(valueProp, defaultValue ?? '', onValueChange);
const [direction, setDirection] = useState<SegmentedControlDirection>('forward');
const [activeIndex, setActiveIndex] = useState(0);
const [rtl, setRtl] = useState(false);
const readingDirection = useDirection();
const rootRef = useRef<HTMLDivElement>(null);
const selectedIndexRef = useRef(-1);
const navigatedIndexRef = useRef<number | null>(null);
Expand Down Expand Up @@ -73,16 +74,13 @@ export const SegmentedControlRoot = React.forwardRef<HTMLDivElement, SegmentedCo
<Composite
orientation='horizontal'
loop={false}
rtl={rtl}
rtl={readingDirection === 'rtl'}
activeIndex={activeIndex}
onNavigate={onNavigate}
render={(compositeProps: React.HTMLAttributes<HTMLElement>) => {
const { onKeyDown: compositeKeyDown, ...otherCompositeProps } = compositeProps;
const defaultProps: Record<string, unknown> = {
role: 'radiogroup' as const,
onFocus: (event: React.FocusEvent<HTMLElement>) => {
setRtl((event.currentTarget.closest('[dir]')?.getAttribute('dir') ?? '').toLowerCase() === 'rtl');
},
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
navigatedIndexRef.current = -1;
if (event.key === 'Home' || event.key === 'End') {
Expand Down
Loading
Loading