Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dropdown-menu-anchor-ref.md
Original file line number Diff line number Diff line change
@@ -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
66 changes: 65 additions & 1 deletion apps/storybook/stories/DropdownMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +98,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',
Expand Down Expand Up @@ -1377,6 +1396,51 @@ export const CustomTrigger: Story = {
},
};

export const AnchorRef: Story = {
name: 'Anchored to a row (anchorRef)',
render: function AnchorRefStory() {
const rowRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState('');
const [showing, setShowing] = useState('Everything');
return (
<div>
<div
ref={rowRef}
data-testid="search-row"
{...stylex.props(anchorStoryStyles.row)}>
<div {...stylex.props(anchorStoryStyles.field)}>
<TextInput
label="Search"
isLabelHidden
placeholder="Search"
value={query}
onChange={setQuery}
/>
</div>
<DropdownMenu
anchorRef={rowRef}
alignment="end"
button={{label: 'Filter'}}
items={['Everything', 'Mine', 'Shared with me'].map(label => ({
label,
onClick: () => setShowing(label),
}))}
/>
</div>
<p data-testid="showing">Showing: {showing}</p>
</div>
);
},
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: () => (
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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. 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',
Expand Down
Loading
Loading