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
5 changes: 5 additions & 0 deletions .changeset/real-pianos-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/fuselage': minor
---

feat(fuselage): Introduce submenu to Menu component
16 changes: 16 additions & 0 deletions packages/fuselage/fuselage.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,21 @@ export namespace MenuSection {
// @public (undocumented)
export type MenuSectionProps<T> = SectionProps<T>;

// @public (undocumented)
export function MenuSubmenuTrigger<T>(_props: MenuSubmenuTriggerProps<T>): null;

// @public (undocumented)
export namespace MenuSubmenuTrigger {
var // (undocumented)
getCollectionNode: <T>(props: MenuSubmenuTriggerProps<T>, context: any) => Generator<PartialNode<T>>;
}

// @public (undocumented)
export type MenuSubmenuTriggerProps<T> = Omit<ItemProps<T>, 'title' | 'children'> & {
children: ReactNode;
variant?: 'danger' | 'success' | 'warning' | 'primary';
};

// @public (undocumented)
export function Message(input: MessageProps): JSX.Element;

Expand Down Expand Up @@ -1938,6 +1953,7 @@ export type PopoverProps = Omit<AriaPopoverProps, 'popoverRef'> & {
children: ReactNode;
state: OverlayTriggerState;
portalContainer?: Element;
popoverRef?: RefObject<HTMLDivElement | null>;
};

// @public (undocumented)
Expand Down
171 changes: 170 additions & 1 deletion packages/fuselage/src/components/Menu/Menu.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import * as stories from './Menu.stories';

withResizeObserverMock();

const { Simple, Complex, WithSections } = composeStories(stories);
const { Simple, Complex, WithSections, WithSubmenu, WithRichSubmenuTrigger } =
composeStories(stories);

const testCases = Object.values(composeStories(stories)).map((Story) => [
Story.storyName || 'Story',
Expand Down Expand Up @@ -148,6 +149,174 @@ describe('[Menu Component]', () => {
});
});

describe('Submenu', () => {
it('should mark a title-based submenu trigger with aria-haspopup', async () => {
render(<WithSubmenu {...WithSubmenu.args} />);

await userEvent.click(screen.getByRole('button'));

const trigger = await screen.findByRole('menuitem', { name: 'Share' });
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
expect(trigger).toHaveAttribute('aria-expanded', 'false');

// sibling leaf items stay regular menu items
expect(
screen.getByRole('menuitem', { name: 'Profile' }),
).not.toHaveAttribute('aria-haspopup');
});

it('should build a rich MenuSubmenuTrigger from its first child and mark it as a submenu', async () => {
render(<WithRichSubmenuTrigger {...WithRichSubmenuTrigger.args} />);

await userEvent.click(screen.getByRole('button'));

// the first child of MenuSubmenuTrigger becomes the trigger, rendered with
// its rich content (icon) and flagged as opening a submenu...
const triggers = (await screen.findAllByRole('menuitem')).filter(
(item) => item.getAttribute('aria-haspopup') === 'menu',
);
expect(triggers).toHaveLength(1);
const [trigger] = triggers;
expect(trigger).toHaveTextContent('Move to');
expect(trigger.querySelector('i')).toBeInTheDocument();

// ...while the remaining children are NOT rendered as items of the parent menu
expect(
screen.queryByRole('menuitem', { name: 'Inbox' }),
).not.toBeInTheDocument();
});

it('should close nested submenus one level at a time when navigating back, keeping the root open', async () => {
const user = userEvent.setup();
render(<WithRichSubmenuTrigger {...WithRichSubmenuTrigger.args} />);

await user.click(screen.getByRole('button'));
await screen.findAllByRole('menuitem');

// open the first level submenu ("Move to") then the nested one ("Teams")
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
await waitFor(() =>
expect(screen.getByText('Inbox')).toBeInTheDocument(),
);
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
await waitFor(() =>
expect(screen.getByText('Design')).toBeInTheDocument(),
);

// going back closes only the nested submenu — level 1 and the root survive
await user.keyboard('{ArrowLeft}');
await waitFor(() =>
expect(screen.queryByText('Design')).not.toBeInTheDocument(),
);
expect(screen.getByText('Inbox')).toBeInTheDocument();
expect(screen.getByText('New')).toBeInTheDocument();

// going back again closes level 1 — the root menu is still open
await user.keyboard('{ArrowLeft}');
await waitFor(() =>
expect(screen.queryByText('Inbox')).not.toBeInTheDocument(),
);
expect(screen.getByText('New')).toBeInTheDocument();
});

it('should close the latest open submenu on Escape, one level at a time, keeping ancestors open', async () => {
const user = userEvent.setup();
render(<WithRichSubmenuTrigger {...WithRichSubmenuTrigger.args} />);

await user.click(screen.getByRole('button'));
await screen.findAllByRole('menuitem');

// open the first level submenu ("Move to") then the nested one ("Teams")
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
await waitFor(() =>
expect(screen.getByText('Inbox')).toBeInTheDocument(),
);
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
await waitFor(() =>
expect(screen.getByText('Design')).toBeInTheDocument(),
);

// Escape closes only the nested submenu — level 1 and the root survive
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByText('Design')).not.toBeInTheDocument(),
);
expect(screen.getByText('Inbox')).toBeInTheDocument();
expect(screen.getByText('New')).toBeInTheDocument();

// Escape again closes level 1 — the root menu is still open
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByText('Inbox')).not.toBeInTheDocument(),
);
expect(screen.getByText('New')).toBeInTheDocument();

// a final Escape closes the root menu
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByText('New')).not.toBeInTheDocument(),
);
});

