Skip to content

Commit

Permalink
[RAC] Support for submenus within sections (#5863)
Browse files Browse the repository at this point in the history
* support submenu as a child of sections

* add story

* add test

* lint

* improve type

* test action and closing
  • Loading branch information
reidbarber authored Feb 13, 2024
1 parent 5dac6d2 commit 0f6aafc
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 3 deletions.
9 changes: 6 additions & 3 deletions packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ function MenuInner<T extends object>({props, collection, menuRef: ref}: MenuInne
children: (item) => {
switch (item.type) {
case 'section':
return <MenuSection section={item} />;
return <MenuSection section={item} parentMenuRef={ref} />;
case 'separator':
return <Separator {...item.props} />;
case 'item':
Expand Down Expand Up @@ -210,10 +210,11 @@ const _Menu = /*#__PURE__*/ (forwardRef as forwardRefType)(Menu);
export {_Menu as Menu};

interface MenuSectionProps<T> extends StyleProps {
section: Node<T>
section: Node<T>,
parentMenuRef: RefObject<HTMLDivElement>
}

function MenuSection<T>({section, className, style, ...otherProps}: MenuSectionProps<T>) {
function MenuSection<T>({section, className, style, parentMenuRef, ...otherProps}: MenuSectionProps<T>) {
let state = useContext(MenuStateContext)!;
let [headingRef, heading] = useSlot();
let {headingProps, groupProps} = useMenuSection({
Expand All @@ -238,6 +239,8 @@ function MenuSection<T>({section, className, style, ...otherProps}: MenuSectionP
}
case 'item':
return <MenuItemInner item={item} />;
case 'submenutrigger':
return <SubmenuTriggerInner item={item} parentMenuRef={parentMenuRef} />;
default:
throw new Error('Unsupported element type in Section: ' + item.type);
}
Expand Down
44 changes: 44 additions & 0 deletions packages/react-aria-components/stories/Menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,50 @@ export const SubmenuDisabledExample = (args) => (
</MenuTrigger>
);

export const SubmenuSectionsExample = (args) => (
<MenuTrigger>
<Button aria-label="Menu"></Button>
<Popover>
<Menu className={styles.menu} onAction={action('onAction')}>
<Section className={styles.group}>
<Header style={{fontSize: '1.2em'}}>Section 1</Header>
<MyMenuItem>Foo</MyMenuItem>
<SubmenuTrigger {...args}>
<MyMenuItem id="Bar">Bar</MyMenuItem>
<Popover className={styles.popover}>
<Menu className={styles.menu} onAction={action('onAction')}>
<Section className={styles.group}>
<Header style={{fontSize: '1.2em'}}>Submenu Section 1</Header>
<MyMenuItem id="Submenu Foo">Submenu Foo</MyMenuItem>
<MyMenuItem id="Submenu Bar">Submenu Bar</MyMenuItem>
<MyMenuItem id="Submenu Baz">Submenu Baz</MyMenuItem>
<MyMenuItem href="https://google.com">Google</MyMenuItem>
</Section>
<Separator style={{borderTop: '1px solid gray', margin: '2px 5px'}} />
<Section className={styles.group}>
<Header style={{fontSize: '1.2em'}}>Submenu Section 2</Header>
<MyMenuItem id="Submenu Foo 2">Submenu Foo</MyMenuItem>
<MyMenuItem id="Submenu Bar 2">Submenu Bar</MyMenuItem>
<MyMenuItem id="Submenu Baz 2">Submenu Baz</MyMenuItem>
</Section>
</Menu>
</Popover>
</SubmenuTrigger>
<MyMenuItem>Baz</MyMenuItem>
<MyMenuItem href="https://google.com">Google</MyMenuItem>
</Section>
<Separator style={{borderTop: '1px solid gray', margin: '2px 5px'}} />
<Section className={styles.group}>
<Header style={{fontSize: '1.2em'}}>Section 2</Header>
<MyMenuItem>Foo</MyMenuItem>
<MyMenuItem>Bar</MyMenuItem>
<MyMenuItem>Baz</MyMenuItem>
</Section>
</Menu>
</Popover>
</MenuTrigger>
);

let submenuArgs = {
args: {
delay: 200
Expand Down
103 changes: 103 additions & 0 deletions packages/react-aria-components/test/Menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -833,5 +833,108 @@ describe('Menu', () => {
expect(menus).toHaveLength(0);
expect(menu).not.toBeInTheDocument();
});
it('should support sections', async () => {
let onAction = jest.fn();
let {getByRole, getAllByRole} = render(
<MenuTrigger>
<Button aria-label="Menu"></Button>
<Popover>
<Menu onAction={onAction}>
<Section>
<Header>Actions</Header>
<MenuItem id="open">Open</MenuItem>
<MenuItem id="rename">Rename…</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<SubmenuTrigger>
<MenuItem id="share">Share…</MenuItem>
<Popover>
<Menu onAction={onAction}>
<Section>
<Header>Work</Header>
<MenuItem id="email-work">Email</MenuItem>
<MenuItem id="sms-work">SMS</MenuItem>
<MenuItem id="twitter-work">Twitter</MenuItem>
</Section>
<Separator />
<Section>
<Header>Personal</Header>
<MenuItem id="email-personal">Email</MenuItem>
<MenuItem id="sms-personal">SMS</MenuItem>
<MenuItem id="twitter-personal">Twitter</MenuItem>
</Section>
</Menu>
</Popover>
</SubmenuTrigger>
<MenuItem id="delete">Delete…</MenuItem>
</Section>
<Separator />
<Section>
<Header>Settings</Header>
<MenuItem id="user">User Settings</MenuItem>
<MenuItem id="system">System Settings</MenuItem>
</Section>
</Menu>
</Popover>
</MenuTrigger>
);

let button = getByRole('button');
expect(button).not.toHaveAttribute('data-pressed');

await user.click(button);
expect(button).toHaveAttribute('data-pressed');

let groups = getAllByRole('group');
expect(groups).toHaveLength(2);

expect(groups[0]).toHaveClass('react-aria-Section');
expect(groups[1]).toHaveClass('react-aria-Section');

expect(groups[0]).toHaveAttribute('aria-labelledby');
expect(document.getElementById(groups[0].getAttribute('aria-labelledby'))).toHaveTextContent('Actions');

expect(groups[1]).toHaveAttribute('aria-labelledby');
expect(document.getElementById(groups[1].getAttribute('aria-labelledby'))).toHaveTextContent('Settings');

let menu = getAllByRole('menu')[0];
expect(getAllByRole('menuitem')).toHaveLength(7);

let popover = menu.closest('.react-aria-Popover');
expect(popover).toBeInTheDocument();
expect(popover).toHaveAttribute('data-trigger', 'MenuTrigger');

let triggerItem = getAllByRole('menuitem')[3];
expect(triggerItem).toHaveTextContent('Share…');

// Open the submenu
await user.pointer({target: triggerItem});
act(() => {jest.runAllTimers();});
let submenu = getAllByRole('menu')[1];
expect(submenu).toBeInTheDocument();

let submenuItems = within(submenu).getAllByRole('menuitem');
expect(submenuItems).toHaveLength(6);

let groupsInSubmenu = within(submenu).getAllByRole('group');
expect(groupsInSubmenu).toHaveLength(2);

expect(groupsInSubmenu[0]).toHaveClass('react-aria-Section');
expect(groupsInSubmenu[1]).toHaveClass('react-aria-Section');

expect(groupsInSubmenu[0]).toHaveAttribute('aria-labelledby');
expect(document.getElementById(groupsInSubmenu[0].getAttribute('aria-labelledby'))).toHaveTextContent('Work');

expect(groupsInSubmenu[1]).toHaveAttribute('aria-labelledby');
expect(document.getElementById(groupsInSubmenu[1].getAttribute('aria-labelledby'))).toHaveTextContent('Personal');

await user.click(submenuItems[0]);
act(() => {jest.runAllTimers();});

expect(onAction).toHaveBeenCalledTimes(1);
expect(onAction).toHaveBeenLastCalledWith('email-work');

expect(submenu).not.toBeInTheDocument();
expect(menu).not.toBeInTheDocument();
});
});
});

1 comment on commit 0f6aafc

@rspbot
Copy link

@rspbot rspbot commented on 0f6aafc Feb 13, 2024

Choose a reason for hiding this comment

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

Please sign in to comment.