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/layer-rtl-popover-position.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] Layer/Popover: anchor-positioned popovers now mirror in RTL contexts. `placement`/`alignment` start/end are logical — resolved against the trigger's computed direction (CSS `direction` property or `dir` attribute) at open time — so DropdownMenu, MoreMenu, Selector, Typeahead, date inputs, and every other context-mode popover open toward the correct side under RTL instead of the physically-LTR side. An explicit `justify-self` is emitted in RTL to guard against engines that mis-resolve position-area's implied alignment via the popover's inherited direction (the "menu far from control" displacement in #3389). LTR output is byte-identical (#3389).
@AKnassa
35 changes: 35 additions & 0 deletions apps/storybook/stories/DropdownMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,38 @@ export const PlacementAbove: Story = {
/>
),
};

export const RTL: Story = {
render: () => (
<div style={{direction: 'rtl', display: 'flex', gap: '16px'}}>
<DropdownMenu
button={{label: 'CSS direction: rtl'}}
items={[
{label: 'Edit', onClick: () => console.log('Edit')},
{label: 'Duplicate', onClick: () => console.log('Duplicate')},
{label: 'Delete', onClick: () => console.log('Delete')},
]}
/>
<div dir="ltr">
<div dir="rtl">
<DropdownMenu
button={{label: 'dir="rtl" attribute'}}
items={[
{label: 'Edit', onClick: () => console.log('Edit')},
{label: 'Duplicate', onClick: () => console.log('Duplicate')},
{label: 'Delete', onClick: () => console.log('Delete')},
]}
/>
</div>
</div>
</div>
),
parameters: {
docs: {
description: {
story:
"In RTL contexts (CSS direction property or dir attribute) the menu right-edge-aligns to the trigger and grows toward the left — the logical mirror of the LTR default (#3389). Both direction mechanisms are shown; the fix resolves the trigger's computed direction at open time.",
},
},
},
};
3 changes: 3 additions & 0 deletions packages/core/src/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,9 @@ export function Carousel({

const coverStyle: React.CSSProperties = {
positionArea: 'center',
// Null the RTL justify-self useLayer may emit — the anchor-size cover
// must stay centered on the anchor with no self-alignment offset.
justifySelf: undefined,
width: 'anchor-size(width)',
height: 'anchor-size(height)',
};
Expand Down
107 changes: 106 additions & 1 deletion packages/core/src/DropdownMenu/DropdownMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ describe('DropdownMenu', () => {
// The popup exposes its own role="menu"; it must not be nested inside a
// modal dialog, which would announce an unnamed dialog around the menu
// while focus stays on the trigger.
expect(screen.queryByRole('dialog', {hidden: true})).not.toBeInTheDocument();
expect(
screen.queryByRole('dialog', {hidden: true}),
).not.toBeInTheDocument();
expect(
document.querySelector('[aria-modal="true"]'),
).not.toBeInTheDocument();
Expand Down Expand Up @@ -107,6 +109,54 @@ describe('DropdownMenu', () => {
);
});

it('mirrors menu placement under an RTL ancestor (#3389)', async () => {
const user = userEvent.setup();

// jsdom's getComputedStyle does not inherit direction from ancestors
// (descendants of a direction:rtl wrapper report ''), so a plain wrapper
// would silently exercise the LTR path. Delegate to the real
// implementation and override only `direction` for elements inside the
// RTL wrapper — everything else (visibility checks etc.) stays real.
const original = window.getComputedStyle;
let wrapper: HTMLElement | null = null;
const spy = vi
.spyOn(window, 'getComputedStyle')
.mockImplementation((el, pseudo) => {
const style = original(el, pseudo);
if (wrapper && el instanceof Element && wrapper.contains(el)) {
Object.defineProperty(style, 'direction', {
value: 'rtl',
configurable: true,
});
}
return style;
});

try {
const {container} = render(
<div style={{direction: 'rtl'}}>
<DropdownMenu
button={{label: 'Actions'}}
items={[{label: 'Item 1'}]}
/>
</div>,
);
wrapper = container.firstElementChild as HTMLElement;

// Direction is resolved at show time, so the menu must be opened.
await user.click(screen.getByRole('button', {name: /Actions/}));

const popover = screen
.getByRole('menu', {hidden: true})
.closest('[popover]');
const style = popover?.getAttribute('style') ?? '';
expect(style).toContain('position-area: bottom span-left');
expect(style).toContain('justify-self: right');
} finally {
spy.mockRestore();
}
});

it('has aria-haspopup and aria-expanded attributes', () => {
render(
<DropdownMenu button={{label: 'Actions'}} items={[{label: 'Item 1'}]} />,
Expand Down Expand Up @@ -232,6 +282,61 @@ describe('DropdownMenu controlled mode', () => {
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
});

it('mirrors menu placement when opened via isMenuOpen under an RTL ancestor (#3389)', () => {
// Same jsdom caveat as the click-path RTL test above: delegate to the
// real getComputedStyle and override only `direction` inside the wrapper.
const original = window.getComputedStyle;
let wrapper: HTMLElement | null = null;
const spy = vi
.spyOn(window, 'getComputedStyle')
.mockImplementation((el, pseudo) => {
const style = original(el, pseudo);
if (wrapper && el instanceof Element && wrapper.contains(el)) {
Object.defineProperty(style, 'direction', {
value: 'rtl',
configurable: true,
});
}
return style;
});

try {
const {container, rerender} = render(
<div style={{direction: 'rtl'}}>
<DropdownMenu
button={{label: 'Actions'}}
items={[{label: 'Item 1'}]}
isMenuOpen={false}
onOpenChange={() => {}}
/>
</div>,
);
wrapper = container.firstElementChild as HTMLElement;

// The controlled open runs show() from an effect (no click), which must
// resolve the trigger's direction the same way the click path does.
rerender(
<div style={{direction: 'rtl'}}>
<DropdownMenu
button={{label: 'Actions'}}
items={[{label: 'Item 1'}]}
isMenuOpen={true}
onOpenChange={() => {}}
/>
</div>,
);

const popover = screen
.getByRole('menu', {hidden: true})
.closest('[popover]');
const style = popover?.getAttribute('style') ?? '';
expect(style).toContain('position-area: bottom span-left');
expect(style).toContain('justify-self: right');
} finally {
spy.mockRestore();
}
});

it('calls onOpenChange when button is clicked', async () => {
const user = userEvent.setup();
const handleToggle = vi.fn();
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/HoverCard/HoverCard.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ export const docs = {
{
name: 'placement',
type: "'above' | 'below' | 'start' | 'end'",
description: 'Position relative to the anchor element.',
description: "Position relative to the anchor element. Logical: start/end resolve against the trigger's computed direction at open time (RTL mirrors).",
default: "'above'",
},
{
name: 'alignment',
type: "'start' | 'center' | 'end'",
description: 'Alignment along the placement axis.',
description: "Alignment along the placement axis. Logical: start/end resolve against the trigger's computed direction at open time (RTL mirrors).",
default: "'center'",
},
{
Expand Down Expand Up @@ -155,13 +155,13 @@ export const docsZh = {
{
name: 'placement',
type: "'above' | 'below' | 'start' | 'end'",
description: '相对于锚点元素的位置。',
description: '相对于锚点元素的位置。逻辑值:start/end 在打开时根据触发器的计算方向解析(RTL 镜像)。',
default: "'above'",
},
{
name: 'alignment',
type: "'start' | 'center' | 'end'",
description: '沿放置轴的对齐方式。',
description: '沿放置轴的对齐方式。逻辑值:start/end 在打开时根据触发器的计算方向解析(RTL 镜像)。',
default: "'center'",
},
{
Expand Down Expand Up @@ -246,8 +246,8 @@ export const docsDense = {
propDescriptions: {
children: 'Trigger element; must accept ref.',
content: 'Hover card content.',
placement: 'Position relative to anchor element.',
alignment: 'Alignment along placement axis.',
placement: 'Position relative to anchor element. Logical: start/end follow trigger computed direction (RTL mirrors).',
alignment: 'Alignment along placement axis. Logical: start/end follow trigger computed direction (RTL mirrors).',
delay: 'Show delay in ms.',
hideDelay: 'Hide delay in ms.',
focusTrigger: 'Controls when focus events trigger hover card.',
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/HoverCard/useHoverCard.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export const docs = {
group: 'HoverCard',
keywords: ['hovercard', 'hover', 'preview', 'card', 'tooltip', 'popup', 'floating', 'anchor'],
params: [
{name: 'placement', type: "'above' | 'below' | 'start' | 'end'", description: 'Position relative to the trigger.', default: "'above'"},
{name: 'alignment', type: "'start' | 'center' | 'end'", description: 'Alignment along the placement axis.', default: "'center'"},
{name: 'placement', type: "'above' | 'below' | 'start' | 'end'", description: "Position relative to the trigger. Logical: start/end resolve against the trigger's computed direction at open time (RTL mirrors).", default: "'above'"},
{name: 'alignment', type: "'start' | 'center' | 'end'", description: "Alignment along the placement axis. Logical: start/end resolve against the trigger's computed direction at open time (RTL mirrors).", default: "'center'"},
{name: 'delay', type: 'number', description: 'Delay before showing the hover card on hover, in milliseconds.', default: '300'},
{name: 'hideDelay', type: 'number', description: 'Delay before hiding after mouse or focus leaves, in milliseconds.', default: '200'},
{name: 'focusTrigger', type: "'auto' | 'always' | 'never'", description: 'When focus should open the hover card. auto only attaches focus listeners to naturally focusable elements.', default: "'auto'"},
Expand Down Expand Up @@ -46,8 +46,8 @@ export const docs = {
export const docsDense = {
description: 'Headless hover-triggered floating cards. Builds on useLayer w/ hover/focus intent, delays, safe hover behavior, aria-describedby. Use for rich previews when trigger/content rendering needs full control.',
paramDescriptions: {
placement: 'position relative to trigger.',
alignment: 'alignment along placement axis.',
placement: 'position relative to trigger. logical: start/end follow trigger computed direction (RTL mirrors).',
alignment: 'alignment along placement axis. logical: start/end follow trigger computed direction (RTL mirrors).',
delay: 'show delay in ms.',
hideDelay: 'hide delay after mouse/focus leave in ms.',
focusTrigger: 'when focus opens hover card; auto = naturally focusable elements only.',
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/Layer/useLayer.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const docs = {
name: 'render',
type: '(children: ReactNode, props: ContextRenderProps | FixedRenderProps) => ReactNode',
description:
'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode. In context mode, pass `as: "span"` to render an inline-safe layer (e.g. inside a paragraph). The layer renders inline in the React tree; the Popover API promotes it to the top layer when shown, so it escapes ancestor clipping and stacking without a portal.',
'Render function for the popover element. Pass placement/alignment in context mode or x/y in fixed mode. Placement/alignment are logical: start/end resolve against the trigger\'s computed direction at open time, so RTL contexts mirror automatically. In context mode, pass `as: "span"` to render an inline-safe layer (e.g. inside a paragraph). The layer renders inline in the React tree; the Popover API promotes it to the top layer when shown, so it escapes ancestor clipping and stacking without a portal.',
},
],
usage: {
Expand Down Expand Up @@ -130,7 +130,7 @@ export const docsDense = {
hide: 'hide layer.',
isOpen: 'whether layer is open.',
id: 'unique ARIA id.',
render: 'renders popover element; pass placement/alignment or x/y. Context mode accepts `as: "span"` for inline-safe layers. Renders inline; the Popover API top layer escapes clipping/stacking without a portal.',
render: 'renders popover element; pass placement/alignment or x/y. Placement/alignment logical: start/end resolve against trigger computed direction at open (RTL mirrors). Context mode accepts `as: "span"` for inline-safe layers. Renders inline; the Popover API top layer escapes clipping/stacking without a portal.',
},
usage: {
description:
Expand Down
Loading