diff --git a/.changeset/dropdown-menu-anchor-ref.md b/.changeset/dropdown-menu-anchor-ref.md new file mode 100644 index 0000000000000..c0c40339d616e --- /dev/null +++ b/.changeset/dropdown-menu-anchor-ref.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[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 ref is read whenever the menu renders; while it is empty, the trigger is the anchor. The bottom-sheet presentation ignores it. + +@vjeux diff --git a/apps/storybook/stories/DropdownMenu.stories.tsx b/apps/storybook/stories/DropdownMenu.stories.tsx index 93639a6f58308..c8bbdae9ad1d7 100644 --- a/apps/storybook/stories/DropdownMenu.stories.tsx +++ b/apps/storybook/stories/DropdownMenu.stories.tsx @@ -4,7 +4,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import * as stylex from '@stylexjs/stylex'; import {Badge} from '@astryxdesign/core/Badge'; import {IconButton} from '@astryxdesign/core/IconButton'; -import {useState} from 'react'; +import {useRef, useState} from 'react'; import { DropdownMenu, DropdownMenuItem, @@ -99,6 +99,25 @@ const readinessStyles = stylex.create({ }, }); +const anchorStoryStyles = stylex.create({ + // A search row: a field and its Filter trigger. The menu lines up with the + // row, not with the trigger. + row: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-2'], + inlineSize: 360, + paddingBlockStart: spacingVars['--spacing-2'], + paddingBlockEnd: spacingVars['--spacing-2'], + paddingInlineStart: spacingVars['--spacing-2'], + paddingInlineEnd: spacingVars['--spacing-2'], + }, + field: { + flexGrow: 1, + minInlineSize: 0, + }, +}); + const PROJECT_ACTIONS = [ { label: 'Edit project', @@ -1402,6 +1421,51 @@ export const CustomTrigger: Story = { }, }; +export const AnchorRef: Story = { + name: 'Anchored to a row (anchorRef)', + render: function AnchorRefStory() { + const rowRef = useRef(null); + const [query, setQuery] = useState(''); + const [showing, setShowing] = useState('Everything'); + return ( +
+
+
+ +
+ ({ + label, + onClick: () => setShowing(label), + }))} + /> +
+

Showing: {showing}

+
+ ); + }, + parameters: { + docs: { + description: { + story: + "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.", + }, + }, + }, +}; + export const LinkRows: Story = { name: 'Rows that navigate (href)', render: () => ( diff --git a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs index 949d2c551a941..004fd3f83efa1 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs +++ b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs @@ -219,7 +219,13 @@ export const docs = { name: 'menuWidth', type: 'number | string', description: - '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.', + "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.", + }, + { + name: 'anchorRef', + type: 'React.RefObject', + 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. The ref is read whenever the menu renders, every open included, as on Popover: while it is empty (an element rendered only in some layouts, or a component that does not forward its ref), the trigger is the anchor, and an element that mounts while the menu is open is followed at the menu's next render. One that unmounts while the menu is open leaves it with no anchor until then, and the menu no longer slides to stay inside the viewport until it closes. Ignored by bottom-sheet presentation.", }, { name: 'menuMaxHeight', diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index d1091b9a15444..3add284708ad6 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -92,7 +92,7 @@ Consumer migration instructions belong in consumer docs and release notes. ## Public concepts Consumer props, item shapes, subcomponents, and presentation policy remain -documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Four component-local concepts are added, by DEC-2, DEC-3, DEC-8 and proposed DEC-13; each +documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Five component-local concepts are added, by DEC-2, DEC-3, DEC-8, proposed DEC-12 and proposed DEC-13; each keeps its released default. | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior | @@ -100,6 +100,7 @@ keeps its released default. | 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. | | 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. | | Header and footer | `header`, `footer`: any React node | Content above and below the rows, beside the menu inside the same popover or sheet — never inside the `role="menu"` element. With either, the pointer popover is a modal dialog around the menu. | Both presentations | none | `component:DropdownMenu` | stable | A false or empty value renders no region. | +| 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. | | Sub-menu regions | `header`, `footer` on a sub-menu: any React node | Content above and below a sub-menu's rows, beside its menu inside its own surface; with either, that surface is a modal dialog named by the row (proposed FR20). | Popover presentation | none | `component:DropdownMenu` | proposed | A false or empty value renders no region. | ## Behavioral and layout contract @@ -126,6 +127,7 @@ keeps its released default. | FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. 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 and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10, docs, and tests | Proposed; awaiting owner approval | | FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved | | FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 | +| FR19 | With `anchorRef`, the pointer menu MUST be placed against the referenced element: `placement` and `alignment` read against it, its default minimum width is that element's, viewport-capped, and its off-screen hold (`spec:AST-059` FR5) reads that element's visibility. The Trigger button keeps focus and its return (FR6), the press model (FR8), keyboard opens and the menu's name (FR9); a press on the element outside the trigger dismisses it. Without it, or with its ref empty, the trigger is the anchor. The element is also the popover's invoker, so popover nesting follows it; DropdownMenu gives it no role, state or relation for assistive technology (the trigger has them), and its focus order never reaches the menu: Tab closes it and returns focus to the trigger first or, with `header` or `footer`, wraps in its dialog (FR15). The menu reads `anchorRef` on every render, as Popover and BaseTypeahead do, so an anchor mounted between renders is followed at the next one. An anchor that unmounts mid-open leaves the menu unanchored until its next render, then on the trigger, with the FR5 hold, not the FR4 slide, on the removed element until it closes. The touch sheet ignores it. | Proposed DEC-12; `component:Popover` `anchorRef` | Proposed; verified in jsdom and Chromium, pending owner review | | FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review | ### Allowed variation @@ -339,6 +341,7 @@ than adding a DropdownMenu-owned heading target. | FR15 | `DropdownMenu.test.tsx` header and footer suite; `__tests__/MenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium | dialog role, modality, name and trigger wiring; regions outside the menu element; typing in a header field; Tab walk header → menu → footer with wrap at both ends; Tab and Shift+Tab from an open sub-menu; hidden and hidden-wrapper controls; Escape from the header; a finger tap on a footer button; bottom sheet order; no regions | A region rendered inside the menu element, a popover with regions that is not a named modal dialog, a trigger still saying `aria-haspopup="menu"` with regions, a header key taken by typeahead, a Tab that leaves the dialog or closes it, focus stranded outside the dialog, or a footer tap swallowed by the press model fails. | | FR16 | `DropdownMenuSelectable.test.tsx`, source inspection, scoped `pr-a11y` over the `a11y/Checkbox pattern` stories, and owned Storybook states | checked, unchecked, disabled, caller click/pointer handlers, marker size and ownership | A dropped consumer handler, wrong checked state, nested semantic checkbox, disabled activation, or menu that closes by default fails. | `audit:DropdownMenuCheckboxItem/behavior` | | FR17, AR6 | Shared navigation source and DropdownMenu keyboard tests | disabled checkbox row first, middle, and last | Current tests prove the shared hook skips the row; no keyboard-discoverability proof exists until OQ1 is settled. | `audit:DropdownMenuCheckboxItem/accessibility` | +| FR19 | `DropdownMenu.test.tsx` anchorRef suite; `__tests__/DropdownMenuAnchorRef.a11y.chromium.spec.ts` (real Chromium) | Button and custom trigger; place, width, toggle, field press, keys, Escape, Shift+Tab, press-drag; empty ref; anchor unmounted mid-open; 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` | | FR20, AR7 | `DropdownMenuSubMenu.test.tsx` "header and footer" suite; `ContextMenu.test.tsx` rows-alone case; `__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium (fine pointer, coarse pointer) | flyout and phone view; dialog role, name, row wiring; regions outside menus; Tab walk and wrap from a field, row, deeper flyout; field keys, presses; Escape, IME and handled Escapes; keyboard open; pointer leaving, focus in then out; parent closing a nested flyout; view tracking its regions; ContextMenu rows alone | A region inside a menu element, a surface with regions that is not a named modal dialog, a row still saying `aria-haspopup="menu"`, a field key taken by the menu, a Tab that leaves or closes the dialog, a flyout closed under focus by a pointer leaving, a flyout outliving its parent, or the root menu beside a view, fails. | `audit:DropdownMenu/behavior` | ## Decision log @@ -558,6 +561,57 @@ pattern requires; Escape and a press outside close it. Without either prop the component is unchanged: a Menu Button whose Tab closes the menu. Rejected: closing on Tab past either end, which leaves a modal dialog by Tab. +### DEC-12 (proposed) — A menu may anchor to an element other than its trigger + +**Reference:** `component:DropdownMenu/DEC-12` + +**Decider:** proposed by `vjeux`, 2026-10-11; pending owner review + +Where a menu sits is a positioning fact; which control opens it is an +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 placement, alignment, the default width and the +layer's anchor-visibility read (`spec:AST-059` FR5) follow the anchor. The +anchor is also the element the layer hands the browser as the +popover's invoker (`showPopover({source})`), so the menu's popover nesting +follows the anchor, which can differ from the trigger's only when the two sit +in different open popovers. DropdownMenu gives the anchor no role, state or +relation for assistive technology, and in Chromium being the invoker adds +none to a search row either; the trigger alone carries the menu's name and its +`aria-haspopup`, `aria-expanded` and `aria-controls`. The sequential focus order the invoker +carries never reaches the menu: without a header or footer, Tab closes it and returns +focus to the trigger before the browser moves focus, and with either, Tab +wraps inside the dialog (FR15). Popover's `anchorRef` names an element that is both +its anchor and its control; here the trigger stays the control, so the name +keeps Popover's anchor meaning without the control meaning. TopNav's mega menu +anchors its panel to the nav while its button keeps the toggle, so the prop +takes Popover's name and a ref object's shape. With it, the handed-back +trigger props carry the focus-return target but not the anchor that +`spec:AST-055` FR2 places in them, because the anchor is usually an ancestor +the render prop does not render. A press on the anchor outside the trigger +stays outside the menu and dismisses it. + +While the ref is empty when the menu renders, as with an anchor rendered +only in some layouts, the trigger is the anchor: `spec:AST-002` FR15's safe +documented fallback, and no warning flags that legitimate composition. The ref +is read whenever the menu renders, every open included, not observed, as +Popover and BaseTypeahead read theirs, so an anchor that mounts between the +menu's renders is followed at its next render. An anchor that unmounts while +the menu is open departs from `spec:AST-059` until the menu closes: the menu +has no anchor until its next render, a frame FR8 does not allow, and the +layer's visibility read stays on the removed element, so the menu keeps the +off-screen hold of FR5 and forgoes the slide of FR4 although the trigger it +then hangs from is in view. + +Rejected: a second ref in `DropdownMenuTriggerProps`, since the anchor is +usually an ancestor the render prop does not render; an offset from the +trigger, which breaks when the row resizes; hosting the menu's rows in a +Popover, which loses the menu's press model and keyboard opens; observing the +document for an anchor that changes between renders, which puts a DOM +observer on every open menu; and a layer that re-observes its anchor when it +changes, a change to the shared layer runtime outside this component. + ### DEC-13 (proposed) — A sub-menu's header and footer make its surface a dialog beside the menu **Reference:** `component:DropdownMenu/DEC-13` diff --git a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx index b154b7cfb6107..08cc072dec04e 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx @@ -21,7 +21,7 @@ import { import {readFileSync} from 'node:fs'; import {render, screen, fireEvent, waitFor, act} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {createRef, useState} from 'react'; +import {createRef, memo, useRef, useState, type RefObject} from 'react'; import * as stylex from '@stylexjs/stylex'; import {DropdownMenu, type DropdownMenuTriggerProps} from './DropdownMenu'; import {DropdownMenuItem} from './DropdownMenuItem'; @@ -35,6 +35,7 @@ import {__resetInteractionModalityForTest} from '../utils/interactionModality'; import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import {spacingVars} from '../theme/tokens.stylex'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; +import {readAnchorNames} from '../Layer/anchorName'; // Mock showPopover and hidePopover methods since they're not implemented in jsdom beforeEach(() => { @@ -3157,6 +3158,409 @@ describe('DropdownMenu menuMaxHeight', () => { }); }); +describe('DropdownMenu anchorRef', () => { + const ITEMS = [{label: 'Mine'}, {label: 'Everyone'}]; + + function positionAnchorOf(menu: HTMLElement): string | undefined { + const layer = menu.closest('[popover]'); + return /position-anchor:\s*([^;]+)/.exec( + layer?.getAttribute('style') ?? '', + )?.[1]; + } + + it('places the menu against the anchorRef element while the trigger keeps its wiring', async () => { + const user = userEvent.setup(); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + function SearchRow() { + const rowRef = useRef(null); + return ( +
+ + +
+ ); + } + render(); + const row = screen.getByTestId('row'); + const trigger = screen.getByRole('button', {name: 'Filter'}); + const menu = screen.getByRole('menu', {hidden: true}); + // The row carries the menu's anchor name; the trigger carries none. + const [anchorName] = readAnchorNames(row); + expect(anchorName).toMatch(/^--astryx-layer-/); + expect(positionAnchorOf(menu)).toBe(anchorName); + expect(readAnchorNames(trigger)).toEqual([]); + // The trigger keeps the menu-button wiring. + expect(trigger).toHaveAttribute('aria-haspopup', 'menu'); + expect(trigger).toHaveAttribute('aria-controls', menu.id); + + await user.click(trigger); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'true'), + ); + await user.keyboard('{Escape}'); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'false'), + ); + // The anchor stays on the row through the open and the close. + expect(readAnchorNames(row)).toEqual([anchorName]); + expect(readAnchorNames(trigger)).toEqual([]); + // The prop never reaches the DOM. + expect(menu).not.toHaveAttribute('anchorref'); + expect(errors).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + } + }); + + it('leaves a renderTrigger control its focus, keys and name, and the anchor to anchorRef', async () => { + const user = userEvent.setup(); + function Toolbar() { + const barRef = useRef(null); + return ( +
+ ( + + )}> + {}} /> + +
+ ); + } + render(); + const bar = screen.getByTestId('bar'); + const trigger = screen.getByRole('button', {name: 'Owner'}); + const menu = screen.getByRole('menu', {hidden: true}); + expect(positionAnchorOf(menu)).toBe(readAnchorNames(bar)[0]); + expect(readAnchorNames(trigger)).toEqual([]); + // The menu is still named by its control. + expect(menu).toHaveAttribute('aria-labelledby', trigger.id); + trigger.focus(); + await user.keyboard('{ArrowDown}'); + await waitFor(() => + expect( + screen.getByRole('menuitem', {name: 'Mine', hidden: true}), + ).toHaveFocus(), + ); + }); + + it('the layer watches the anchorRef element, so a menu whose anchor scrolls out of view holds its alignment', async () => { + let notify!: IntersectionObserverCallback; + const observed: Element[] = []; + class Observer { + constructor(callback: IntersectionObserverCallback) { + notify = callback; + } + observe(target: Element) { + observed.push(target); + } + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + vi.stubGlobal('IntersectionObserver', Observer); + try { + const user = userEvent.setup(); + function SearchRow() { + const rowRef = useRef(null); + return ( +
+ +
+ ); + } + render(); + await user.click(screen.getByRole('button', {name: 'Filter'})); + // The anchor-visibility read (spec:AST-059 FR5) watches the row, not + // the trigger, and the row keeps its anchor name through the open. + const row = screen.getByTestId('row'); + expect(observed).toEqual([row]); + expect(readAnchorNames(row)).toHaveLength(1); + const layer = screen + .getByRole('menu', {hidden: true}) + .closest('[popover]') as HTMLElement; + expect(layer.style.justifySelf).toBe(''); + act(() => { + notify( + [{isIntersecting: false} as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + }); + expect(layer.style.justifySelf).toMatch(/^unsafe /); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('an anchorRef that is still empty as the menu opens leaves the menu on its trigger', async () => { + const user = userEvent.setup(); + function Unattached() { + // A ref the caller never attaches: an anchor rendered only in some + // layouts, or a component that does not forward its ref. + const rowRef = useRef(null); + return ( + + ); + } + render(); + const trigger = screen.getByRole('button', {name: 'Filter'}); + const menu = screen.getByRole('menu', {hidden: true}); + const [anchorName] = readAnchorNames(trigger); + expect(anchorName).toMatch(/^--astryx-layer-/); + expect(positionAnchorOf(menu)).toBe(anchorName); + await user.click(trigger); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'true'), + ); + expect(readAnchorNames(trigger)).toEqual([anchorName]); + expect(positionAnchorOf(menu)).toBe(anchorName); + }); + + it('an anchor that appears later takes over from the trigger', async () => { + const user = userEvent.setup(); + function Responsive() { + const rowRef = useRef(null); + const [isWide, setIsWide] = useState(false); + return ( + <> + + {isWide &&
} + + + ); + } + render(); + const trigger = screen.getByRole('button', {name: 'Filter'}); + const menu = screen.getByRole('menu', {hidden: true}); + const [anchorName] = readAnchorNames(trigger); + expect(positionAnchorOf(menu)).toBe(anchorName); + await user.click(screen.getByRole('button', {name: 'Widen'})); + const row = screen.getByTestId('row'); + expect(readAnchorNames(row)).toEqual([anchorName]); + expect(readAnchorNames(trigger)).toEqual([]); + }); + + it('a memoized menu keeps an anchor while its anchor mounts mid-open and follows it at its next render', async () => { + const user = userEvent.setup(); + const Menu = memo(function Menu({ + anchorRef, + }: { + anchorRef: RefObject; + }) { + return ( + + ); + }); + function Page() { + const rowRef = useRef(null); + const [isWide, setIsWide] = useState(false); + return ( + <> + + {isWide &&
} + + + ); + } + render(); + const trigger = screen.getByRole('button', {name: 'Filter'}); + const menu = screen.getByRole('menu', {hidden: true}); + const [anchorName] = readAnchorNames(trigger); + expect(positionAnchorOf(menu)).toBe(anchorName); + await user.click(trigger); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'true'), + ); + // The anchor mounts during the open without a render of the memoized + // menu: the menu stays open and keeps an anchor. + fireEvent.click(screen.getByRole('button', {name: 'Widen'})); + const row = screen.getByTestId('row'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect( + [trigger, row].filter(el => readAnchorNames(el).includes(anchorName)), + ).toHaveLength(1); + // The menu's next render, here its close, reads the ref and follows the + // row. + fireEvent.keyDown(menu, {key: 'Escape'}); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'false'), + ); + expect(readAnchorNames(row)).toEqual([anchorName]); + expect(readAnchorNames(trigger)).toEqual([]); + }); + + it('an anchor that unmounts mid-open leaves the menu without one until its next render, then on its trigger, held by the removed anchor', async () => { + let notify!: IntersectionObserverCallback; + const observed: Element[] = []; + class Observer { + constructor(callback: IntersectionObserverCallback) { + notify = callback; + } + observe(target: Element) { + observed.push(target); + } + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + vi.stubGlobal('IntersectionObserver', Observer); + try { + const user = userEvent.setup(); + const Menu = memo(function Menu({ + anchorRef, + }: { + anchorRef: RefObject; + }) { + return ( + + ); + }); + function Page() { + const rowRef = useRef(null); + const [isWide, setIsWide] = useState(true); + return ( + <> + + {isWide &&
} + + + ); + } + render(); + const trigger = screen.getByRole('button', {name: 'Filter'}); + const row = screen.getByTestId('row'); + const [anchorName] = readAnchorNames(row); + await user.click(trigger); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'true'), + ); + expect(observed).toEqual([row]); + // The anchor unmounts during the open without a render of the memoized + // menu: until the menu renders, no element carries its anchor. + fireEvent.click(screen.getByRole('button', {name: 'Narrow'})); + expect(row).not.toBeInTheDocument(); + expect(readAnchorNames(trigger)).toEqual([]); + // The browser reports the removed anchor out of view. That renders the + // menu, which reads the empty ref and falls back to its trigger. + act(() => { + notify( + [{isIntersecting: false} as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(readAnchorNames(trigger)).toEqual([anchorName]); + // The visibility read (spec:AST-059 FR5) stays on the anchor the menu + // opened against, so its off-screen hold lasts until the menu closes. + expect(observed).toEqual([row]); + const layer = screen + .getByRole('menu', {hidden: true}) + .closest('[popover]') as HTMLElement; + expect(layer.style.justifySelf).toMatch(/^unsafe /); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('with a header, Tab in an anchored menu wraps inside its dialog instead of closing the menu', async () => { + const user = userEvent.setup(); + function SearchRow() { + const rowRef = useRef(null); + return ( +
+ + }> + {}} /> + {}} /> + +
+ ); + } + render(); + const trigger = screen.getByRole('button', {name: 'Filter'}); + trigger.focus(); + await user.keyboard('{Enter}'); + const mine = screen.getByRole('menuitem', {name: 'Mine', hidden: true}); + await waitFor(() => expect(mine).toHaveFocus()); + fireEvent.keyDown(mine, {key: 'Tab'}); + expect( + screen.getByRole('textbox', {name: 'Filter owners', hidden: true}), + ).toHaveFocus(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('without anchorRef the trigger stays the anchor', () => { + render(); + const trigger = screen.getByRole('button', {name: 'Filter'}); + const menu = screen.getByRole('menu', {hidden: true}); + const names = readAnchorNames(trigger); + expect(names).toHaveLength(1); + expect(positionAnchorOf(menu)).toBe(names[0]); + }); + + it('the bottom sheet ignores anchorRef', () => { + function SheetRow() { + const rowRef = useRef(null); + return ( +
+ +
+ ); + } + render(); + expect(readAnchorNames(screen.getByTestId('row'))).toEqual([]); + expect(document.querySelector('[anchorref]')).toBeNull(); + }); +}); + describe('DropdownMenu focus return after a pointer pick', () => { function renderMenu(onPick: (label: string) => void = () => {}) { render( diff --git a/packages/core/src/DropdownMenu/DropdownMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenu.tsx index 55c9ed01649f5..99858d18a38f6 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.tsx @@ -17,7 +17,9 @@ * Both modes use useListFocus for DOM-based keyboard navigation. * * The trigger is the design system's Button (`button`) or any control the - * caller renders (`renderTrigger`). + * caller renders (`renderTrigger`). The menu anchors to the trigger unless the + * caller names another element (`anchorRef`); the trigger keeps focus, the + * press model and the menu's name either way. * * A sub-menu drills in on a phone through the view stack useMenuDrillIn * keeps; the root shows the drilled view in place of its rows. @@ -368,7 +370,10 @@ export type DropdownMenuPresentation = AdaptivePresentation; * the control. Spread them all; add your own handlers beside them. */ export interface DropdownMenuTriggerProps { - /** Attaches the control as the menu's anchor and focus-return target. */ + /** + * Attaches the control as the menu's focus-return target, and as its anchor + * unless the menu has an `anchorRef`. + */ ref: (element: HTMLElement | null) => void; /** The menu points at this id with `aria-labelledby`. */ id: string; @@ -417,9 +422,35 @@ interface DropdownMenuBaseProps extends BaseProps { /** * Minimum popover width. The menu may grow for its content but is capped to * the available viewport space. Ignored by bottom-sheet presentation. - * Defaults to the trigger width. + * Defaults to the anchor's width: the trigger's, or the `anchorRef` + * element's. */ menuWidth?: number | string; + /** + * External ref to use as the menu's anchor, as on Popover. The pointer menu + * 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. The ref is read whenever the menu renders, + * every open included, as on Popover: while it is empty (an element rendered + * only in some layouts, or a component that does not forward its ref), the + * trigger is the anchor, and an element that mounts while the menu is open + * is followed at the menu's next render. One that unmounts while the menu is + * open leaves it with no anchor until then, and the menu no longer slides to + * stay inside the viewport until it closes. Ignored by bottom-sheet + * presentation. + * + * @example + * ``` + * const rowRef = useRef(null); + *
+ * + * + *
+ * ``` + */ + anchorRef?: React.RefObject; /** * Maximum menu height in pixels, for a menu that must fit its rows. Lifts * the default 300px cap; the viewport still bounds it. Ignored by @@ -622,6 +653,7 @@ function DropdownMenuBottomSheet({ alignment: _alignment, header, footer, + anchorRef: _anchorRef, className, style, xstyle, @@ -912,6 +944,7 @@ function DropdownMenuPopover({ hasChevron = true, placement = 'below', alignment = 'start', + anchorRef, presentation: _presentation, header, footer, @@ -1107,6 +1140,44 @@ function DropdownMenuPopover({ ? button.label : undefined; + // 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. The layer reads + // the anchor for positioning, for its visibility hold, and as the popover's + // invoker, which gives it nothing for assistive technology. 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. + const anchorSetterRef = useRef<(element: HTMLElement | null) => void>( + () => {}, + ); + const appliedAnchorRef = useRef(null); + useIsomorphicLayoutEffect(() => { + if (anchorRef == null) { + return; + } + const element = anchorRef.current ?? buttonRef.current; + if (element !== appliedAnchorRef.current) { + appliedAnchorRef.current = element; + anchorSetterRef.current(element); + } + }); + useIsomorphicLayoutEffect(() => { + if (anchorRef == null) { + return; + } + return () => { + appliedAnchorRef.current = null; + anchorSetterRef.current(null); + }; + }, [anchorRef]); + const popover = usePopover({ onHide: handleLayerHide, onShow: handleLayerShow, @@ -1125,6 +1196,7 @@ function DropdownMenuPopover({ ? (menuLabel ?? t(DEFAULT_BUTTON_I18N_KEY)) : undefined, }); + anchorSetterRef.current = popover.triggerRef; const closeMenu = useCallback(() => { popover.hide(); @@ -1594,12 +1666,17 @@ function DropdownMenuPopover({ {surfaceHostElement} ); + // The trigger is the menu's anchor unless the caller named another element + // (`anchorRef`; see the effects above the popover). + const attachTrigger = (el: HTMLElement | null) => { + buttonRef.current = el; + if (anchorRef == null) { + popover.triggerRef(el); + } + }; const triggerProps: DropdownMenuTriggerProps = { - ref: el => { - buttonRef.current = el; - popover.triggerRef(el); - }, + ref: attachTrigger, id: triggerId, onClick: handleButtonClick, onClickCapture: handleTriggerClickCapture, @@ -1623,8 +1700,7 @@ function DropdownMenuPopover({