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-rtl-mirror-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

Direction-aware icons (the forward chevrons on menu rows, the log-out arrow, and the previous/next arrows in paginated lists) now mirror horizontally when rendered under an ancestor with `dir="rtl"`.
13 changes: 11 additions & 2 deletions .claude/skills/mosaic/references/stylex.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,17 @@ rule reserves the `.stylex.ts` extension for StyleX define-primitives: **a
components render the same visual surface — e.g. inputs (`TextInput`,
`NumberInput`, date fields, `Selector`) sharing one `inputWrapper` /
`inputStatusBorder` / `inputStatusFocusWithin` set instead of redefining the
border/focus treatment five times. Three exist today: `reset.styles.ts`,
`typography.styles.ts` and `focus-outline.styles.ts`.
border/focus treatment five times. Four exist today: `reset.styles.ts`,
`typography.styles.ts`, `focus-outline.styles.ts` and `rtl.styles.ts`.
- **DO** compose `rtl.mirror` from `utils/rtl.styles.ts` onto a direction-aware
icon — a chevron that means "forward"/"back", a pagination arrow, the
log-out arrow leaving its frame — via its
`xstyle`. It flips the glyph with `scaleX(-1)` only under an ancestor carrying
`dir="rtl"`, so the same `chevron-right` reads as "forward" in both directions.
Pick it by meaning, not by shape: a chevron that points at a dropdown or an
external-link arrow stays unmirrored.
- **DON'T** register a mirrored twin (`chevron-forward`) in the icon registry or
swap the name at the call site by direction. The direction lives in CSS.
- **DON'T** put a shared style file under `components/`. That directory holds one
subdirectory per component and nothing else, so a loose file there reads as a
component that lost its folder.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Icon, IconFrame } from '../../../components/icon';
import { Item } from '../../../components/item';
import { Spinner } from '../../../components/spinner';
import { Text } from '../../../components/text';
import { rtl } from '../../../utils/rtl.styles';