it('should close a hover-opened submenu on Escape without closing the root menu', async () => {
const user = userEvent.setup();
render(<WithRichSubmenuTrigger {...WithRichSubmenuTrigger.args} />);

await user.click(screen.getByRole('button'));
await screen.findAllByRole('menuitem');

// open the submenu by hovering its trigger — focus stays on the trigger
const trigger = (await screen.findAllByRole('menuitem')).find(
(item) => item.getAttribute('aria-haspopup') === 'menu',
);
await user.hover(trigger!);
await waitFor(() =>
expect(screen.getByText('Inbox')).toBeInTheDocument(),
);

// Escape closes only the submenu — the root menu stays open
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByText('Inbox')).not.toBeInTheDocument(),
);
expect(screen.getByText('New')).toBeInTheDocument();

// a second Escape closes the root menu
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByText('New')).not.toBeInTheDocument(),
);
});

it('should close the entire menu when interacting outside while a submenu is open', async () => {
const user = userEvent.setup();
render(
<div>
{/* a non-focusable target: closing must not depend on a blur event */}
<div data-testid='outside'>outside</div>
<WithRichSubmenuTrigger {...WithRichSubmenuTrigger.args} />
</div>,
);

await user.click(screen.getByRole('button'));
await screen.findAllByRole('menuitem');

// open a submenu so it becomes the topmost overlay
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
await waitFor(() =>
expect(screen.getByText('Inbox')).toBeInTheDocument(),
);

await user.click(screen.getByTestId('outside'));

// both the submenu and the root menu close
await waitFor(() =>
expect(screen.queryByText('Inbox')).not.toBeInTheDocument(),
);
expect(screen.queryByText('New')).not.toBeInTheDocument();
});
});

