You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f0a9bab
Browse filesBrowse the repository at this point in the historyBrowse files
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.
[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.
// 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
+
99
119
constPROJECT_ACTIONS=[
100
120
{
101
121
label: 'Edit project',
@@ -1341,6 +1361,51 @@ export const CustomTrigger: Story = {
1341
1361
},
1342
1362
};
1343
1363
1364
+
exportconstAnchorRef: Story={
1365
+
name: 'Anchored to a row (anchorRef)',
1366
+
render: functionAnchorRefStory(){
1367
+
constrowRef=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
+
<pdata-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.",
Copy file name to clipboardExpand all lines: packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
+7-1Lines changed: 7 additions & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -219,7 +219,13 @@ export const docs = {
219
219
name: 'menuWidth',
220
220
type: 'number | string',
221
221
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.",
Copy file name to clipboardExpand all lines: packages/core/src/DropdownMenu/DropdownMenu.spec.md
+25-1Lines changed: 25 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -85,13 +85,14 @@ Consumer migration instructions belong in consumer docs and release notes.
85
85
## Public concepts
86
86
87
87
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-2and 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
89
89
released default.
90
90
91
91
| Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
| 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. |
94
94
| 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. |
95
96
96
97
## Behavioral and layout contract
97
98
@@ -114,6 +115,7 @@ released default.
114
115
| 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 |
115
116
| 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 |
116
117
| 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 |
117
119
118
120
### Allowed variation
119
121
@@ -276,6 +278,7 @@ than adding a DropdownMenu-owned heading target.
276
278
| 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`|
277
279
| 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`|
278
280
| 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`|
279
282
280
283
## Decision log
281
284
@@ -440,3 +443,24 @@ The value is a number of pixels. Rejected: an arbitrary CSS length (`50vh`,
440
443
`calc(...)`), which is product-shaped tuning on a shared component that
441
444
`spec:AST-002` does not admit, and which lets a caller write a cap the
442
445
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.
0 commit comments