Skip to content
This repository has been archived by the owner on Jun 5, 2023. It is now read-only.

Commit

Permalink
feat: New "Menu" component
Browse files Browse the repository at this point in the history
  • Loading branch information
diondiondion committed Sep 10, 2020
1 parent ee761c7 commit 9fa5d8e
Show file tree
Hide file tree
Showing 5 changed files with 288 additions and 2 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@
"react-hook-size": "^1.3.0",
"react-merge-refs": "^1.0.0",
"react-popper": "^2.2.3",
"react-resize-aware": "^3.0.1"
"react-resize-aware": "^3.0.1",
"use-item-list": "0.0.1"
},
"commitlint": {
"extends": [
Expand Down
53 changes: 53 additions & 0 deletions src/Menu/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
name: Menu
menu: Components
---

import {Playground, Props} from 'docz';

import Icon from '../Icon';
import PopoverCard from '../PopoverCard';
import TextLink from '../TextLink';
import Status from '../Status';

import {Menu, MenuButton, MenuList, MenuItem} from './';

# Menu

An implementation of the menu button pattern as described in the [WAI-ARIA guidelines](https://www.w3.org/TR/wai-aria-practices-1.1/#menubutton).

<Playground>
{() => {
const [lastSelectedItem, setLastSelectedItem] = React.useState();
const menuItems = [
{id: 0, label: 'Insert', icon: 'plus'},
{id: 1, label: 'Favourite', icon: 'star'},
{id: 2, label: 'Insights', icon: 'trending', isDisabled: true},
{id: 3, label: 'Download', icon: 'download'},
];
return (
<>
<Menu id="demo-menu">
<MenuButton iconRight icon="chevron">
More actions
</MenuButton>
<MenuList>
{menuItems.map(item => (
<MenuItem
key={item.id}
icon={item.icon}
isDisabled={item.isDisabled}
onClick={() => setLastSelectedItem(item.label)}
>
{item.label}
</MenuItem>
))}
</MenuList>
</Menu>
<Status mt={lastSelectedItem ? 's' : null}>
{lastSelectedItem && `Last action: "${lastSelectedItem}"`}
</Status>
</>
);
}}
</Playground>
228 changes: 228 additions & 0 deletions src/Menu/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import React, {createContext, useContext, useRef} from 'react';
import PropTypes from 'prop-types';
import {useItemList} from 'use-item-list';

import mergeCallbacks from '../utils/mergeCallbacks';
import {KEY_CODES} from '../constants';

import Button from '../Button';
import PopoverCard from '../PopoverCard';
import * as MenuListUI from '../MenuList';

import useEventListener from '../useEventListener';
import usePopover from '../usePopover';
import usePopoverState from '../usePopoverState';
import useOnClickOutside from '../useOnClickOutside';

const MenuContext = createContext();

function Menu({id, menuPlacement = 'bottom', menuPositionFixed, children}) {
const buttonRef = useRef();
const menuListRef = useRef();
const popoverRef = useRef();

const popover = usePopover({
ref: popoverRef,
referenceRef: buttonRef,
placement: menuPlacement,
positionFixed: menuPositionFixed,
adaptivePositioning: true,
});

const itemList = useItemList({
id,
onSelect,
});

const {isOpen, open, close, toggle} = usePopoverState({
onOpen: () => {
menuListRef.current?.focus();
itemList.setHighlightedItem(0);
},
onClose: () => {
// If focus is on or within the popover when it's closed,
// we move it back to the button.
if (
popoverRef.current === document.activeElement ||
popoverRef.current?.contains(document.activeElement)
) {
buttonRef.current?.focus();
}
},
});

function onSelect() {
itemList.clearHighlightedItem();
close();
}

function handleGlobalMenuKeyEvents(event) {
if (
event.keyCode === KEY_CODES.ESC ||
event.keyCode === KEY_CODES.TAB
) {
event.preventDefault();
close();
}

const isFocusInMenuOrOnButton =
buttonRef.current === document.activeElement ||
popoverRef.current === document.activeElement ||
popoverRef.current.contains(document.activeElement);

if (isFocusInMenuOrOnButton) {
if (event.keyCode === KEY_CODES.ARROW_UP) {
event.preventDefault();
itemList.moveHighlightedItem(-1);
}
if (event.keyCode === KEY_CODES.ARROW_DOWN) {
event.preventDefault();
itemList.moveHighlightedItem(1);
}
if (
event.keyCode === KEY_CODES.SPACE ||
event.keyCode === KEY_CODES.ENTER
) {
if (itemList.highlightedIndex.current) {
event.preventDefault();
// Trigger a click on the highlighted item
// to select it (unless it's disabled)
const item =
itemList.items.current[
itemList.highlightedIndex.current
];
if (item && !item.ref.current.ariaDisabled) {
item.ref.current.click();
}
}
}
itemList.highlightItemByString(event);
}
}

function handleButtonKeyEvents(event) {
if (event.keyCode === KEY_CODES.ARROW_DOWN) {
event.preventDefault();
open(event);
}
}

// Handle global keyboard events when the menu is open
useEventListener('keydown', handleGlobalMenuKeyEvents, {
isEnabled: isOpen,
});

// Close the menu when clicking outside of it
useOnClickOutside(popoverRef, close, isOpen);

const highlightedItemId = itemList.useHighlightedItemId();

const menuListProps = {
ref: menuListRef,
role: 'menu',
tabIndex: '-1',
id: itemList.listId,
'aria-activedescendant': isOpen ? highlightedItemId : null,
'aria-labelledby': itemList.controllerId,
};

const menuButtonProps = {
ref: popover.setReferenceRef,
id: itemList.controllerId,
'aria-haspopup': 'true',
'aria-controls': itemList.listId,
'aria-expanded': isOpen ? 'true' : 'false',
onKeyDown: handleButtonKeyEvents,
onClick: toggle,
};

return (
<MenuContext.Provider
value={{
popover: {
...popover,
isOpen,
toggle,
open,
close,
},
menuListProps,
menuButtonProps,
itemList,
}}
>
{children}
</MenuContext.Provider>
);
}

Menu.propTypes = {
id: PropTypes.string.isRequired,
menuPlacement: PropTypes.string,
menuPositionFixed: PropTypes.bool,
};

function MenuList({children}) {
const {popover, menuListProps} = useContext(MenuContext);

return (
<PopoverCard
renderWhenClosed
renderInPlace={false}
isOpen={popover.isOpen}
{...popover.props}
ref={popover.setRef}
arrow={popover.arrow}
onUpdatePopover={popover.update}
>
<MenuListUI.Wrapper {...menuListProps}>
{children}
</MenuListUI.Wrapper>
</PopoverCard>
);
}

function MenuButton({as: Component = Button, forwardedAs, ...otherProps}) {
const {menuButtonProps} = useContext(MenuContext);

return <Component {...otherProps} as={forwardedAs} {...menuButtonProps} />;
}

function MenuItem({icon, isDisabled, onClick, children}) {
const itemRef = useRef();
const {itemList} = useContext(MenuContext);
const {
id,
select,
highlight,
useHighlighted,
clearHighlightedItem,
} = itemList.useItem({
ref: itemRef,
text: children,
value: children,
});

return (
<MenuListUI.Item
ref={itemRef}
id={id}
role="menuitem"
aria-disabled={isDisabled ? 'true' : null}
onClick={mergeCallbacks(select, onClick)}
onMouseEnter={highlight}
onMouseLeave={clearHighlightedItem}
>
<MenuListUI.Link
forwardedAs="span"
isDisabled={isDisabled}
isHighlighted={useHighlighted()}
>
{icon && <MenuListUI.ItemIcon name={icon} />}
{children}
</MenuListUI.Link>
</MenuListUI.Item>
);
}

export {MenuContext, Menu, MenuButton, MenuList, MenuItem};
4 changes: 3 additions & 1 deletion src/MenuList/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ const Item = styled.li`
`;

const Link = styled(ButtonCore).withConfig({
shouldForwardProp: prop => ![...textLinkProps, 'isFocused'].includes(prop),
shouldForwardProp: prop =>
![...textLinkProps, 'isHighlighted'].includes(prop),
})`
position: relative;
display: flex;
Expand All @@ -44,6 +45,7 @@ const Link = styled(ButtonCore).withConfig({
${textLinkStyles}
${overflowWrap}
cursor: pointer;
transition: none;
&:hover:not(.is-disabled),
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export {default as Flex} from './Flex';
export {default as Icon} from './Icon';
export {default as InlineList} from './InlineList';
export {default as Meter} from './Meter';
export * as Menu from './Menu';
export * as MenuList from './MenuList';
export {default as Popover} from './Popover';
export {default as Portal} from './Portal';
export {default as SimpleChart} from './charts/SimpleChart';
Expand Down

0 comments on commit 9fa5d8e

Please sign in to comment.