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
6 changes: 6 additions & 0 deletions .changeset/calendar-rtl-nav-arrows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] Calendar: month navigation chevrons now mirror under `dir="rtl"` so "Previous month" points outward (visually right) and "Next month" points left, instead of both pointing inward at the month label. The mirror is a CSS-only StyleX conditional transform keyed on the `dir` attribute; DOM order, labels, and handlers are unchanged. Also fixes the embedded calendars in DateInput, DateRangeInput, and DateTimeInput (#3388).
@AKnassa
30 changes: 25 additions & 5 deletions apps/storybook/stories/Calendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,7 @@ export const Default: Story = {
render: () => {
const [value, setValue] = useState<ISODateString | undefined>(undefined);
return (
<Calendar
mode="single"
value={value}
onChange={val => setValue(val)}
/>
<Calendar mode="single" value={value} onChange={val => setValue(val)} />
);
},
};
Expand Down Expand Up @@ -186,6 +182,30 @@ export const MondayStart: Story = {
},
};

export const RTL: Story = {
render: () => {
const [value, setValue] = useState<ISODateString | undefined>(undefined);
return (
<div dir="rtl">
<Calendar
mode="single"
value={value}
onChange={val => setValue(val)}
focusDate="2026-01-01"
/>
</div>
);
},
parameters: {
docs: {
description: {
story:
'Under dir="rtl" the header flips: "Previous month" sits on the visual right with its chevron mirrored to point right (outward), "Next month" on the visual left pointing left (#3388).',
},
},
},
};

export const AllVariations: Story = {
render: () => {
const [singleValue, setSingleValue] = useState<ISODateString | undefined>(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Calendar/Calendar.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const docs = {
{guidance: false, description: 'Disable large blocks of dates without context. The user should understand why dates are unavailable.'},
],
anatomy: [
{name: 'Month header', required: true, description: 'The month name and year with navigation arrows to move between months.'},
{name: 'Month header', required: true, description: 'The month name and year with navigation arrows to move between months. The arrows mirror automatically under dir="rtl".'},
{name: 'Day grid', required: true, description: 'A 7-column grid of days with column headers for the day names.'},
{name: 'Selected day', required: false, description: 'The currently selected date, highlighted. In range mode, the start and end dates plus the days between them.'},
{name: 'Today marker', required: false, description: 'A subtle indicator on the current date for orientation.'},
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/Calendar/Calendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
import {describe, it, expect, vi} from 'vitest';
import {act, render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as stylex from '@stylexjs/stylex';
import {Calendar} from './Calendar';
import type {CalendarHandle} from './Calendar';
import {calendarStyles} from './styles';

/**
* Helper to find a day button by its day number.
Expand Down Expand Up @@ -596,4 +598,48 @@ describe('Calendar', () => {
}
}
});

// ─── RTL (#3388) ─────────────────────────────────────────────

describe('RTL month navigation', () => {
// jsdom does not apply compiled StyleX CSS, so the scaleX(-1) mirror
// itself is only observable in a browser (see the dir="rtl" Storybook
// story). These tests pin the structure the fix depends on: both nav
// chevrons render inside the navIcon wrapper that carries the
// ':is([dir="rtl"] *)' conditional transform.
it('wraps both nav chevrons in the RTL-mirroring navIcon wrapper', () => {
render(<Calendar focusDate="2026-01-01" />);

const {className: navIconClass} = stylex.props(calendarStyles.navIcon);
expect(navIconClass).toBeTruthy();

for (const name of ['Previous month', 'Next month']) {
const button = screen.getByRole('button', {name});
const wrappers = Array.from(button.querySelectorAll('span')).filter(
span => span.className === navIconClass,
);
expect(wrappers.length).toBe(1);
}
});

it('keeps navigation semantics unchanged under dir="rtl"', async () => {
const user = userEvent.setup();

render(
<div dir="rtl">
<Calendar focusDate="2026-02-01" />
</div>,
);

expect(screen.getByText('February 2026')).toBeInTheDocument();

// DOM order and handlers must not change in RTL: flexbox already
// places "Previous month" at the visual right; only the glyph mirrors.
await user.click(screen.getByRole('button', {name: 'Previous month'}));
expect(screen.getByText('January 2026')).toBeInTheDocument();

await user.click(screen.getByRole('button', {name: 'Next month'}));
expect(screen.getByText('February 2026')).toBeInTheDocument();
});
});
});
15 changes: 13 additions & 2 deletions packages/core/src/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,14 @@ export function Calendar({ref, ...props}: CalendarProps) {
<Button
label="Previous month"
variant="ghost"
icon={<Icon icon="chevronLeft" size="sm" color="inherit" />}
icon={
// Wrapper span (not Icon props): Icon's string mode clobbers
// caller classNames, so the RTL mirror must live on its own
// element.
<span {...stylex.props(calendarStyles.navIcon)}>
<Icon icon="chevronLeft" size="sm" color="inherit" />
</span>
}
onClick={() => navigateMonth(-1)}
isDisabled={!canNavigatePrevious}
isIconOnly
Expand All @@ -438,7 +445,11 @@ export function Calendar({ref, ...props}: CalendarProps) {
<Button
label="Next month"
variant="ghost"
icon={<Icon icon="chevronRight" size="sm" color="inherit" />}
icon={
<span {...stylex.props(calendarStyles.navIcon)}>
<Icon icon="chevronRight" size="sm" color="inherit" />
</span>
}
onClick={() => navigateMonth(1)}
isDisabled={!canNavigateNext}
isIconOnly
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/Calendar/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,19 @@ export const calendarStyles = stylex.create({
whiteSpace: 'nowrap',
borderWidth: 0,
},
/**
* Wrapper for the month nav chevrons. In RTL the flex header already
* swaps the buttons' visual sides; the glyphs must mirror with them so
* "Previous month" points outward (visually right) instead of inward.
* Keyed on the dir attribute (dir="rtl"), same as MobileNav's drawer
* transform — bare CSS `direction: rtl` alone won't trigger it.
*/
navIcon: {
width: spacingVars['--spacing-4'],
height: spacingVars['--spacing-4'],
display: 'inline-flex',
transform: {
default: null,
':is([dir="rtl"] *)': 'scaleX(-1)',
},
},
});

Expand Down