Skip to content

Commit

Permalink
Add a menu component to replace the old Popover
Browse files Browse the repository at this point in the history
  • Loading branch information
sirineJ committed Dec 27, 2024
1 parent 907925c commit 6620099
Show file tree
Hide file tree
Showing 6 changed files with 584 additions and 7 deletions.
36 changes: 36 additions & 0 deletions packages/circuit-ui/components/Menu/Menu.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.base {
box-sizing: border-box;
overflow-y: auto;
background-color: var(--cui-bg-elevated);
border: 1px solid var(--cui-border-subtle);
border-radius: var(--cui-border-radius-byte);
box-shadow: 0 3px 8px 0 rgb(0 0 0 / 20%);
}

.base > div {
padding: 8px 0 !important;
}

.item {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
font-size: var(--cui-body-m-font-size);
line-height: var(--cui-body-m-line-height);
text-align: left;
background: var(--cui-bg-elevated);
}

.icon {
margin-right: var(--cui-spacings-kilo);
}

.trigger {
display: inline-block;
}

.divider {
width: calc(100% - var(--cui-spacings-mega) * 2) !important;
margin: var(--cui-spacings-byte) var(--cui-spacings-mega) !important;
}
286 changes: 286 additions & 0 deletions packages/circuit-ui/components/Menu/Menu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
/**
* Copyright 2024, SumUp Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { FC } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Delete, Add, Download, type IconProps } from '@sumup-oss/icons';

import {
act,
axe,
render,
userEvent,
screen,
type RenderFn,
} from '../../util/test-utils.js';
import type { ClickEvent } from '../../types/events.js';

import { MenuItem, type MenuItemProps, Menu, type MenuProps } from './Menu.js';

describe('MenuItem', () => {
function renderMenuItem<T>(renderFn: RenderFn<T>, props: MenuItemProps) {
return renderFn(<MenuItem {...props} />);
}

const baseProps = {
children: 'MenuItem',
icon: Download as FC<IconProps>,
};

describe('Styles', () => {
it('should render as Link when an href (and onClick) is passed', () => {
const props = {
...baseProps,
href: 'https://sumup.com',
onClick: vi.fn(),
};
const { container } = renderMenuItem(render, props);
const anchorEl = container.querySelector('a');
expect(anchorEl).toBeVisible();
});

it('should render as a `button` when an onClick is passed', () => {
const props = { ...baseProps, onClick: vi.fn() };
const { container } = renderMenuItem(render, props);
const buttonEl = container.querySelector('button');
expect(buttonEl).toBeVisible();
});
});

describe('Logic', () => {
it('should call onClick when rendered as Link', async () => {
const props = {
...baseProps,
href: 'https://sumup.com',
onClick: vi.fn((event: ClickEvent) => {
event.preventDefault();
}),
};
const { container } = renderMenuItem(render, props);
const anchorEl = container.querySelector('a');
if (anchorEl) {
await userEvent.click(anchorEl);
}
expect(props.onClick).toHaveBeenCalledTimes(1);
});
});
});

describe('Menu', () => {
afterEach(() => {
vi.clearAllMocks();
});

function renderMenu(props: MenuProps) {
return render(<Menu {...props} />);
}

function createStateSetter(initialState: boolean) {
return (state: boolean | ((prev: boolean) => boolean)) =>
typeof state === 'boolean' ? state : state(initialState);
}

/**
* Flushes microtasks to prevent act() warnings.
*
* From https://floating-ui.com/docs/react-dom#testing:
*
* > The position of floating elements is computed asynchronously, so a state
* > update occurs during a Promise microtask.
* >
* > The state update happens after tests complete, resulting in act warnings.
*/
async function flushMicrotasks() {
// eslint-disable-next-line testing-library/no-unnecessary-act
await act(async () => {});
}

