Skip to content
Closed
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
147 changes: 147 additions & 0 deletions static/app/components/core/dropdownMenu/dropdownMenu.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,153 @@ describe('DropdownMenu', () => {
expect(onAction).toHaveBeenCalledTimes(1);
});

describe('virtualized submenu', () => {
const projects = Array.from({length: 100}, (_, i) => ({
key: `project-${i}`,
label: `project-${i}`,
}));

const rowHeight = 32;
const originalScrollTo = HTMLElement.prototype.scrollTo;

function mockHeight(
property: 'offsetHeight' | 'clientHeight' | 'scrollHeight',
menuHeight: number
) {
jest.spyOn(HTMLElement.prototype, property, 'get').mockImplementation(function (
this: HTMLElement
) {
return this.getAttribute('role') === 'menu' ? menuHeight : rowHeight;
});
}

beforeEach(() => {
// JSDOM has no layout. Give the submenu a fixed height and every row the
// same height, so the virtualizer renders a window of rows.
mockHeight('offsetHeight', 240);
mockHeight('clientHeight', 240);
mockHeight('scrollHeight', (projects.length + 1) * rowHeight);
// JSDOM does not scroll. Like a browser, emit the scroll event the
// virtualizer listens for after the current task.
HTMLElement.prototype.scrollTo = function (
this: HTMLElement,
options?: ScrollToOptions | number
) {
this.scrollTop = typeof options === 'object' ? (options.top ?? 0) : 0;
setTimeout(() => this.dispatchEvent(new Event('scroll')));
};
});

afterEach(() => {
jest.restoreAllMocks();
HTMLElement.prototype.scrollTo = originalScrollTo;
});

function renderVirtualizedMenu() {
render(
<DropdownMenu
triggerLabel="Projects"
items={[
{
key: 'all-projects',
label: 'All Projects',
submenu: {
search: {placeholder: 'Search projects'},
maxHeight: 240,
maxWidth: 258,
virtualizeThreshold: 50,
},
children: [{key: 'my-projects', label: 'My Projects', children: projects}],
},
]}
/>
);
}

it('renders only the visible rows of a long submenu', async () => {
renderVirtualizedMenu();

await userEvent.click(screen.getByRole('button', {name: 'Projects'}));
await userEvent.hover(screen.getByRole('menuitemradio', {name: 'All Projects'}));

expect(screen.getByRole('group', {name: 'My Projects'})).toBeInTheDocument();
expect(screen.getByRole('menuitemradio', {name: 'project-0'})).toBeInTheDocument();
expect(
screen.queryByRole('menuitemradio', {name: 'project-99'})
).not.toBeInTheDocument();
expect(screen.getAllByRole('menuitemradio').length).toBeLessThan(30);
expect(
screen.getByRole('menuitemradio', {name: 'project-0'}).closest('[data-overlay]')
).toHaveStyle({width: 'min(258px, 100%)'});

await userEvent.type(
screen.getByRole('textbox', {name: 'Search projects'}),
'project-99'
);
expect(screen.getByRole('menuitemradio', {name: 'project-99'})).toBeInTheDocument();
});

it('moves keyboard focus between search and rows that are not rendered', async () => {
renderVirtualizedMenu();

await userEvent.click(screen.getByRole('button', {name: 'Projects'}));
await userEvent.hover(screen.getByRole('menuitemradio', {name: 'All Projects'}));
const search = screen.getByRole('textbox', {name: 'Search projects'});
expect(search).toHaveFocus();

await userEvent.keyboard('{ArrowDown}');
expect(screen.getByRole('menuitemradio', {name: 'project-0'})).toHaveFocus();
await userEvent.keyboard('{End}');
expect(
await screen.findByRole('menuitemradio', {name: 'project-99'})
).toHaveFocus();
await userEvent.keyboard('{ArrowDown}');
expect(await screen.findByRole('menuitemradio', {name: 'project-0'})).toHaveFocus();
await userEvent.keyboard('{End}');
expect(
await screen.findByRole('menuitemradio', {name: 'project-99'})
).toHaveFocus();
await userEvent.keyboard('{Home}');
expect(await screen.findByRole('menuitemradio', {name: 'project-0'})).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(search).toHaveFocus();
});

it('renders every row and warns when the submenu has nested submenus', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
render(
<DropdownMenu
triggerLabel="Projects"
items={[
{
key: 'all-projects',
label: 'All Projects',
submenu: {maxHeight: 240, maxWidth: 258, virtualizeThreshold: 50},
children: [
...projects,
{
key: 'nested',
label: 'Nested',
submenu: true,
children: [{key: 'nested-child', label: 'Nested child'}],
},
],
},
]}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Projects'}));
await userEvent.hover(screen.getByRole('menuitemradio', {name: 'All Projects'}));

expect(screen.getByRole('menuitemradio', {name: 'project-99'})).toBeInTheDocument();
expect(screen.getByRole('menuitemradio', {name: 'Nested'})).toBeInTheDocument();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('`virtualizeThreshold` is ignored')
);
});
});

it('returns from the first enabled item to search when a disabled item comes first', async () => {
render(
<DropdownMenu
Expand Down
10 changes: 9 additions & 1 deletion static/app/components/core/dropdownMenu/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export interface MenuItemProps extends ListItemProps {
* customize the sub-menu: `title` is shown as its header, and `position`
* overrides where it opens relative to this item (defaults to `right-start`).
* `maxHeight` and `maxWidth` cap its size without forcing smaller
* menus to fill those dimensions.
* menus to fill those dimensions. Above `virtualizeThreshold` items, it
* renders only the visible rows.
*/
submenu?:
| boolean
Expand All @@ -88,6 +89,13 @@ export interface MenuItemProps extends ListItemProps {
position?: UseOverlayProps['position'];
search?: DropdownMenuSearchOptions;
title?: string;
/**
* Number of items above which only the visible rows render. A
* virtualized sub-menu uses `maxWidth` as its width, because hidden
* rows cannot size it. Ignored, with a development warning, when the
* sub-menu contains its own nested sub-menus.
*/
virtualizeThreshold?: number;
});
/**
* A plain text version of the `label` prop if the label is not a string. Used for
Expand Down
Loading
Loading