diff --git a/packages/circuit-ui/components/Menu/Menu.module.css b/packages/circuit-ui/components/Menu/Menu.module.css new file mode 100644 index 0000000000..d9d0f570bd --- /dev/null +++ b/packages/circuit-ui/components/Menu/Menu.module.css @@ -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; +} diff --git a/packages/circuit-ui/components/Menu/Menu.spec.tsx b/packages/circuit-ui/components/Menu/Menu.spec.tsx new file mode 100644 index 0000000000..6765214d35 --- /dev/null +++ b/packages/circuit-ui/components/Menu/Menu.spec.tsx @@ -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(renderFn: RenderFn, props: MenuItemProps) { + return renderFn(); + } + + const baseProps = { + children: 'MenuItem', + icon: Download as FC, + }; + + 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(); + } + + 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) => , + actions: [ + { + onClick: vi.fn(), + children: 'Add', + icon: Add as FC, + }, + { type: 'divider' }, + { + onClick: vi.fn(), + children: 'Remove', + icon: Delete as FC, + 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(); + + 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(); + + 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(); + }); +}); diff --git a/packages/circuit-ui/components/Menu/Menu.stories.tsx b/packages/circuit-ui/components/Menu/Menu.stories.tsx new file mode 100644 index 0000000000..c95d2a115f --- /dev/null +++ b/packages/circuit-ui/components/Menu/Menu.stories.tsx @@ -0,0 +1,98 @@ +/** + * 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 { action } from '@storybook/addon-actions'; +import { Add, Edit, Delete } from '@sumup-oss/icons'; +import { type ReactNode, useState } from 'react'; + +import { Button } from '../Button/index.js'; + +import { Menu, type MenuProps } from './Menu.js'; + +export default { + title: 'Components/Menu', + component: Menu, + tags: ['status:stable'], + argTypes: { + children: { control: 'text' }, + }, +}; + +const actions = [ + { + onClick: action('Button Click'), + children: 'Add', + icon: Add, + }, + { + onClick: action('Button Click'), + children: 'Edit', + icon: Edit, + }, + { type: 'divider' }, + { + onClick: action('Button Click'), + children: 'Delete', + icon: Delete, + destructive: true, + }, +]; +function PopoverWrapper({ children }: { children: ReactNode }) { + return
{children}
; +} +export const Base = (args: MenuProps) => { + const [isOpen, setOpen] = useState(true); + + return ( + + ( + + )} + /> + + ); +}; + +Base.args = { + actions, +}; + +export const Offset = (args: MenuProps) => { + const [isOpen, setOpen] = useState(true); + + return ( + ( + + )} + /> + ); +}; + +Offset.args = { + actions, + offset: 20, +}; diff --git a/packages/circuit-ui/components/Menu/Menu.tsx b/packages/circuit-ui/components/Menu/Menu.tsx new file mode 100644 index 0000000000..b547688f3e --- /dev/null +++ b/packages/circuit-ui/components/Menu/Menu.tsx @@ -0,0 +1,159 @@ +/** + * 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. + */ + +'use client'; + +import type { IconComponentType } from '@sumup-oss/icons'; +import { + type AnchorHTMLAttributes, + type ButtonHTMLAttributes, + useCallback, +} from 'react'; + +import type { ClickEvent } from '../../types/events.js'; +import { useComponents } from '../ComponentsContext/index.js'; +import type { EmotionAsPropType } from '../../types/prop-types.js'; +import { clsx } from '../../styles/clsx.js'; +import { sharedClasses } from '../../styles/shared.js'; +import { Hr } from '../Hr/index.js'; +import { useFocusList } from '../../hooks/useFocusList/index.js'; +import { isFunction } from '../../util/type-check.js'; +import { Popover, type PopoverProps } from '../Popover/index.js'; + +import classes from './Menu.module.css'; + +export interface BaseProps { + /** + * The Popover item label. + */ + children: string; + /** + * Function that's called when the item is clicked. + */ + onClick?: (event: ClickEvent) => void; + /** + * Display an icon in addition to the label. Designed for 24px icons from `@sumup-oss/icons`. + */ + icon?: IconComponentType; + /** + * Destructive variant, changes the color of label and icon from blue to red to signal to the user that the action + * is irreversible or otherwise dangerous. Interactive states are the same for destructive variant. + */ + destructive?: boolean; + /** + * Disabled variant. Visually and functionally disable the button. + */ + disabled?: boolean; + /** + * Remove the [`menu` role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/roles/menu_role) + * when its semantics aren't appropriate for the use case, for example when + * the Popover is used as part of a navigation. Default: 'menu'. + * + * Learn more: https://inclusive-components.design/menus-menu-buttons/ + */ + role?: 'menuitem' | null; +} + +type LinkElProps = Omit, 'onClick'>; +type ButtonElProps = Omit, 'onClick'>; + +export const MenuItem = ({ + children, + icon: Icon, + destructive, + className, + ...props +}: MenuItemProps) => { + const { Link } = useComponents(); + + const Element = props.href ? (Link as EmotionAsPropType) : 'button'; + + return ( + + {Icon && + ); +}; + +export type MenuItemProps = BaseProps & LinkElProps & ButtonElProps; +type OnToggle = (open: boolean | ((prevOpen: boolean) => boolean)) => void; +type Divider = { type: 'divider' }; +type Action = MenuItemProps | Divider; + +export interface MenuProps extends Omit { + /** + * An array of PopoverItem or Divider. + */ + actions?: Action[]; +} + +function isDivider(action: Action): action is Divider { + return 'type' in action && action.type === 'divider'; +} +export const Menu = ({ + actions, + onToggle, + className, + role = 'menu', + ...rest +}: MenuProps) => { + const focusProps = useFocusList(); + const isMenu = role === 'menu'; + + const handleToggle: OnToggle = useCallback( + (state) => { + onToggle((prev) => (isFunction(state) ? state(prev) : state)); + }, + [onToggle], + ); + + const handlePopoverItemClick = + (onClick: BaseProps['onClick']) => (event: ClickEvent) => { + onClick?.(event); + handleToggle(false); + }; + return ( + +
+ {actions?.map((action, index) => + isDivider(action) ? ( +
+ ) : ( + + ), + )} +
+
+ ); +}; diff --git a/packages/circuit-ui/components/TopNavigation/components/ProfileMenu/ProfileMenu.tsx b/packages/circuit-ui/components/TopNavigation/components/ProfileMenu/ProfileMenu.tsx index 4dbe4ea289..cb265ff234 100644 --- a/packages/circuit-ui/components/TopNavigation/components/ProfileMenu/ProfileMenu.tsx +++ b/packages/circuit-ui/components/TopNavigation/components/ProfileMenu/ProfileMenu.tsx @@ -20,12 +20,12 @@ import { ChevronDown, Profile as ProfileIcon } from '@sumup-oss/icons'; import { Avatar } from '../../../Avatar/index.js'; import { Body } from '../../../Body/index.js'; -import { Popover, type PopoverProps } from '../../../Popover/index.js'; import { Skeleton } from '../../../Skeleton/index.js'; import type { UserProps } from '../../types.js'; import { utilClasses } from '../../../../styles/utility.js'; import { sharedClasses } from '../../../../styles/shared.js'; import { clsx } from '../../../../styles/clsx.js'; +import { Menu, type MenuProps } from '../../../Menu/Menu.js'; import classes from './ProfileMenu.module.css'; @@ -93,7 +93,7 @@ export interface ProfileMenuProps extends ProfileProps { * A collection of actions to be rendered in the profile menu. * Same API as the Popover actions. */ - actions: PopoverProps['actions']; + actions: MenuProps['actions']; /** * Function that is called when opening and closing the ProfileMenu. */ @@ -121,8 +121,7 @@ export function ProfileMenu({ }, [onToggle, isOpen]); return ( - // biome-ignore lint/a11y/useValidAriaRole: This removes the default `menu` role of the Popover. - ( @@ -133,9 +132,6 @@ export function ProfileMenu({ fallbackPlacements={[]} offset={offset} className={className} - // This removes the default `menu` role of the Popover. - // eslint-disable-next-line jsx-a11y/aria-role - role={null} /> ); } diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index cf046ffa60..545fe7fb6e 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -152,6 +152,8 @@ export { Tag } from './components/Tag/index.js'; export type { TagProps } from './components/Tag/index.js'; export { Popover } from './components/Popover/index.js'; export type { PopoverProps } from './components/Popover/index.js'; +export { Menu } from './components/Menu/Menu.js'; +export type { MenuProps, MenuItemProps } from './components/Menu/Menu.js'; export { ModalProvider } from './components/Modal/ModalContext.js'; export type { ModalProviderProps } from './components/Modal/ModalContext.js'; export { useModal } from './components/Modal/index.js';