From 83a692fe9e8c892abad016c8ba8e007fad885242 Mon Sep 17 00:00:00 2001 From: Maxime Date: Tue, 18 May 2021 16:22:02 -0400 Subject: [PATCH] feat: add MenuButton component --- .../menu-button/menu-button.test.tsx | 87 +++++++++ .../components/menu-button/menu-button.tsx | 84 ++++++++ .../react/src/components/menu/menu.test.tsx | 127 ++++++++++++ packages/react/src/components/menu/menu.tsx | 183 ++++++++++++++++++ packages/react/src/index.ts | 1 + packages/react/src/test-utils/enzyme-utils.ts | 12 ++ .../storybook/stories/menu-button.stories.tsx | 36 ++++ 7 files changed, 530 insertions(+) create mode 100644 packages/react/src/components/menu-button/menu-button.test.tsx create mode 100644 packages/react/src/components/menu-button/menu-button.tsx create mode 100644 packages/react/src/components/menu/menu.test.tsx create mode 100644 packages/react/src/components/menu/menu.tsx create mode 100644 packages/react/src/test-utils/enzyme-utils.ts create mode 100644 packages/storybook/stories/menu-button.stories.tsx diff --git a/packages/react/src/components/menu-button/menu-button.test.tsx b/packages/react/src/components/menu-button/menu-button.test.tsx new file mode 100644 index 0000000000..d1e848b798 --- /dev/null +++ b/packages/react/src/components/menu-button/menu-button.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { MenuButton } from './menu-button'; +import { getByTestId } from '../../test-utils/enzyme-selectors'; +import { waitForComponentToPaint } from '../../test-utils/enzyme-utils'; +import { mountWithTheme } from '../../test-utils/renderer'; + +const options = [ + { + label: 'Option 1', + onClick: jest.fn(), + }, + { + label: 'Option 2', + onClick: jest.fn(), + }, + { + label: 'Option 3', + onClick: jest.fn(), + }, +]; + +describe('MenuButton', () => { + it('should open menu when menu-button is clicked', () => { + const wrapper = mountWithTheme(); + waitForComponentToPaint(wrapper); + + getByTestId(wrapper, 'menu-button').simulate('click'); + + expect(getByTestId(wrapper, 'menu').exists()).toBe(true); + }); + + it('should be default open when defaultOpen prop is set to true', () => { + const wrapper = mountWithTheme(); + + expect(getByTestId(wrapper, 'menu').exists()).toBe(true); + }); + + it('should close menu when escape key is pressed inside menu', () => { + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'Escape' }); + + expect(getByTestId(wrapper, 'menu').exists()).toBe(false); + }); + + it('should focus menu-button when escape key is pressed inside menu', () => { + const wrapper = mountWithTheme( + , + { attachTo: document.body }, + ); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'Escape' }); + + expect(document.activeElement).toBe(getByTestId(wrapper, 'menu-button').getDOMNode()); + wrapper.detach(); + }); + + it('should close menu when tab key is pressed inside menu', () => { + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'Tab' }); + + expect(getByTestId(wrapper, 'menu').exists()).toBe(false); + }); + + it('should close menu when an option is selected inside menu', () => { + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'menu-option-0').simulate('click'); + + expect(getByTestId(wrapper, 'menu').exists()).toBe(false); + }); + + describe('caret icon', () => { + it('should point downwards when menu is not open', () => { + const wrapper = mountWithTheme(); + + expect(getByTestId(wrapper, 'caret-icon').prop('name')).toBe('chevronDown'); + }); + + it('should point upwards when menu is open', () => { + const wrapper = mountWithTheme(); + + expect(getByTestId(wrapper, 'caret-icon').prop('name')).toBe('chevronUp'); + }); + }); +}); diff --git a/packages/react/src/components/menu-button/menu-button.tsx b/packages/react/src/components/menu-button/menu-button.tsx new file mode 100644 index 0000000000..0ccd943b82 --- /dev/null +++ b/packages/react/src/components/menu-button/menu-button.tsx @@ -0,0 +1,84 @@ +import React, { FunctionComponent, KeyboardEvent, useState } from 'react'; +import styled from 'styled-components'; +import { usePopperTooltip } from 'react-popper-tooltip'; +import { Menu, MenuOption } from '../menu/menu'; +import { Button, ButtonType } from '../buttons/button'; +import { Icon } from '../icon/icon'; + +const StyledMenu = styled(Menu)` + max-width: 350px; + min-width: 200px; + width: initial; +`; + +const StyledIcon = styled(Icon)` + margin-left: var(--spacing-1x); +`; + +interface Props { + buttonType: ButtonType; + defaultOpen?: boolean; + options: MenuOption[]; +} + +export const MenuButton: FunctionComponent = ({ buttonType, defaultOpen, options }) => { + const [controlledVisible, setControlledVisible] = useState(!!defaultOpen); + const { + getTooltipProps, + setTooltipRef, + setTriggerRef, + triggerRef, + visible, + } = usePopperTooltip({ + offset: [0, 0], + placement: 'bottom-start', + trigger: 'click', + visible: controlledVisible, + onVisibleChange: setControlledVisible, + }); + + function handleMenuKeyDown({ key }: KeyboardEvent): void { + switch (key) { + case 'Escape': + setControlledVisible(false); + triggerRef?.focus(); + break; + case 'Tab': + setControlledVisible(false); + break; + } + } + + return ( +
+ + {visible && ( + setControlledVisible(false)} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...getTooltipProps({ className: 'tooltip-container' })} + /> + )} +
+ ); +}; diff --git a/packages/react/src/components/menu/menu.test.tsx b/packages/react/src/components/menu/menu.test.tsx new file mode 100644 index 0000000000..b10da6b5d9 --- /dev/null +++ b/packages/react/src/components/menu/menu.test.tsx @@ -0,0 +1,127 @@ +import { ReactWrapper } from 'enzyme'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Menu } from './menu'; +import { mountWithTheme } from '../../test-utils/renderer'; +import { getByTestId } from '../../test-utils/enzyme-selectors'; + +const options = [ + { + label: 'Mango', + onClick: jest.fn(), + }, + { + label: 'Pineapple', + onClick: jest.fn(), + }, + { + label: 'Lime', + onClick: jest.fn(), + }, +]; + +function expectFocusToBeOn(element: ReactWrapper): void { + expect(document.activeElement).toBe(element.getDOMNode()); +} + +describe('Menu', () => { + beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + + it('should call onClick callback when option is clicked', () => { + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'menu-option-0').simulate('click'); + + expect(options[0].onClick).toHaveBeenCalledTimes(1); + }); + + it('should call onKeyDown callback when a key is pressed inside menu', () => { + const callback = jest.fn(); + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'menu').simulate('keydown'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call onOptionSelect callback when an option is selected', () => { + const callback = jest.fn(); + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'menu-option-0').simulate('click'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + describe('focus', () => { + afterEach(() => { + ReactDOM.unmountComponentAtNode(document.body); + }); + + it('should be on first option when initialFocus is set to 0', () => { + const wrapper = mountWithTheme( + , + { attachTo: document.body }, + ); + + expectFocusToBeOn(getByTestId(wrapper, 'menu-option-0')); + }); + + it('should go to next option when ArrowDown key is pressed', () => { + const wrapper = mountWithTheme( + , + { attachTo: document.body }, + ); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'ArrowDown' }); + + expectFocusToBeOn(getByTestId(wrapper, 'menu-option-1')); + }); + + it('should go to the first option when ArrowDown key is pressed on last option', () => { + const wrapper = mountWithTheme( + , + { attachTo: document.body }, + ); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'ArrowDown' }); + + expectFocusToBeOn(getByTestId(wrapper, 'menu-option-0')); + }); + + it('should go to the previous option when ArrowUp key is pressed', () => { + const wrapper = mountWithTheme( + , + { attachTo: document.body }, + ); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'ArrowUp' }); + + expectFocusToBeOn(getByTestId(wrapper, `menu-option-${0}`)); + }); + + it('should go to the last option when ArrowUp key is pressed on first option', () => { + const wrapper = mountWithTheme( + , + { attachTo: document.body }, + ); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'ArrowUp' }); + + expectFocusToBeOn(getByTestId(wrapper, `menu-option-${options.length - 1}`)); + }); + + it('should focus the first option starting with typed character', () => { + const wrapper = mountWithTheme( + , + { attachTo: document.body }, + ); + + getByTestId(wrapper, 'menu').simulate('keydown', { key: 'l' }); + + expectFocusToBeOn(getByTestId(wrapper, 'menu-option-2')); + }); + }); +}); diff --git a/packages/react/src/components/menu/menu.tsx b/packages/react/src/components/menu/menu.tsx new file mode 100644 index 0000000000..e12cb780d4 --- /dev/null +++ b/packages/react/src/components/menu/menu.tsx @@ -0,0 +1,183 @@ +import React, { + createRef, + KeyboardEvent, + forwardRef, + ReactElement, + Ref, + RefObject, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import styled from 'styled-components'; +import { DeviceContextProps, useDeviceContext } from '../device-context-provider/device-context-provider'; +import { getNextElementInArray, getPreviousElementInArray } from '../../utils/array'; + +function getMaxHeight(numberOfVisibleItems: number): string { + const menuOptionHeight = 32; + const optionsHeight = menuOptionHeight * numberOfVisibleItems; + + return `calc(var(--spacing-half) + ${optionsHeight.toString()}px)`; +} + +const StyledDiv = styled.div<{ numberOfVisibleItems: number }>` + background-color: ${({ theme }) => theme.greys.white}; + + /* TODO update with next thematization */ + border: 1px solid #878f9a; + border-radius: var(--border-radius); + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.1); + flex-direction: column; + margin: 0; + max-height: ${({ numberOfVisibleItems }) => getMaxHeight(numberOfVisibleItems)}; + overflow-y: auto; + padding: var(--spacing-half) 0; + position: absolute; + scroll-behavior: smooth; + width: 100%; +`; + +interface ButtonProps { + $device: DeviceContextProps; +} + +const Button = styled.button` + color: ${({ theme }) => theme.greys.black}; + cursor: pointer; + display: block; + font-size: ${({ $device: { isMobile, isTablet } }) => ((isTablet || isMobile) ? '1rem' : '0.875rem')}; + line-height: ${({ $device: { isMobile, isTablet } }) => ((isTablet || isMobile) ? 2.5 : 2)}rem; + overflow: hidden; + padding: 0 var(--spacing-2x); + text-align: left; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + + &:focus { + box-shadow: ${({ theme }) => theme.tokens['focus-border-box-shadow-inset']}; + outline: none; + } + + :hover { + background-color: ${({ theme }) => theme.greys.grey}; + } +`; + +export interface MenuOption { + label: string; + onClick(): void; +} + +interface ListOption extends MenuOption { + focusIndex: number, + ref: RefObject, +} + +interface Props { + className?: string; + initialFocusIndex?: number; + numberOfVisibleItems?: number; + options: MenuOption[]; + + onKeyDown?(event: KeyboardEvent): void; + onOptionSelect?(): void; +} + +export const Menu = forwardRef(({ + className, + initialFocusIndex = -1, + numberOfVisibleItems = 4, + options, + onKeyDown, + onOptionSelect, + ...props +}: Props, ref: Ref): ReactElement => { + const [focusedIndex, setFocusedIndex] = useState(initialFocusIndex); + const device = useDeviceContext(); + const list: ListOption[] = useMemo((): ListOption[] => options.map( + (option, index) => ({ + ...option, + focusIndex: index, + ref: createRef(), + }), + ), [options]); + + const focusElementAtIndex = useCallback((index: number): void => { + list[index]?.ref.current?.focus(); + }, [list]); + + const scrollToElementAtIndex = useCallback((index: number): void => { + list[index]?.ref.current?.scrollIntoView(); + }, [list]); + + useEffect(() => { + focusElementAtIndex(focusedIndex); + scrollToElementAtIndex(focusedIndex); + }, [focusElementAtIndex, scrollToElementAtIndex, focusedIndex]); + + function handleOptionClick(onClick: () => void): void { + onClick(); + onOptionSelect?.(); + } + + function handleKeyDown(event: KeyboardEvent): void { + const letterNumber = /^[\p{L}\p{N}]$/iu; + + onKeyDown?.(event); + + if (letterNumber.test(event.key)) { + const firstMatchingOption = list.find((option) => option.label + .toLowerCase() + .startsWith(event.key.toLowerCase())); + + firstMatchingOption?.ref.current?.focus(); + } + + switch (event.key) { + case 'ArrowUp': { + const previousElement = getPreviousElementInArray(list, focusedIndex); + setFocusedIndex(previousElement.focusIndex); + break; + } + case 'ArrowDown': { + const nextElement = getNextElementInArray(list, focusedIndex); + setFocusedIndex(nextElement.focusIndex); + break; + } + default: { + break; + } + } + } + + return ( + + {list.map((opt, index) => ( + + ))} + + ); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d744f1110a..2cf08a144b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,6 +3,7 @@ export { AddButton } from './components/buttons/add-button'; export { Button } from './components/buttons/button'; export { IconButton } from './components/buttons/icon-button'; export { NavMenuOption } from './components/nav-menu/nav-menu'; +export { MenuButton } from './components/menu-button/menu-button'; export { NavMenuButton } from './components/nav-menu-button/nav-menu-button'; export { BentoMenuButton } from './components/bento-menu-button/bento-menu-button'; export { ToggleButtonGroup } from './components/toggle-button-group/toggle-button-group'; diff --git a/packages/react/src/test-utils/enzyme-utils.ts b/packages/react/src/test-utils/enzyme-utils.ts new file mode 100644 index 0000000000..bded7b0836 --- /dev/null +++ b/packages/react/src/test-utils/enzyme-utils.ts @@ -0,0 +1,12 @@ +import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; + +export async function waitForComponentToPaint

( + wrapper: ReactWrapper

, + amount = 0, +): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, amount)); + wrapper.update(); + }); +} diff --git a/packages/storybook/stories/menu-button.stories.tsx b/packages/storybook/stories/menu-button.stories.tsx new file mode 100644 index 0000000000..b176bd2280 --- /dev/null +++ b/packages/storybook/stories/menu-button.stories.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { MenuButton } from '@equisoft/design-elements-react'; +import { Story } from '@storybook/react'; + +export default { + title: 'Menu Button', + component: MenuButton, +}; + +const options = [ + { + label: 'Aption 1', + onClick: () => console.info('Option 1 clicked'), + }, + { + label: 'Bption 2', + onClick: () => console.info('Option 2 clicked'), + }, + { + label: 'Cption 3', + onClick: () => console.info('Option 3 clicked'), + }, +]; + +export const Normal: Story = () => ( +

+ + + + +
+); + +export const DefaultOpen: Story = () => ( + +);