describe('Sizing', () => {
it('should apply small size by default', () => {
render(<Simple {...Simple.args} />);
Expand Down
45 changes: 45 additions & 0 deletions packages/fuselage/src/components/Menu/Menu.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { StoryFn, Meta } from '@storybook/react-webpack5';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { action } from 'storybook/actions';

import { Box } from '../Box';
import type { IconButtonProps } from '../Button';
Expand All @@ -16,6 +17,7 @@ import {
Menu,
MenuItem,
MenuSection,
MenuSubmenuTrigger,
MenuItemContent,
MenuItemIcon,
MenuItemInput,
Expand Down Expand Up @@ -454,3 +456,46 @@ export const Scrollable = () => {
));
return <Menu title='Scrollable Menu'>{items}</Menu>;
};

export const WithSubmenu: StoryFn<typeof Menu> = (args) => (
<Menu title='Menu with Submenu' {...args}>
<MenuItem key='profile'>Profile</MenuItem>
<MenuItem key='share' title='Share'>
<MenuItem key='copy-link'>Copy link</MenuItem>
<MenuItem key='email'>Email</MenuItem>
</MenuItem>
<MenuItem key='settings'>Settings</MenuItem>
</Menu>
);

export const WithRichSubmenuTrigger: StoryFn<typeof Menu> = (args) => (
<Menu title='Menu with rich submenu triggers' {...args}>
<MenuItem key='new' aria-label='New'>
<MenuItemIcon name='plus' />
<MenuItemContent>New</MenuItemContent>
</MenuItem>
<MenuSubmenuTrigger key='move-to'>
<MenuItem aria-label='Move to'>
<MenuItemIcon name='folder' />
<MenuItemContent>Move to…</MenuItemContent>
</MenuItem>
<MenuItem key='inbox'>Inbox</MenuItem>
<MenuItem key='archive'>Archive</MenuItem>
<MenuSubmenuTrigger key='teams'>
<MenuItem aria-label='Teams'>
<MenuItemIcon name='team' />
<MenuItemContent>Teams…</MenuItemContent>
</MenuItem>
<MenuItem key='design'>Design</MenuItem>
<MenuItem key='engineering'>Engineering</MenuItem>
</MenuSubmenuTrigger>
</MenuSubmenuTrigger>
<MenuItem key='delete' aria-label='Delete'>
<MenuItemIcon name='trash' />
<MenuItemContent>Delete</MenuItemContent>
</MenuItem>
</Menu>
);
WithRichSubmenuTrigger.args = {
onAction: action('click'),
};
2 changes: 1 addition & 1 deletion packages/fuselage/src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const Menu = <T extends object>({
placement={getPlacement(placement)}
maxWidth={maxWidth}
>
<MenuDropDown {...props} {...menuProps} />
<MenuDropDown {...props} {...menuProps} rootMenuTriggerState={state} />
</MenuPopover>
);

Expand Down
69 changes: 60 additions & 9 deletions packages/fuselage/src/components/Menu/MenuDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,77 @@
import type { KeyboardEvent } from '@react-types/shared';
import type { RefObject } from 'react';
import { useRef } from 'react';
import type { AriaMenuProps } from 'react-aria';
import { useMenu } from 'react-aria';
import type { RootMenuTriggerState } from 'react-stately';
import { useTreeState } from 'react-stately';

import MenuItem from './MenuItem';
import MenuSection from './MenuSection';
import MenuSubmenu from './MenuSubmenu';

function MenuDropDown<T extends object>(props: AriaMenuProps<T>) {
export type MenuDropDownProps<T extends object> = AriaMenuProps<T> & {
/**
* The root menu trigger state, shared across every (sub)menu level so the
* submenu hooks can track the open submenu stack.
*/
rootMenuTriggerState: RootMenuTriggerState;
/**
* Ref of the menu container. Submenus pass their `submenuRef` here so
* `useSubmenuTrigger` can wire pointer/keyboard behavior to the right element.
*/
menuRef?: RefObject<HTMLDivElement | null>;
/** The nesting level of the menu, provided by `useSubmenuTrigger` for submenus. */
submenuLevel?: number;
/**
* Keydown handler forwarded to the underlying menu element. Submenus use this
* to override the Escape behavior so it closes a single level at a time.
*/
onKeyDown?: (event: KeyboardEvent) => void;
};

function MenuDropDown<T extends object>({
rootMenuTriggerState,
menuRef,
...props
}: MenuDropDownProps<T>) {
const state = useTreeState(props);

const ref = useRef<HTMLDivElement>(null);
const fallbackRef = useRef<HTMLDivElement>(null);
const ref = menuRef ?? fallbackRef;
const { menuProps } = useMenu(props, state, ref);

return (
<div {...menuProps} ref={ref}>
{[...state.collection].map((item) =>
item.type === 'section' ? (
<MenuSection key={item.key} section={item} state={state} />
) : (
<MenuItem key={item.key} item={item} state={state} />
),
)}
{[...state.collection].map((item) => {
if (item.type === 'section') {
return (
<MenuSection
key={item.key}
section={item}
state={state}
rootMenuTriggerState={rootMenuTriggerState}
parentMenuRef={ref}
onAction={props.onAction}
/>
);
}

if (item.hasChildNodes) {
return (
<MenuSubmenu
key={item.key}
item={item}
state={state}
rootMenuTriggerState={rootMenuTriggerState}
parentMenuRef={ref}
onAction={props.onAction}
/>
);
}

return <MenuItem key={item.key} item={item} state={state} />;
})}
</div>
);
}
Expand Down
Loading
Loading