const styles = stylex.create({
helpText: {
Expand Down Expand Up @@ -81,7 +82,16 @@ export function ReverificationMethodPicker({
<Item.Label>{method.label}</Item.Label>
{method.description ? <Item.Description>{method.description}</Item.Description> : null}
</Item.Content>
<Item.Actions>{isPending ? <Spinner size='sm' /> : <Icon name='chevron-right' />}</Item.Actions>
<Item.Actions>
{isPending ? (
<Spinner size='sm' />
) : (
<Icon
name='chevron-right'
xstyle={rtl.mirror}
/>
)}
</Item.Actions>
</Item.Root>
);
})}
Expand Down
3 changes: 3 additions & 0 deletions packages/mosaic/src/features/user-button/user-button.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { IconName } from '../../icons/registry';
import { applyOrder } from '../../utils/apply-order';
import { focusOutline } from '../../utils/focus-outline.styles';
import { fill, plural } from '../../utils/messages';
import { rtl } from '../../utils/rtl.styles';
import { truncationStyles } from '../../utils/typography.styles';
import type { UserButtonLayout } from './user-button.layout';
import { resolveUserButtonLayout } from './user-button.layout';
Expand Down Expand Up @@ -794,6 +795,7 @@ function SwitchAccountRow() {
<Trailing>
<Icon
name='chevron-right'
xstyle={rtl.mirror}
size='sm'
/>
</Trailing>
Expand Down Expand Up @@ -957,6 +959,7 @@ function Footer() {
<Icon
name='log-out'
size='sm'
xstyle={rtl.mirror}
/>
}
label={m.accounts.signOutAll}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Input } from '../../components/input';
import { Menu } from '../../components/menu';
import { Profile } from '../../components/profile';
import { mergeStyleProps, themeProps } from '../../props';
import { rtl } from '../../utils/rtl.styles';
import { styles } from './user-profile-api-keys-panel.styles';

export interface UserProfileAPIKey {
Expand Down Expand Up @@ -202,7 +203,10 @@ export function UserProfileApiKeysPanelView({
variant='ghost'
onClick={() => onPageChange?.(pagination.page - 1)}
>
<Icon name='chevron-left' />
<Icon
name='chevron-left'
xstyle={rtl.mirror}
/>
</Button>
<Button
aria-current='page'
Expand All @@ -225,7 +229,10 @@ export function UserProfileApiKeysPanelView({
variant='ghost'
onClick={() => onPageChange?.(pagination.page + 1)}
>
<Icon name='chevron-right' />
<Icon
name='chevron-right'
xstyle={rtl.mirror}
/>
</Button>
</div>
<label {...stylex.props(styles.pageSizeLabel)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Badge } from '../../components/badge';
import { Button } from '../../components/button';
import { Icon } from '../../components/icon';
import { Section } from '../../components/section';
import { rtl } from '../../utils/rtl.styles';
import { styles } from './user-profile-billing-history-section.styles';

export interface UserProfileBillingHistoryItem {
Expand Down Expand Up @@ -128,7 +129,10 @@ export function UserProfileBillingHistorySectionView({
variant='ghost'
onClick={() => onPageChange?.(pagination.page - 1)}
>
<Icon name='chevron-left' />
<Icon
name='chevron-left'
xstyle={rtl.mirror}
/>
</Button>
<Button
aria-current='page'
Expand All @@ -151,7 +155,10 @@ export function UserProfileBillingHistorySectionView({
variant='ghost'
onClick={() => onPageChange?.(pagination.page + 1)}
>
<Icon name='chevron-right' />
<Icon
name='chevron-right'
xstyle={rtl.mirror}
/>
</Button>
</div>
<label {...stylex.props(styles.pageSizeLabel)}>
Expand Down
7 changes: 7 additions & 0 deletions packages/mosaic/src/utils/rtl.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as stylex from '@stylexjs/stylex';

export const rtl = stylex.create({
mirror: {
transform: { default: null, ':is([dir="rtl"] *)': 'scaleX(-1)' },
},
});
35 changes: 35 additions & 0 deletions packages/mosaic/src/utils/rtl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as stylex from '@stylexjs/stylex';
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { Icon } from '../components/icon';
import { rtl } from './rtl.styles';

// StyleX generates the same atom for the same property+value across separate `create` calls, so a
// local probe names the atom to assert on without hardcoding a hash that a StyleX upgrade rewrites.
const probe = stylex.create({
mirrored: { transform: { default: null, ':is([dir="rtl"] *)': 'scaleX(-1)' } },
});

const atoms = (style: stylex.StyleXStyles) =>
(stylex.props(style).className ?? '').split(' ').filter(name => name && !name.includes('__'));

const mirrorAtom = atoms(probe.mirrored);

describe('Mosaic rtl styles', () => {
it('mirrors on the horizontal axis only under an rtl ancestor', () => {
expect(mirrorAtom).toHaveLength(1);
expect(atoms(rtl.mirror)).toEqual(mirrorAtom);
});

it('lands on an icon that composes it through xstyle', () => {
const { container } = render(
<Icon
name='chevron-right'
xstyle={rtl.mirror}
/>,
);

expect(container.querySelector('svg')).toHaveClass(...mirrorAtom);
});
});
1 change: 1 addition & 0 deletions packages/swingset/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ These require reading several files together; the `README.md` covers the step-by
- **Shared playground state.** `DocsViewer` wraps each overview in a `PlaygroundProvider` (`PlaygroundContext.tsx`), keyed by slug and seeded from the component's `meta` via `getModuleBySlug`. It owns the knob values (props). The `<Preview>` and the interactive `<PropTable>` both read/write this single context, so editing a prop in the table updates the preview above it.

- **Every story renders inside `MosaicProvider`.** `StoryPreview` (the MDX `<Preview>`) renders a named story with the playground's knob values as props and exposes a Reset button. `StoryEmbed` (the MDX `<Story>`) renders a single static variation with default knob values and no controls.
- The header's **RTL** switch (`DirectionToggle`, state in `DirectionProvider`) sets `dir` on `<html>`. It has to sit that high because popover and menu popups portal to `<body>`, so a `dir` on the preview container would never reach them; swingset's own chrome flips along with the story.

- **The prop table is the knob surface.** `PropTable` (MDX `<PropTable>`) derives rows from `meta.styles._variants`/`_defaultVariants`, then appends the `className` + `style` escape-hatch rows every Mosaic component accepts. Each variant row renders a `KnobControl` in its **Value** column, seeded with the prop's default and bound to the playground context. The escape-hatch rows and `extra` stay static.

Expand Down
73 changes: 39 additions & 34 deletions packages/swingset/src/components/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
import { getModule } from '@/lib/registry';

import { AppSidebar } from './app-sidebar';
import { DirectionProvider } from './DirectionProvider';
import { DirectionToggle } from './DirectionToggle';
import { ThemeToggle } from './ThemeToggle';

function useBreadcrumb() {
Expand All @@ -38,39 +40,42 @@ export function ClientRoot({ children }: { children: React.ReactNode }) {
const crumbs = useBreadcrumb();

return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className='bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4'>
<SidebarTrigger className='-ml-1' />
<Separator
orientation='vertical'
className='data-vertical:h-4 data-vertical:self-auto mr-2'
/>
{crumbs.length > 0 && (
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => (
<React.Fragment key={crumb}>
{i > 0 && <BreadcrumbSeparator className='hidden md:block' />}
<BreadcrumbItem className={i < crumbs.length - 1 ? 'hidden md:block' : undefined}>
{i < crumbs.length - 1 ? (
<BreadcrumbLink href='#'>{crumb}</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb}</BreadcrumbPage>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)}
<div className='ml-auto'>
<ThemeToggle />
</div>
</header>
{children}
</SidebarInset>
</SidebarProvider>
<DirectionProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className='bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4'>
<SidebarTrigger className='-ml-1' />
<Separator
orientation='vertical'
className='data-vertical:h-4 data-vertical:self-auto mr-2'
/>
{crumbs.length > 0 && (
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => (
<React.Fragment key={crumb}>
{i > 0 && <BreadcrumbSeparator className='hidden md:block' />}
<BreadcrumbItem className={i < crumbs.length - 1 ? 'hidden md:block' : undefined}>
{i < crumbs.length - 1 ? (
<BreadcrumbLink href='#'>{crumb}</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb}</BreadcrumbPage>
)}
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)}
<div className='ml-auto flex items-center gap-4'>
<DirectionToggle />
<ThemeToggle />
</div>
</header>
{children}
</SidebarInset>
</SidebarProvider>
</DirectionProvider>
);
}
25 changes: 25 additions & 0 deletions packages/swingset/src/components/DirectionProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import * as React from 'react';

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

interface DirectionContextValue {
direction: Direction;
setDirection: (direction: Direction) => void;
}

const DirectionContext = React.createContext<DirectionContextValue>({ direction: 'ltr', setDirection: () => {} });

export function DirectionProvider({ children }: { children: React.ReactNode }) {
const [direction, setDirection] = React.useState<Direction>('ltr');
React.useEffect(() => {
document.documentElement.dir = direction;
}, [direction]);
const value = React.useMemo(() => ({ direction, setDirection }), [direction]);
return <DirectionContext.Provider value={value}>{children}</DirectionContext.Provider>;
}

export function useDirection() {
return React.useContext(DirectionContext);
}
26 changes: 26 additions & 0 deletions packages/swingset/src/components/DirectionToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';

import { useDirection } from './DirectionProvider';

export function DirectionToggle() {
const { direction, setDirection } = useDirection();

return (
<div className='flex items-center gap-2'>
<Switch
id='direction-toggle'
checked={direction === 'rtl'}
onCheckedChange={checked => setDirection(checked ? 'rtl' : 'ltr')}
/>
<Label
htmlFor='direction-toggle'
className='text-muted-foreground cursor-pointer text-xs'
>
RTL
</Label>
</div>
);
}
2 changes: 1 addition & 1 deletion references/mosaic-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function

A theme's CSS wins over the Mosaic sheet without any prop: the sheet is imported into a cascade layer (`@import '@clerk/mosaic/styles.css' layer(components)`) and an unlayered rule beats any layered one.

`utils/reset.styles.ts` holds the per-element resets so a component does not re-declare UA-normalization; `utils/typography.styles.ts` and `utils/focus-outline.styles.ts` do the same for the treatments several components share.
`utils/reset.styles.ts` holds the per-element resets so a component does not re-declare UA-normalization; `utils/typography.styles.ts`, `utils/focus-outline.styles.ts` and `utils/rtl.styles.ts` do the same for the treatments several components share. `rtl.mirror` flips a direction-aware icon (a forward/back chevron, a pagination arrow) under a `dir="rtl"` ancestor; there are no mirrored twins in the icon registry.

For the full StyleX authoring rules (token usage, the local `s(n)` spacing helper, the CSS build), see the `mosaic` Claude Code skill's `references/stylex.md`.

Expand Down
Loading