Skip to content

Commit f0a9bab

Browse files
committed
feat(DropdownMenu): anchorRef places the menu against an element other than its trigger
A menu that lines up with a box wider than its control, a toolbar or a search row, could not: the layer anchored to the trigger, and renderTrigger's one ref was the anchor, the focus target and the press target at once. DropdownMenu now takes `anchorRef`, Popover's name and a ref object's shape: the popover menu is placed against that element, with placement and alignment read against it, and takes its width by default. The trigger keeps focus and focus return, the press model, the keyboard opens and the menu's name; only the layer's anchor moves, so a press elsewhere in the anchor still dismisses the menu. The bottom sheet ignores it. The draft DropdownMenu record proposes the Menu anchor concept, FR15 and DEC-8 for the owner. Docs, a story (a search row whose Filter menu lines up with the row), unit cases for the Button and a custom trigger, and a real-Chromium spec for placement, width, dismissal, toggle, keys, focus return and the press model.
1 parent 2e0dbb5 commit f0a9bab

7 files changed

Lines changed: 404 additions & 13 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astryxdesign/core': patch
3+
---
4+
5+
[feat] `DropdownMenu` takes `anchorRef`, Popover's prop for an external anchor: the popover menu is placed against that element, with `placement` and `alignment` read against it, and takes its width by default, instead of the trigger's. A menu can now line up with a toolbar or a search row wider than its control, while the trigger keeps focus and focus return, the press model, the keyboard opens and the menu's name. The bottom-sheet presentation ignores it.
6+
7+
@vjeux

‎apps/storybook/stories/DropdownMenu.stories.tsx‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type {Meta, StoryObj} from '@storybook/react';
44
import * as stylex from '@stylexjs/stylex';
55
import {Badge} from '@astryxdesign/core/Badge';
66
import {IconButton} from '@astryxdesign/core/IconButton';
7-
import {useState} from 'react';
7+
import {TextInput} from '@astryxdesign/core/TextInput';
8+
import {useRef, useState} from 'react';
89
import {
910
DropdownMenu,
1011
DropdownMenuItem,
@@ -96,6 +97,25 @@ const readinessStyles = stylex.create({
9697
},
9798
});
9899

