Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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-submenu-sheet-regions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] A `DropdownMenu` data item with `items` takes `header` and `footer`, as `DropdownMenuSubMenu` does: in the popover they sit above and below that sub-menu's rows, and in the bottom sheet a level with either shows its own in place of both of the menu's. With either, the row stays a sub-menu while `items` is empty, so a filter that matches nothing keeps its field. Every level of the bottom sheet, with or without regions, is now read from the current `items` while it shows (by `id`, by a label no other row at its level shares, or, while the level keeps its row count, by position), so a field there filters it live and a row added, removed or relabelled there shows at once; a row the sheet cannot find again, or one that loses its `items`, keeps the level as it was when it opened. Give the row an `id` when another row at its level has the same label, when its label changes while it shows, or when the rows can reorder or grow. Each level's regions mount on their own, so what was typed into one field never shows up in another.

@vjeux
62 changes: 62 additions & 0 deletions apps/storybook/stories/DropdownMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1654,3 +1654,65 @@ export const SubMenuHeaderChoice: Story = {
);
},
};

/**
* The same Labels level in the action sheet: the sheet is already a dialog,
* so the level shows its own filter field and "Create" button above and below
* its actions, in place of the menu's own, and the field filters them live.
*/
export const SubMenuHeaderAndFooterActionSheet: Story = {
name: 'Submenu / header and footer, action sheet',
render: () => {
const [query, setQuery] = useState('');
const [created, setCreated] = useState(0);
const [last, setLast] = useState('');
const visible = LABELS.filter(label =>
label.toLowerCase().includes(query.trim().toLowerCase()),
);
return (
<div data-testid="submenu-regions-sheet-fixture" style={{padding: 16}}>
<DropdownMenu
button={{label: 'Session'}}
presentation="bottom-sheet"
items={[
{
label: 'Rename',
icon: PencilIcon,
onClick: () => setLast('Rename'),
},
{
label: 'Labels',
header: (
<TextInput
label="Filter labels"
isLabelHidden
placeholder="Filter labels"
value={query}
onChange={setQuery}
/>
),
footer: (
<Button
label="Create label"
variant="ghost"
onClick={() => setCreated(count => count + 1)}
/>
),
items: visible.map(label => ({
label,
onClick: () => setLast(label),
})),
},
{
label: 'Delete',
icon: TrashIcon,
onClick: () => setLast('Delete'),
},
]}
/>
<output data-testid="last">{last}</output>
<output data-testid="created">{created}</output>
</div>
);
},
};
39 changes: 39 additions & 0 deletions packages/core/src/ContextMenu/ContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,45 @@ describe('ContextMenu', () => {
);
});

it('keeps a data item with a header and empty items a plain row, as ContextMenu shows no sub-menu regions', async () => {
const user = userEvent.setup();
const onLabels = vi.fn();
const items = [
{label: 'Rename', onClick: () => {}},
{
label: 'Labels',
header: <input aria-label="Filter labels" />,
items: [],
onClick: onLabels,
},
];
const {unmount} = render(
<ContextMenu label="Row actions" items={items}>
<div>Right-click me</div>
</ContextMenu>,
);
fireEvent.contextMenu(screen.getByText('Right-click me'));
expect(
screen.getByRole('menuitem', {name: 'Labels', hidden: true}),
).not.toHaveAttribute('aria-haspopup');
unmount();

render(
<ContextMenu
presentation="bottom-sheet"
label="Row actions"
items={items}>
<div>Right-click me</div>
</ContextMenu>,
);
fireEvent.contextMenu(screen.getByText('Right-click me'));
await user.click(await screen.findByRole('button', {name: 'Labels'}));
expect(onLabels).toHaveBeenCalledOnce();
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
});

for (const mode of ['items', 'compound'] as const) {
it(`insets a ${mode} menu sheet itself and publishes the inset to its rows`, async () => {
render(
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export const docs = {
name: 'items',
type: 'DropdownMenuOption[]',
description:
'Array of menu entries. Each entry is one of: an action item `{label, onClick?, href?, target?, rel?, icon?, description?, endContent?, isDisabled?, variant?, hasCloseOnSelect?, id?}` (`href` makes the row a real link, so a modified click keeps the browser\'s meaning; variant `"destructive"` renders it in the error color; `endContent` holds trailing content such as a keyboard-shortcut hint; `id` is the row\'s stable React key, needed only when the array reorders or filters), a divider `{type: "divider"}`, or a section `{type: "section", title?, id?, items: [...action items]}`.',
'Array of menu entries. Each entry is one of: an action item `{label, onClick?, href?, target?, rel?, icon?, description?, endContent?, isDisabled?, variant?, hasCloseOnSelect?, id?, items?, header?, footer?}` (nested `items` make the row a sub-menu, which the bottom sheet reads from the current array while it shows, and `header` and `footer` then sit above and below that sub-menu\'s rows, as on `DropdownMenuSubMenu`, and keep the row a sub-menu while `items` is empty; in the bottom sheet a level with either shows neither of the menu\'s own; `href` makes the row a real link, so a modified click keeps the browser\'s meaning; variant `"destructive"` renders it in the error color; `endContent` holds trailing content such as a keyboard-shortcut hint; `id` is the row\'s stable React key, needed when the array reorders, filters or grows, and in the bottom sheet for a row with nested `items` whose label another row at its level shares or changes while its level shows), a divider `{type: "divider"}`, or a section `{type: "section", title?, id?, items: [...action items]}`.',
required: true,
},
{
Expand Down Expand Up @@ -231,13 +231,13 @@ export const docs = {
name: 'header',
type: 'ReactNode',
description:
'Content above the rows and outside the menu itself — a filter field, a toggle. A menu holds only its rows, so a control that is not a row goes here or in `footer`, never among the rows. It keeps its own keys and presses. With a header or footer the popover is a modal dialog around the menu: Tab and Shift+Tab move through the header, the menu and the footer and wrap at either end, and Escape closes. The bottom sheet shows it above the actions.',
'Content above the rows and outside the menu itself — a filter field, a toggle. A menu holds only its rows, so a control that is not a row goes here or in `footer`, never among the rows. It keeps its own keys and presses. With a header or footer the popover is a modal dialog around the menu: Tab and Shift+Tab move through the header, the menu and the footer and wrap at either end, and Escape closes. The bottom sheet shows it above the actions, except on a level whose own `header` or `footer` replaces it.',
},
{
name: 'footer',
type: 'ReactNode',
description:
'Content below the rows and outside the menu itself — a "Create" button. Tab from the menu moves into it, and Tab past its last control wraps to the start of the dialog (see `header`). The bottom sheet shows it below the actions.',
'Content below the rows and outside the menu itself — a "Create" button. Tab from the menu moves into it, and Tab past its last control wraps to the start of the dialog (see `header`). The bottom sheet shows it below the actions, except on a level whose own `header` or `footer` replaces it.',
},
{
name: 'placement',
Expand Down
Loading
Loading