Skip to content

Commit

Permalink
feat: add MenuButton component
Browse files Browse the repository at this point in the history
  • Loading branch information
maxime-gendron committed Nov 26, 2021
1 parent 4c62c83 commit 83a692f
Show file tree
Hide file tree
Showing 7 changed files with 530 additions and 0 deletions.
87 changes: 87 additions & 0 deletions packages/react/src/components/menu-button/menu-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MenuButton buttonType="primary" options={options} />);
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(<MenuButton buttonType="primary" defaultOpen options={options} />);

expect(getByTestId(wrapper, 'menu').exists()).toBe(true);
});

it('should close menu when escape key is pressed inside menu', () => {
const wrapper = mountWithTheme(<MenuButton buttonType="primary" defaultOpen options={options} />);

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(
<MenuButton buttonType="primary" defaultOpen options={options} />,
{ 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(<MenuButton buttonType="primary" defaultOpen options={options} />);

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(<MenuButton buttonType="primary" defaultOpen options={options} />);

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(<MenuButton buttonType="primary" options={options} />);

expect(getByTestId(wrapper, 'caret-icon').prop('name')).toBe('chevronDown');
});

it('should point upwards when menu is open', () => {
const wrapper = mountWithTheme(<MenuButton buttonType="primary" defaultOpen options={options} />);

expect(getByTestId(wrapper, 'caret-icon').prop('name')).toBe('chevronUp');
});
});
});
84 changes: 84 additions & 0 deletions packages/react/src/components/menu-button/menu-button.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<div className="App">
<Button
data-testid="menu-button"
type="button"
aria-haspopup="menu"
aria-expanded={visible}
buttonType={buttonType}
onClick={() => setControlledVisible(!controlledVisible)}
ref={setTriggerRef}
>
Trigger
<StyledIcon
ria-hidden="true"
data-testid="caret-icon"
name={visible ? 'chevronUp' : 'chevronDown'}
size="16"
/>
</Button>
{visible && (
<StyledMenu
initialFocusIndex={0}
options={options}
ref={setTooltipRef}
onKeyDown={handleMenuKeyDown}
onOptionSelect={() => setControlledVisible(false)}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...getTooltipProps({ className: 'tooltip-container' })}
/>
)}
</div>
);
};
127 changes: 127 additions & 0 deletions packages/react/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Menu options={options} />);

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(<Menu options={options} onKeyDown={callback} />);

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(<Menu options={options} onOptionSelect={callback} />);

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(
<Menu options={options} initialFocusIndex={0} />,
{ attachTo: document.body },
);

expectFocusToBeOn(getByTestId(wrapper, 'menu-option-0'));
});

it('should go to next option when ArrowDown key is pressed', () => {
const wrapper = mountWithTheme(
<Menu options={options} initialFocusIndex={0} />,
{ 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(
<Menu options={options} initialFocusIndex={options.length - 1} />,
{ 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(
<Menu options={options} initialFocusIndex={1} />,
{ 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(
<Menu options={options} initialFocusIndex={0} />,
{ 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(
<Menu options={options} initialFocusIndex={0} />,
{ attachTo: document.body },
);

getByTestId(wrapper, 'menu').simulate('keydown', { key: 'l' });

expectFocusToBeOn(getByTestId(wrapper, 'menu-option-2'));
});
});
});
Loading

0 comments on commit 83a692f

Please sign in to comment.