100+
const anchorStoryStyles = stylex.create({
101+
// A search row: a field and its Filter trigger. The menu lines up with the
102+
// row, not with the trigger.
103+
row: {
104+
display: 'flex',
105+
alignItems: 'center',
106+
gap: spacingVars['--spacing-2'],
107+
inlineSize: 360,
108+
paddingBlockStart: spacingVars['--spacing-2'],
109+
paddingBlockEnd: spacingVars['--spacing-2'],
110+
paddingInlineStart: spacingVars['--spacing-2'],
111+
paddingInlineEnd: spacingVars['--spacing-2'],
112+
},
113+
field: {
114+
flexGrow: 1,
115+
minInlineSize: 0,
116+
},
117+
});
118+
99119
const PROJECT_ACTIONS = [
100120
{
101121
label: 'Edit project',
@@ -1341,6 +1361,51 @@ export const CustomTrigger: Story = {
13411361
},
13421362
};
13431363

1364+
export const AnchorRef: Story = {
1365+
name: 'Anchored to a row (anchorRef)',
1366+
render: function AnchorRefStory() {
1367+
const rowRef = useRef<HTMLDivElement>(null);
1368+
const [query, setQuery] = useState('');
1369+
const [showing, setShowing] = useState('Everything');
1370+
return (
1371+
<div>
1372+
<div
1373+
ref={rowRef}
1374+
data-testid="search-row"
1375+
{...stylex.props(anchorStoryStyles.row)}>
1376+
<div {...stylex.props(anchorStoryStyles.field)}>
1377+
<TextInput
1378+
label="Search"
1379+
isLabelHidden
1380+
placeholder="Search"
1381+
value={query}
1382+
onChange={setQuery}
1383+
/>
1384+
</div>
1385+
<DropdownMenu
1386+
anchorRef={rowRef}
1387+
alignment="end"
1388+
button={{label: 'Filter'}}
1389+
items={['Everything', 'Mine', 'Shared with me'].map(label => ({
1390+
label,
1391+
onClick: () => setShowing(label),
1392+
}))}
1393+
/>
1394+
</div>
1395+
<p data-testid="showing">Showing: {showing}</p>
1396+
</div>
1397+
);
1398+
},
1399+
parameters: {
1400+
docs: {
1401+
description: {
1402+
story:
1403+
"A menu that lines up with a box wider than its trigger: `anchorRef` names the search row, so the menu sits under the row with its inline end on the row's and takes the row's width. The Filter button keeps focus, the press model, the keyboard opens and the menu's name.",
1404+
},
1405+
},
1406+
},
1407+
};
1408+
13441409
export const LinkRows: Story = {
13451410
name: 'Rows that navigate (href)',
13461411
render: () => (

‎packages/core/src/DropdownMenu/DropdownMenu.doc.mjs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,13 @@ export const docs = {
219219
name: 'menuWidth',
220220
type: 'number | string',
221221
description:
222-
'Minimum width for the popover presentation. Length values may grow for content; intrinsic and CSS-wide keywords select the preferred inline size. Every form is capped to the available viewport space. Defaults to matching the trigger width up to that cap.',
222+
"Minimum width for the popover presentation. Length values may grow for content; intrinsic and CSS-wide keywords select the preferred inline size. Every form is capped to the available viewport space. Defaults to matching the anchor width up to that cap: the trigger's, or the anchorRef element's.",
223+
},
224+
{
225+
name: 'anchorRef',
226+
type: 'React.RefObject<HTMLElement | null>',
227+
description:
228+
"External ref to use as the menu's anchor, as on Popover. The popover presentation is placed against this element, with placement and alignment read against it, and takes its width by default, instead of the trigger's: a menu that lines up with a toolbar or a search row wider than its control. The trigger keeps focus and focus return, the press model, the keyboard opens and the menu's name. Ignored by bottom-sheet presentation.",
223229
},
224230
{
225231
name: 'menuMaxHeight',

‎packages/core/src/DropdownMenu/DropdownMenu.spec.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,14 @@ Consumer migration instructions belong in consumer docs and release notes.
8585
## Public concepts
8686

8787
Consumer props, item shapes, subcomponents, and presentation policy remain
88-
documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Two component-local concepts are added, by DEC-2 and DEC-3; each keeps its
88+
documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Three component-local concepts are added, by DEC-2, DEC-3 and proposed DEC-8; each keeps its
8989
released default.
9090

9191
| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
9292
| --------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -------- | ------------------------ | --------- | --------------------------------------------------- |
9393
| Menu height cap | `menuMaxHeight`: a number of pixels | Lifts the 300px cap for a menu that must fit its rows; the viewport still bounds it. | Pointer presentation | `300px` | `component:DropdownMenu` | stable | Ignored by the touch sheet. |
9494
| Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. |
95+
| Menu anchor | `anchorRef`: a ref to an element | The element the pointer menu is placed against and takes its default width from, instead of the trigger; the trigger keeps focus, the press model and the name. | Pointer presentation | Trigger | `component:DropdownMenu` | proposed | Ignored by the touch sheet. |
9596

9697
## Behavioral and layout contract
9798

@@ -114,6 +115,7 @@ released default.
114115
| FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Proposed in this change; `DropdownMenu.test.tsx` keyboard suite, `useListFocus.test.tsx`, `useTypeahead.test.tsx` | Proposed; verified in jsdom, pending owner review |
115116
| FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | Proposed in this change; `DropdownMenuSubMenu.test.tsx` safe-triangle suite, `useMenuHover.test.tsx` | Proposed; verified in jsdom, pending owner review |
116117
| FR14 | A compound `DropdownMenuGroup` is one `role="group"` named by its heading through `aria-labelledby`; the heading carries `dropdown-menu-section-heading`, is plain text rather than a menu item, and is skipped by roving focus and typeahead. An untitled group is an unnamed `role="group"`. | DEC-1, docs, and tests | Proposed; awaiting owner approval |
118+
| FR15 | With `anchorRef`, the pointer menu MUST be placed against the referenced element, with `placement` and `alignment` read against it and its default minimum width that element's width, still capped by the viewport, while the Trigger button keeps focus and focus return (FR6), the press model (FR8), the keyboard opens and the menu's name (FR9); a press on that element outside the trigger is a press outside the menu and dismisses it. Without it the trigger is the anchor. The touch sheet ignores it. | Proposed DEC-8; `component:Popover` `anchorRef` | Proposed; verified in jsdom and Chromium, pending owner review |
117119

118120
### Allowed variation
119121

@@ -276,6 +278,7 @@ than adding a DropdownMenu-owned heading target.
276278
| FR8, AR2 | `DropdownMenu.test.tsx` "DropdownMenuItem href" suite (pointer menu and bottom sheet) | Plain click, ⌘-click, Enter with modifiers, disabled link row | A row rendering an inner anchor, running `onClick` on a modified click, or a synthesized click dropping the modifiers fails. | `audit:DropdownMenu/behavior` |
277279
| FR12, AR3 | `DropdownMenuSubMenu.test.tsx` "drill-in on a phone" suite; `__tests__/DropdownMenuDrillIn.a11y.chromium.spec.ts` (real Chromium, coarse pointer) | Drill in, Back, Escape, ArrowLeft, nested level, scoped typeahead, forced presentations, close resets | A flyout opening on a coarse pointer, a missing Back row, sibling rows staying in the order, or focus not returning to the row fails. | `audit:DropdownMenu/behavior` |
278280
| FR10 | `DropdownMenuSubMenu.test.tsx` safe-triangle suite; `useMenuHover.test.tsx` `isPointInSafeTriangle` and click-guard suites | diagonal path toward the flyout, a pause inside it, path away, click-opened and hover-opened guard windows | A flyout that closes while the pointer is inside the triangle or paused in it, one that never closes after leaving it, or a changed guard-window rule, fails. | `audit:DropdownMenu/behavior` |
281+
| FR15 | `DropdownMenu.test.tsx` anchorRef suite; `__tests__/DropdownMenuAnchorRef.a11y.chromium.spec.ts` (real Chromium) | Button and custom trigger; place, width, toggle, field press, keys, Escape, press-drag; sheet | A menu placed against its trigger, an anchored trigger, a lost toggle, key or focus return, or a press in the anchor that leaves the menu open, fails. | `audit:DropdownMenu/behavior` |
279282

280283
## Decision log
281284

@@ -440,3 +443,24 @@ The value is a number of pixels. Rejected: an arbitrary CSS length (`50vh`,
440443
`calc(...)`), which is product-shaped tuning on a shared component that
441444
`spec:AST-002` does not admit, and which lets a caller write a cap the
442445
viewport term cannot reason about.
446+
447+
### DEC-8 (proposed) — A menu may anchor to an element other than its trigger
448+
449+
**Reference:** `component:DropdownMenu/DEC-8`
450+
451+
**Decider:** proposed by `vjeux`, 2026-10-11; pending owner review
452+
453+
Where a menu sits is a positioning fact; which control opens it is an
454+
interaction fact. A menu that lines up with a box wider than its control, a
455+
toolbar or a search row, needs the first without moving the second: the
456+
trigger keeps focus and focus return, the press model, the keyboard opens and
457+
the menu's name (DEC-3), and only placement, alignment and the default width
458+
read the anchor. Popover already names an external anchor `anchorRef`, and
459+
TopNav's mega menu anchors its panel to the nav while its button keeps the
460+
toggle, so the prop takes Popover's name and a ref object's shape. A press on
461+
the anchor outside the trigger stays outside the menu and dismisses it.
462+
463+
Rejected: a second ref in `DropdownMenuTriggerProps`, since the anchor is
464+
usually an ancestor the render prop does not render; an offset from the
465+
trigger, which breaks when the row resizes; and hosting the menu's rows in a
466+
Popover, which loses the menu's press model and keyboard opens.

‎packages/core/src/DropdownMenu/DropdownMenu.test.tsx‎

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {describe, it, expect, vi, beforeEach} from 'vitest';
1313
import {readFileSync} from 'node:fs';
1414
import {render, screen, fireEvent, waitFor, act} from '@testing-library/react';
1515
import userEvent from '@testing-library/user-event';
16-
import {useState} from 'react';
16+
import {useRef, useState} from 'react';
1717
import * as stylex from '@stylexjs/stylex';
1818
import {DropdownMenu} from './DropdownMenu';
1919
import {DropdownMenuItem} from './DropdownMenuItem';
@@ -23,6 +23,7 @@ import {Divider} from '../Divider';
2323
import {rtlStyles} from '../utils';
2424
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
2525
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
26+
import {readAnchorNames} from '../Layer/anchorName';
2627

2728
// Mock showPopover and hidePopover methods since they're not implemented in jsdom
2829
beforeEach(() => {
@@ -2987,6 +2988,130 @@ describe('DropdownMenu menuMaxHeight', () => {
29872988
});
29882989
});
29892990

2991+
describe('DropdownMenu anchorRef', () => {
2992+
const ITEMS = [{label: 'Mine'}, {label: 'Everyone'}];
2993+
2994+
function positionAnchorOf(menu: HTMLElement): string | undefined {
2995+
const layer = menu.closest('[popover]');
2996+
return /position-anchor:\s*([^;]+)/.exec(
2997+
layer?.getAttribute('style') ?? '',
2998+
)?.[1];
2999+
}
3000+
3001+
it('places the menu against the anchorRef element while the trigger keeps its wiring', async () => {
3002+
const user = userEvent.setup();
3003+
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
3004+
try {
3005+
function SearchRow() {
3006+
const rowRef = useRef<HTMLDivElement>(null);
3007+
return (
3008+
<div ref={rowRef} data-testid="row">
3009+
<input aria-label="Search" />
3010+
<DropdownMenu
3011+
anchorRef={rowRef}
3012+
alignment="end"
3013+
button={{label: 'Filter'}}
3014+
items={ITEMS}
3015+
/>
3016+
</div>
3017+
);
3018+
}
3019+
render(<SearchRow />);
3020+
const row = screen.getByTestId('row');
3021+
const trigger = screen.getByRole('button', {name: 'Filter'});
3022+
const menu = screen.getByRole('menu', {hidden: true});
3023+
// The row carries the menu's anchor name; the trigger carries none.
3024+
const [anchorName] = readAnchorNames(row);
3025+
expect(anchorName).toMatch(/^--astryx-layer-/);
3026+
expect(positionAnchorOf(menu)).toBe(anchorName);
3027+
expect(readAnchorNames(trigger)).toEqual([]);
3028+
// The trigger keeps the menu-button wiring.
3029+
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
3030+
expect(trigger).toHaveAttribute('aria-controls', menu.id);
3031+
3032+
await user.click(trigger);
3033+
await waitFor(() =>
3034+
expect(trigger).toHaveAttribute('aria-expanded', 'true'),
3035+
);
3036+
await user.keyboard('{Escape}');
3037+
await waitFor(() =>
3038+
expect(trigger).toHaveAttribute('aria-expanded', 'false'),
3039+
);
3040+
// The anchor stays on the row through the open and the close.
3041+
expect(readAnchorNames(row)).toEqual([anchorName]);
3042+
expect(readAnchorNames(trigger)).toEqual([]);
3043+
// The prop never reaches the DOM.
3044+
expect(menu).not.toHaveAttribute('anchorref');
3045+
expect(errors).not.toHaveBeenCalled();
3046+
} finally {
3047+
errors.mockRestore();
3048+
}
3049+
});
3050+
3051+
it('leaves a renderTrigger control its focus, keys and name, and the anchor to anchorRef', async () => {
3052+
const user = userEvent.setup();
3053+
function Toolbar() {
3054+
const barRef = useRef<HTMLDivElement>(null);
3055+
return (
3056+
<div ref={barRef} data-testid="bar">
3057+
<DropdownMenu
3058+
anchorRef={barRef}
3059+
renderTrigger={props => (
3060+
<button type="button" {...props}>
3061+
Owner
3062+
</button>
3063+
)}>
3064+
<DropdownMenuItem label="Mine" onClick={() => {}} />
3065+
</DropdownMenu>
3066+
</div>
3067+
);
3068+
}
3069+
render(<Toolbar />);
3070+
const bar = screen.getByTestId('bar');
3071+
const trigger = screen.getByRole('button', {name: 'Owner'});
3072+
const menu = screen.getByRole('menu', {hidden: true});
3073+
expect(positionAnchorOf(menu)).toBe(readAnchorNames(bar)[0]);
3074+
expect(readAnchorNames(trigger)).toEqual([]);
3075+
// The menu is still named by its control.
3076+
expect(menu).toHaveAttribute('aria-labelledby', trigger.id);
3077+
trigger.focus();
3078+
await user.keyboard('{ArrowDown}');
3079+
await waitFor(() =>
3080+
expect(
3081+
screen.getByRole('menuitem', {name: 'Mine', hidden: true}),
3082+
).toHaveFocus(),
3083+
);
3084+
});
3085+
3086+
it('without anchorRef the trigger stays the anchor', () => {
3087+
render(<DropdownMenu button={{label: 'Filter'}} items={ITEMS} />);
3088+
const trigger = screen.getByRole('button', {name: 'Filter'});
3089+
const menu = screen.getByRole('menu', {hidden: true});
3090+
const names = readAnchorNames(trigger);
3091+
expect(names).toHaveLength(1);
3092+
expect(positionAnchorOf(menu)).toBe(names[0]);
3093+
});
3094+
3095+
it('the bottom sheet ignores anchorRef', () => {
3096+
function SheetRow() {
3097+
const rowRef = useRef<HTMLDivElement>(null);
3098+
return (
3099+
<div ref={rowRef} data-testid="row">
3100+
<DropdownMenu
3101+
anchorRef={rowRef}
3102+
presentation="bottom-sheet"
3103+
button={{label: 'Filter'}}
3104+
items={ITEMS}
3105+
/>
3106+
</div>
3107+
);
3108+
}
3109+
render(<SheetRow />);
3110+
expect(readAnchorNames(screen.getByTestId('row'))).toEqual([]);
3111+
expect(document.querySelector('[anchorref]')).toBeNull();
3112+
});
3113+
});
3114+
29903115
describe('DropdownMenu focus return after a pointer pick', () => {
29913116
function renderMenu(onPick: (label: string) => void = () => {}) {
29923117
render(

0 commit comments

Comments
 (0)