const baseProps: MenuProps = {
component: (triggerProps) => <button {...triggerProps}>Button</button>,
actions: [
{
onClick: vi.fn(),
children: 'Add',
icon: Add as FC<IconProps>,
},
{ type: 'divider' },
{
onClick: vi.fn(),
children: 'Remove',
icon: Delete as FC<IconProps>,
destructive: true,
},
],
isOpen: true,
onToggle: vi.fn(createStateSetter(true)),
};
it('should open the menu when clicking the trigger element', async () => {
const isOpen = false;
const onToggle = vi.fn(createStateSetter(isOpen));
renderMenu({ ...baseProps, isOpen, onToggle });

const menuTrigger = screen.getByRole('button');

await userEvent.click(menuTrigger);

expect(onToggle).toHaveBeenCalledTimes(1);
});

it.each([
['space', '{ }'],
['enter', '{Enter}'],
['arrow down', '{ArrowDown}'],
['arrow up', '{ArrowUp}'],
])(
'should open the menu when pressing the %s key on the trigger element',
async (_, key) => {
const isOpen = false;
const onToggle = vi.fn(createStateSetter(isOpen));
renderMenu({ ...baseProps, isOpen, onToggle });

const menuTrigger = screen.getByRole('button');

menuTrigger.focus();
await userEvent.keyboard(key);

expect(onToggle).toHaveBeenCalledTimes(1);
},
);

it('should close the menu when clicking outside', async () => {
renderMenu(baseProps);

await userEvent.click(document.body);

expect(baseProps.onToggle).toHaveBeenCalledTimes(1);
});

it('should close the menu when clicking the trigger element', async () => {
renderMenu(baseProps);

const menuTrigger = screen.getByRole('button');

await userEvent.click(menuTrigger);

expect(baseProps.onToggle).toHaveBeenCalledTimes(1);
});

it.each([
['space', '{ }'],
['enter', '{Enter}'],
['arrow up', '{ArrowUp}'],
])(
'should close the menu when pressing the %s key on the trigger element',
async (_, key) => {
renderMenu(baseProps);

const menuTrigger = screen.getByRole('button');

menuTrigger.focus();
await userEvent.keyboard(key);

expect(baseProps.onToggle).toHaveBeenCalledTimes(1);
},
);

it('should close the menu when clicking the escape key', async () => {
renderMenu(baseProps);

await userEvent.keyboard('{Escape}');

expect(baseProps.onToggle).toHaveBeenCalledTimes(1);
});

it('should close the menu when clicking a menu item', async () => {
renderMenu(baseProps);

const popoverItems = screen.getAllByRole('menuitem');

await userEvent.click(popoverItems[0]);

expect(baseProps.onToggle).toHaveBeenCalledTimes(1);
});

it('should move focus to the first menu item after opening', async () => {
const isOpen = false;
const onToggle = vi.fn(createStateSetter(isOpen));

const { rerender } = renderMenu({
...baseProps,
isOpen,
onToggle,
});

rerender(<Menu {...baseProps} isOpen />);

const menuItems = screen.getAllByRole('menuitem');

expect(menuItems[0]).toHaveFocus();

await flushMicrotasks();
});

it('should move focus to the trigger element after closing', async () => {
const { rerender } = renderMenu(baseProps);

rerender(<Menu {...baseProps} isOpen={false} />);

const menuTrigger = screen.getByRole('button');

expect(menuTrigger).toHaveFocus();

await flushMicrotasks();
});

it('should have no accessibility violations', async () => {
const { container } = renderMenu(baseProps);

await act(async () => {
const actual = await axe(container);
expect(actual).toHaveNoViolations();
});
});

it('should render the menu with menu semantics by default ', async () => {
renderMenu(baseProps);

const menu = screen.getByRole('menu');
expect(menu).toBeVisible();
const menuitems = screen.getAllByRole('menuitem');
expect(menuitems.length).toBe(2);

await flushMicrotasks();
});

it('should render the menu without menu semantics ', async () => {
renderMenu({ ...baseProps, role: 'none' });

const menu = screen.queryByRole('menu');
expect(menu).toBeNull();
const menuitems = screen.queryAllByRole('menuitem');
expect(menuitems.length).toBe(0);

await flushMicrotasks();
});

it('should hide dividers from the accessibility tree', async () => {
const { baseElement } = renderMenu(baseProps);

const dividers = baseElement.querySelectorAll('hr[aria-hidden="true"');
expect(dividers.length).toBe(1);

await flushMicrotasks();
});
});
Loading

0 comments on commit 6620099

Please sign in to comment.