Skip to content

feat(DropdownMenu): anchorRef places the menu against an element other than its trigger - #7352

Open
vjeux wants to merge 2 commits into
facebook:mainfrom
vjeux:dropdown-menu-anchor-ref
Open

vjeux wants to merge 2 commits into
facebook:mainfrom
vjeux:dropdown-menu-anchor-ref

Conversation

@vjeux

@vjeux vjeux commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

User need

A menu sometimes lines up with a box wider than its control: a filter menu under a whole search row, at its width. DropdownMenu anchors only to its trigger (renderTrigger's one ref is anchor, focus and press target), so callers change the design or host rows in a Popover, losing the press model and keyboard opens.

Problem and solution fit

  • Primary: DropdownMenu takes anchorRef, Popover's name for an external anchor. The pointer menu is placed against that element (placement, alignment, default width); the trigger keeps focus and focus return, the press model, the keyboard opens and the menu's name. The bottom sheet ignores it.
  • Supporting: docs, a story, proposed spec rows, a changeset.

Current specification

component:DropdownMenu is a draft; its proposed Menu anchor concept, FR16 and DEC-10 record the shape for the owner. DEC-3 (decided) sets what the trigger carries; component:Popover defines anchorRef. FR16 and DEC-10 also state what the owner rules on: the anchor is the popover's invoker too, the handed-back props carry no anchor (spec:AST-055 FR2), and an anchor unmounted mid-open leaves the menu unanchored until it renders and held until it closes (spec:AST-059 FR4, FR5, FR8).

Public delta and caller burden

  • One optional prop, anchorRef?: React.RefObject<HTMLElement | null>; nothing changes without it. menuWidth's default is the anchor's width. While the ref is empty, the trigger is the anchor.
const rowRef = useRef<HTMLDivElement>(null);
<div ref={rowRef}>
  <TextInput label="Search" isLabelHidden value={query} onChange={setQuery} />
  <DropdownMenu anchorRef={rowRef} alignment="end" button={{label: 'Filter'}} items={filters} />
</div>

Design and alternatives

  • Popover's and Tooltip's name and shape; TopNav's mega menu anchors to its nav while its button keeps the toggle.
  • Rejected (DEC-10): a second ref in DropdownMenuTriggerProps (the anchor is usually an ancestor the render prop does not render), an offset from the trigger, and Popover-hosted rows.

Evidence

  • Unit: the row carries the menu's anchor and the trigger none, for the Button and a custom trigger, which keep their wiring, toggle and keyboard open; the visibility observer (spec:AST-059 FR5) watches the row; empty, late and mid-open-unmounted anchors behave as FR16 states. Seven of nine cases fail on main.
  • Real Chromium, new story: the menu sits under the row at its width, inline ends aligned; a press on the row's field dismisses it; the trigger toggles; a keyboard open lands on the first row, Escape returns focus to the trigger, and Shift+Tab continues from it; a mouse press-down opens and a release acts. Placement fails against main.

Writing rubric results

R1 narration: the concept, FR16 and DEC-10 are present tense.
R2 research: none.
R3 evidence: FR16's verification row names the tests.
R4 rejected alternatives: DEC-10 lists five.
R5 change references: none.
R6 open questions: none.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

FR16/DEC-10 completed (invoker, unmount, AST-055/AST-059 departures) at the new head:
  vitest -t "DropdownMenu anchorRef": main's DropdownMenu.tsx 7 failed, 2 passed · with the change 9 passed
  vitest DropdownMenu, MoreMenu, Popover, Layer, ContextMenu, docProp*   Test Files 19 passed   Tests 601 passed
  playwright DropdownMenuAnchorRef (+ Shift+Tab), MenuPress, MenuHeaderFooter, DropdownMenuFocusReturn --project=chromium   29 passed
  typecheck, lint:strict, check:knowledge, storybook:build   ✓

Rebased onto main 250fd9ac19 as one commit (#7459 took DEC-9, so this record's decision is proposed DEC-10; FR16 unchanged):
  vitest DropdownMenu, MoreMenu, Popover, Layer, ContextMenu, docProp*   Test Files 19 passed   Tests 600 passed
  playwright DropdownMenuAnchorRef, MenuPress, MenuHeaderFooter, DropdownMenuFocusReturn --project=chromium   28 passed
  typecheck, lint:strict, check:knowledge, storybook:build   ✓

pnpm exec vitest run packages/core/src/DropdownMenu/DropdownMenu.test.tsx -t "DropdownMenu anchorRef"
  main's DropdownMenu.tsx: 6 failed, 2 passed (the trigger-anchor and empty-ref guards) · with the change: 8 passed
  (the observer case failed on 0c2fae6bdb; the empty-ref and appears-later cases on 1b66fd21de)

pnpm exec vitest run packages/core/src/DropdownMenu packages/core/src/ContextMenu packages/core/src/Popover \
  packages/core/src/Layer packages/core/src/Selector packages/core/src/Breadcrumbs \
  packages/core/src/docPropLiterals.test.ts packages/core/src/docPropReferences.test.ts --maxWorkers=10
  Test Files  25 passed (25)   Tests  849 passed (849)   (+1 memoized-menu case on the latest head: DropdownMenu and ContextMenu 291 passed)

pnpm storybook:build   (this branch; once with main's DropdownMenu.tsx for the red arm)
ASTRYX_STORYBOOK_DIR=<red build> pnpm exec playwright test \
  packages/core/src/DropdownMenu/__tests__/DropdownMenuAnchorRef.a11y.chromium.spec.ts --project=chromium
  1 failed, 3 passed   (placement: the menu sits under the trigger, inside the row)
pnpm exec playwright test DropdownMenuAnchorRef MenuHeaderFooter MenuPress DropdownMenuDrillIn (.a11y.chromium.spec.ts) --project=chromium
  28 passed · DropdownMenuAnchorRef --repeat-each=3: 12 passed

pnpm -F @astryxdesign/core typecheck, pnpm lint:strict, check:knowledge, storybook:build   ✓

@vercel

vercel Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 13, 2026 11:45pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 11, 2026
@vjeux
vjeux force-pushed the dropdown-menu-anchor-ref branch from 63b9cb4 to f0a9bab Compare October 11, 2026 21:28
@astracat-bot

astracat-bot Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 14, 2026, 12:07 AM UTC

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

44 of 448 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,188
Chat Full AI Chat probe light 238,081
Chat Full AI Chat probe dark 237,048
Markdown Default probe light 159,625
Markdown Default probe dark 158,986
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 33,205
Chat Default neutral light 32,876
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
TableRowExpansion Detail Panel neutral dark 5,150
TableRowExpansion Detail Panel neutral light 4,068

and 24 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so the new anchorRef prop and the proposed FR15 and DEC-8 have no approved spec behind them yet. A maintainer needs to approve or reject FR15 and DEC-8 as the governing spec for this addition, and settle the two points below. Separately, please fix one defect: with anchorRef, the new layout effect clears the anchor on every open before the layer checks whether it is in view, so anchored menus never get the off-screen-anchor hold of spec:AST-059 FR5/FR8, and a test asserting that the visibility observer watches the anchorRef element would lock the fix in.

  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, allows an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's referenced anchor also supplies the trigger.

[Automated review]

Comment on lines +1248 to +1256
useIsomorphicLayoutEffect(() => {
if (anchorRef == null) {
return;
}
setAnchor(anchorRef.current);
return () => {
setAnchor(null);
};
}, [anchorRef, setAnchor]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With anchorRef, this cleanup runs on every open. popover.triggerRef gets a new identity whenever the layer's isOpen changes, so React runs setAnchor(null) before the layer's layout effects re-run.

The layer's anchor-visibility effect is declared earlier in this component. It runs before this effect re-attaches the anchor, finds no anchor, and skips both the pre-paint read and the IntersectionObserver. As a result, spec:AST-059 FR5/FR8 (an off-screen anchor holds) never apply to anchored menus.

Suggested fix:

  • Attach the anchorRef element without clearing it on these re-runs, and detach only when anchorRef goes away or on unmount.
  • Resolve the element after mount: an ancestor's ref is still null when this layout effect first runs.
  • Add a case asserting that the observer watches the anchorRef element.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so the new anchorRef prop and the proposed FR16, DEC-9 and Menu anchor concept have no approved spec behind them yet, and a maintainer needs to approve or reject them and settle the two points below. Separately, please fix one defect whatever that decision is: the new layout effect clears the menu's anchor on every open before the layer checks whether the anchor is in view, so anchored menus never get the off-screen-anchor hold of spec:AST-059 FR5/FR8, and a test asserting that the visibility observer watches the anchorRef element would lock the fix in. The PR description still calls the proposal FR15 and DEC-8, which on this branch are the header and footer rows, so please update it.

  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's and Tooltip's referenced anchor is also the control.

[Automated review]

@vjeux

vjeux commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Fixed in 1b66fd2.

  • Off-screen anchor: confirmed. The anchor effect depended on the popover's triggerRef, whose identity follows the layer's open state, so every open cleared the anchor and set it again. Its cleanup ran before the layer's own layout effect read the anchor, so neither the first-frame visibility read nor the IntersectionObserver saw it. The anchor effects now run ahead of the popover in the hook order and read the latest setter through a ref, so the anchor stays set across opens. A new unit case stubs IntersectionObserver: the layer now watches the row, and withdraws the slide when the row leaves the viewport. It failed on the previous head (0c2fae6, nothing observed) and passes now. DropdownMenu, ContextMenu, Popover, Layer and Selector suites, typecheck, lint, knowledge and the Chromium anchor, header/footer, press and drill-in specs pass.
  • Description: it names FR16 and DEC-9 now (updated after the rebase past the header/footer change); the evidence and Testing sections are refreshed for this head.
  • AST-055 FR2 and the positioning-only meaning of anchorRef: those are for the record owner, so I've left them open.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so approval needs a maintainer to provide the governing spec for the new anchorRef prop, by approving or rejecting the proposed FR16, DEC-9 and Menu anchor concept, and to settle the two points below. The off-screen-anchor issue from the last round is fixed. Separately, please handle an anchorRef that is still empty when the menu opens: today the menu opens attached to nothing (at the page's top-left in Chromium) with no warning, so falling back to the trigger, as BaseTypeahead falls back to its input, or warning in development, plus a test, would close it.

  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's and Tooltip's referenced anchor is also the control.

[Automated review]

if (anchorRef == null) {
return;
}
const element = anchorRef.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When anchorRef.current is null here (for example, a ref on a component that doesn't forward it, or a conditionally rendered anchor), nothing is anchored: attachTrigger skips the trigger whenever anchorRef is set, so the layer's position-anchor names an anchor no element carries. In Chromium the menu then opens at the page's top-left, over other content, with no warning.

Falling back to the trigger while the element is absent, as BaseTypeahead falls back to its input, or warning in development, would avoid a silently broken state (spec:AST-002 FR15). A test for the empty-ref case would lock it in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 165c041. While anchorRef.current is null, the trigger is now the anchor, as BaseTypeahead falls back to its input. The menu moves to the caller's element once it's attached, so it's never placed against nothing. I chose the documented fallback over a development warning, per spec:AST-002 FR15: an anchor rendered only in some layouts is legitimate composition, and a warning would flag it. The prop doc, the changeset, FR16 and DEC-9 now say what an empty ref does. Two new unit cases: an empty ref keeps the menu on its trigger through an open, and an anchor that appears later takes over from the trigger. Both fail on 1b66fd2 and pass now. The DropdownMenu, ContextMenu, Popover, Layer and Selector suites, typecheck, lint, knowledge, the Storybook build and the Chromium anchor, header/footer, press and drill-in specs pass.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so approval needs a maintainer to provide the governing spec for the new anchorRef prop, by approving or rejecting the proposed FR16, DEC-9 and Menu anchor concept, and to settle the two points below. The empty-ref issue from the last round is fixed: an unattached anchorRef now leaves the menu on its trigger, and the new tests fail without that fallback. One optional follow-up: if the anchor element unmounts while the menu is open, the menu moves to its trigger but keeps the old element's off-screen hold until it closes, so either re-subscribing the layer's visibility observer when its anchor changes or noting this in FR16 would close it.

  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's and Tooltip's referenced anchor is also the control.

[Automated review]

if (anchorRef == null) {
return;
}
const element = anchorRef.current ?? buttonRef.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: when this re-points the anchor during an open (the referenced element unmounts or mounts while the menu is open), the layer's anchor-visibility observer keeps watching the element it subscribed to when the menu opened, because that effect only re-subscribes when the open state changes (useLayer.tsx, the anchor-visibility effect). In Chromium, after a sibling anchor unmounted mid-open, the menu correctly moved under the trigger but kept justify-self: unsafe self-end (the off-screen hold) while the trigger was in view, so the slide stays withdrawn until the menu closes. Narrow and self-correcting at the next open; re-subscribing the observer when the layer's anchor changes, or saying in FR16 that an anchor change during an open takes full effect at the next open, would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted in FR16 in 86a921a: an anchor change during an open moves the menu at once, and the off-screen hold (spec:AST-059 FR5) follows the new anchor from the next open. I kept the observer change out of this PR. The layer's visibility effect is shared by every layer, and re-subscribing it when the anchor changes is a separate fix to useLayer.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so approval needs a maintainer to provide the governing spec for the new anchorRef prop, by approving or rejecting the proposed FR16, DEC-9 and Menu anchor concept, and to settle the points below. Separately, please fix one small issue whatever that decision is: FR16's new sentence says an anchor change during an open moves the menu at once, but the menu only re-reads anchorRef when it renders, so with a sibling anchor beside a memoized menu it stays on its trigger when that anchor mounts mid-open and is left for a frame with no anchor, at the viewport's top-left, when it unmounts; narrowing FR16 and DEC-9 to what the code guarantees, or following the element without a menu render, plus a test, would close it.

  • Whether the off-screen hold may follow the previous anchor for the rest of an open under spec:AST-059 FR4, FR5 and FR7, or the layer must re-observe a changed anchor first.
  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's and Tooltip's referenced anchor is also the control.

[Automated review]

| 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 |
| 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 |
| FR15 | With `header` or `footer`, the pointer popover holds the header, then the `role="menu"` element, then the footer, and the menu element holds only its rows. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab from the menu moves to the footer, and Shift+Tab to the header, when that region holds a control Tab can reach; otherwise Tab closes the menu as before. Tab past the footer's last control or Shift+Tab before the header's first closes the menu, and focus returns to the trigger. From a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue as they do from a row of the root menu. Escape closes from anywhere in the popover. The bottom sheet shows the header above the actions and the footer below them. Without either, the menu renders and behaves exactly as it does without the props. | DEC-8, docs, and tests | Proposed; awaiting owner approval |
| FR16 | 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, or while its ref is empty, the trigger is the anchor. An anchor change during an open moves the menu at once; the off-screen hold (`spec:AST-059` FR5) follows it from the next open. The touch sheet ignores it. | Proposed DEC-9; `component:Popover` `anchorRef` | Proposed; verified in jsdom and Chromium, pending owner review |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first half of the new sentence holds only when the anchor change also re-renders the menu: the anchor effect in DropdownMenu.tsx runs on menu renders. With a sibling anchor beside a memoized menu, the menu stayed on its trigger in Chromium after the anchor mounted mid-open, and when the anchor unmounted it sat for one frame at the viewport's top-left, with no element carrying its anchor name, before the layer's observer re-rendered it onto the trigger. DEC-9's "never placed against nothing" has the same gap. Stating what the code guarantees (the menu reads anchorRef whenever it renders, including at every open), or following the element without depending on a menu render, would close it; a memoized-menu case would lock it in.

The second half (the hold follows the element observed at open) is a question for the maintainer under spec:AST-059 FR4, FR5 and FR7.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed in 37516c6. FR16 now says the menu reads anchorRef whenever it renders, every open included, rather than observing it, as Popover and BaseTypeahead read theirs. An anchor that mounts or unmounts between renders of the menu is followed at its next render, and the off-screen hold follows the anchor the menu opened against until it closes. DEC-9 drops "never placed against nothing": it now covers an empty ref when the menu renders, states the same read-on-render rule, and rejects observing the document for anchor changes. A new memoized-menu case puts a sibling anchor beside the menu and mounts it mid-open. The menu stays open and keeps an anchor, then follows the row at its next render. That case fails against main's DropdownMenu and passes now; the whole anchorRef suite is 8/8. The off-screen-hold question stays with the maintainer, as you noted.

if (anchorRef == null) {
return;
}
const element = anchorRef.current ?? buttonRef.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read happens only when DropdownMenuPopover renders. A caller whose anchor mounts or unmounts during an open without re-rendering the menu (for example, a sibling anchor beside a memo-wrapped menu) gets no update: the trigger keeps the anchor after a mount, and after an unmount the layer's position-anchor names an element that is no longer connected until something re-renders the menu (in Chromium the layer's IntersectionObserver does, a frame later). See the comment on FR16.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by narrowing FR16 and DEC-9 to this read-on-render behavior, in 37516c6 (see the FR16 thread). The code is unchanged: following the element without a render would mean a document-wide DOM observer on every open menu, which DEC-9 now lists as rejected. The memoized-menu case pins the guarantee.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so approval needs a maintainer to provide the governing spec for the new anchorRef prop, by approving or rejecting the proposed FR16, DEC-9 and Menu anchor concept, and to settle the points below. The narrowed FR16 and DEC-9 now match the code; if the maintainer holds spec:AST-059 FR8 for this case, the menu must not paint without an anchor when such an anchor unmounts mid-open, and if FR16 is accepted as written, the prop docs should carry its at-render wording too.

  • Whether FR16 may accept that, when an anchor rendered outside the menu unmounts during an open, the menu paints one frame at the viewport's top-left before moving to its trigger and keeps the old anchor's off-screen hold until it closes (spec:AST-059 FR4, FR5, FR7, FR8).
  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's and Tooltip's referenced anchor is also the control.

[Automated review]

| 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 |
| 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 |
| FR15 | With `header` or `footer`, the pointer popover holds the header, then the `role="menu"` element, then the footer, and the menu element holds only its rows. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab from the menu moves to the footer, and Shift+Tab to the header, when that region holds a control Tab can reach; otherwise Tab closes the menu as before. Tab past the footer's last control or Shift+Tab before the header's first closes the menu, and focus returns to the trigger. From a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue as they do from a row of the root menu. Escape closes from anywhere in the popover. The bottom sheet shows the header above the actions and the footer below them. Without either, the menu renders and behaves exactly as it does without the props. | DEC-8, docs, and tests | Proposed; awaiting owner approval |
| FR16 | 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, or while its ref is empty, the trigger is the anchor. The menu reads `anchorRef` whenever it renders, every open included, rather than observing it, as Popover and BaseTypeahead read theirs: an anchor that mounts or unmounts between renders of the menu is followed at its next render, and the off-screen hold (`spec:AST-059` FR5) follows the anchor the menu opened against until it closes. The touch sheet ignores it. | Proposed DEC-9; `component:Popover` `anchorRef` | Proposed; verified in jsdom and Chromium, pending owner review |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now matches the code. One consequence for whoever approves it: when an anchor rendered outside the menu unmounts during an open without re-rendering the menu, the layer has no connected anchor until that next render. On this revision, in Chromium, a memoized menu with a sibling anchor painted one frame at the viewport's top-left (x 16, y 4), then moved under the trigger with the off-screen hold (justify-self: unsafe self-end) set while the trigger was in view. Current spec:AST-059 FR8 ("A layer MUST NOT paint at a geometry it is about to correct") and FR4/FR5 reach every usePopover consumer through FR7 "with no record change", so accepting this is the maintainer's decision. The new memoized-menu test covers only the mount case.

name: 'anchorRef',
type: 'React.RefObject<HTMLElement | null>',
description:
"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. While the ref is empty (an element rendered only in some layouts, or a component that does not forward its ref), the trigger is the anchor. Ignored by bottom-sheet presentation.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If FR16's read-on-render rule is accepted, this sentence should carry it too: an anchor that unmounts while the menu is open empties the ref, but the trigger only becomes the anchor at the menu's next render.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 768353a: the prop's JSDoc, this doc entry and the changeset now carry FR16's read-on-render rule. The ref is read whenever the menu renders, every open included, as on Popover. While it is empty the trigger is the anchor, and an element that mounts or unmounts while the menu is open is followed at the menu's next render. The one-frame paint after an outside anchor unmounts mid-open stays with the maintainer, under spec:AST-059 FR8.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so approval needs a maintainer to provide the governing spec for the new anchorRef prop, by approving or rejecting the proposed FR16, DEC-9 and Menu anchor concept, and to settle the points below. The prop docs now carry FR16's read-on-render rule; any further change depends on that decision.

  • Whether FR16 may accept that, when an anchor rendered outside the menu unmounts while the menu is open, the menu has no anchor until its next render (one painted frame at the viewport's top-left in Chromium) and keeps the removed anchor's off-screen hold until it closes (spec:AST-059 FR4, FR5, FR7, FR8).
  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's and Tooltip's referenced anchor is also the element that opens the overlay.

[Automated review]

interaction fact. A menu that lines up with a box wider than its control, a
toolbar or a search row, needs the first without moving the second: the
trigger keeps focus and focus return, the press model, the keyboard opens and
the menu's name (DEC-3), and only placement, alignment and the default width

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With anchorRef applied, more than placement, alignment and the default width read the anchor: the layer also passes it to the browser as the popover's invoker source (useLayer.tsx, where the comment says the invoker sets the layer's sequential focus order and popover nesting), and its visibility observation, which FR16's off-screen-hold clause relies on, watches it too. If DEC-9 is accepted, this sentence should say so, so the approver sees the whole effect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4e0593f (amended from 53609d9 to add the visibility read). DEC-10 now says that placement, alignment, the default width and the layer's anchor-visibility read (spec:AST-059 FR5) follow the anchor. It also says the anchor is the popover's invoker (showPopover({source})), so popover nesting follows it. The invoker's sequential focus order never reaches the menu, because Tab closes it and returns focus to the trigger before the browser moves focus. A new Chromium case covers this: Shift+Tab lands on the field before the trigger. FR16 says the same in its contract clause. The other two points are in the PR comment above.

…r than its trigger

`anchorRef` (Popover's name and shape) moves the pointer menu's anchor off
the trigger: placement, alignment and the default width read the referenced
element, while the trigger keeps focus and focus return, the press model, the
keyboard opens and the menu's name. While the ref is empty the trigger is the
anchor. The ref is read whenever the menu renders, every open included; the
anchor effects run ahead of the layer's own layout effects, so the layer's
off-screen hold observes the anchor the menu opened against. The bottom sheet
ignores it.

The draft record gains the Menu anchor concept, FR16 and proposed DEC-10
(pending owner review). Tests: an anchorRef suite in DropdownMenu.test.tsx and
DropdownMenuAnchorRef.a11y.chromium.spec.ts; story AnchorRef.
@vjeux
vjeux force-pushed the dropdown-menu-anchor-ref branch from 768353a to 67a11b0 Compare October 13, 2026 22:01
@vjeux

vjeux commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main 250fd9a as one commit (67a11b0). #7459 recorded DEC-9 (the Menu Button trigger), so this PR's proposed decision is now DEC-10; the concept row and FR16 are unchanged, and the verification row sits in #7459's wider table. The code merged cleanly past #7382 and #7475. DropdownMenu, MoreMenu, Popover, Layer and ContextMenu suites pass (600 tests), as do 28 Chromium tests, typecheck, lint:strict, check:knowledge and the Storybook build.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so a maintainer needs to provide the governing spec for the new anchorRef prop by approving or rejecting the proposed Menu anchor concept, FR16 and DEC-10, and to settle the points below. Without anchorRef nothing changes and the new tests pass; whether anything else must change depends on that decision.

  • Whether FR16 may accept that, when an anchor rendered outside the menu unmounts while the menu is open, the menu has no anchor until its next render and keeps the removed anchor's off-screen hold until it closes (spec:AST-059 FR4, FR5, FR7, FR8).
  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may mean positioning-only here, when Popover's referenced anchor is also the control, and the element also becomes the popover's invoker, which DEC-10 does not mention.

[Automated review]

@vjeux

vjeux commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. 53609d9 writes the three points into the proposed text, so the ruling is on complete wording. No runtime code changed.

  1. FR16 now says it outright. An anchor that unmounts while the menu is open leaves the menu with no anchor until its next render, when the trigger becomes the anchor. The anchor-visibility read stays on the removed element, so the menu keeps its off-screen hold, without the FR4 slide, until it closes. DEC-10 names this as a departure from spec:AST-059 FR4, FR5 and FR8. It lists re-observing a changed anchor in the shared layer as a rejected alternative, alongside observing the document. A new unit case pins each step: no element carries the anchor after the unmount, the trigger carries it after the render, the observer still watches the removed row, and the hold stays.
  2. DEC-10 now states that with anchorRef the handed-back trigger props carry the focus-return target but not the anchor that spec:AST-055 FR2 places in them. The reason is that the anchor is usually an ancestor the render prop does not render.
  3. DEC-10 and FR16 now say the anchor is also the popover's invoker (showPopover({source})). The menu's popover nesting follows the anchor, which can differ from the trigger's only when the two sit in different open popovers. The sequential focus order never reaches the menu, because Tab closes it and returns focus to the trigger first. A new Chromium case checks this: Shift+Tab from the open menu lands on the Search field before the trigger, not before the row. In Chromium's accessibility tree the row gains no relation; only the trigger has hasPopup, expanded and controls. DEC-10 also notes that Popover's anchorRef names its control, while here the trigger stays the control.

The prop docs carry the unmount sentence. The anchorRef suite (7 of 9 fail on main), 601 unit tests, 29 Chromium tests, typecheck, lint:strict, check:knowledge and the Storybook build all pass.

…nmounted mid-open

The anchor is also the popover's invoker: the menu's popover nesting follows
it, while Tab still closes the menu to the trigger first, so the focus order
runs from the trigger (new Chromium case: Shift+Tab from the menu lands on the
field before the trigger). An anchor that unmounts while the menu is open
leaves it with no anchor until its next render, then on the trigger, with the
layer's visibility read still on the removed element, so the menu stays held
until it closes (new unit case). DEC-10 names the departures from AST-055 FR2
and AST-059 FR4/FR5/FR8, notes that Popover's anchorRef is also its control,
and adds the shared-layer re-observe alternative to the rejected list. The
prop docs carry the unmount sentence.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a maintainer decision before it can be approved: DropdownMenu.spec.md is still a draft, so a maintainer needs to provide the governing spec for the new anchorRef prop by approving or rejecting the proposed Menu anchor concept, FR16 and DEC-10, including the three departures they now state. Without anchorRef nothing changes and the new tests pass; whether anything else must change depends on that decision.

  • Whether the menu may accept that an anchor rendered outside it which unmounts while it is open leaves it unanchored until its next render and held in place until it closes, against spec:AST-059 FR4, FR5 and FR8.
  • Whether spec:AST-055 FR2, which says the trigger props carry the anchor the overlay positions against, admits an anchor supplied outside those props.
  • Whether anchorRef may name an element that anchors the menu and becomes its popover invoker while the trigger stays the control, when Popover's referenced anchor is also the control.

[Automated review]

Comment on lines +1014 to +1027
// An `anchorRef` (as on Popover) moves the menu's anchor off the trigger;
// the trigger keeps focus, the press model and the menu's name, and only
// positioning reads the anchor. These effects are declared before the
// popover so they run ahead of its layer's own layout effects in each
// commit: the layer reads the anchor as it opens (its first-frame
// visibility read and its observer, spec:AST-059 FR5/FR8), so the anchor
// must already be set then and must not be cleared and re-set around an
// open. The first follows the caller's element once it is attached (an
// ancestor's ref attaches after this component's effects) and whenever it
// changes; the second clears it when the menu unmounts or drops the ref.
// While the caller's element is absent (not yet attached, rendered only in
// some layouts, or a component that does not forward its ref), the trigger
// is the anchor, as BaseTypeahead falls back to its input, so the menu is
// never placed against nothing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this comment still says "only positioning reads the anchor" and that "the menu is never placed against nothing". On this revision FR16 and DEC-10 say the anchor is also the popover's invoker and the layer's visibility target, and that an anchor unmounting while the menu is open leaves it with no anchor until its next render; updating these lines would keep the code comment in step with the proposal.

This branch was successfully deployed

1 active deployment
Preview — 4e0593f5 Deployed Oct 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant