|
| 1 | +/* |
| 2 | + Context menu shows a list of actions the user can select to perform actions. The actions |
| 3 | + are hidden until the user opens the menu (the three dot icon). The menu is automatically |
| 4 | + closed on blur. Pass this component an array of objects with the label and action to tie |
| 5 | + to that label. Be sure to pass in a description of the menu in the `label` prop. This prop |
| 6 | + is used as ellipsis' aria-label. |
| 7 | +*/ |
| 8 | +import React, { useState, useEffect, useCallback } from 'react'; |
| 9 | +import PropTypes from 'prop-types'; |
| 10 | +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; |
| 11 | +import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'; |
| 12 | +import { Button } from '@trussworks/react-uswds'; |
| 13 | + |
| 14 | +import './ContextMenu.css'; |
| 15 | + |
| 16 | +const ESCAPE_KEY_CODE = 27; |
| 17 | + |
| 18 | +function ContextMenu({ |
| 19 | + label, menuItems, backgroundColor, left, |
| 20 | +}) { |
| 21 | + const [shown, updateShown] = useState(false); |
| 22 | + const defaultClass = 'smart-hub--context-menu'; |
| 23 | + const menuClass = left ? `${defaultClass} smart-hub--context-menu__left` : defaultClass; |
| 24 | + |
| 25 | + const onEscape = useCallback((event) => { |
| 26 | + if (event.keyCode === ESCAPE_KEY_CODE) { |
| 27 | + updateShown(false); |
| 28 | + } |
| 29 | + }, [updateShown]); |
| 30 | + |
| 31 | + useEffect(() => { |
| 32 | + document.addEventListener('keydown', onEscape, false); |
| 33 | + return () => { |
| 34 | + document.removeEventListener('keydown', onEscape, false); |
| 35 | + }; |
| 36 | + }, [onEscape]); |
| 37 | + |
| 38 | + const onBlur = (e) => { |
| 39 | + const { currentTarget } = e; |
| 40 | + |
| 41 | + setTimeout(() => { |
| 42 | + if (!currentTarget.contains(document.activeElement) && shown) { |
| 43 | + updateShown(false); |
| 44 | + } |
| 45 | + }, 0); |
| 46 | + }; |
| 47 | + |
| 48 | + return ( |
| 49 | + <div |
| 50 | + onBlur={onBlur} |
| 51 | + > |
| 52 | + <Button |
| 53 | + className="smart-hub--context-menu-button smart-hub--button__no-margin" |
| 54 | + unstyled |
| 55 | + aria-haspopup |
| 56 | + onClick={() => updateShown((previous) => !previous)} |
| 57 | + aria-label={label} |
| 58 | + > |
| 59 | + <FontAwesomeIcon color="black" icon={faEllipsisH} /> |
| 60 | + </Button> |
| 61 | + {shown |
| 62 | + && ( |
| 63 | + <div className={menuClass} style={{ backgroundColor }}> |
| 64 | + <ul className="usa-list usa-list--unstyled" role="menu"> |
| 65 | + {menuItems.map((item) => ( |
| 66 | + <li key={item.label} role="menuitem"> |
| 67 | + <Button type="button" onClick={item.onClick} unstyled className="smart-hub--context-menu-button smart-hub--button__no-margin"> |
| 68 | + <div className="smart-hub--context-menu-item-label"> |
| 69 | + {item.label} |
| 70 | + </div> |
| 71 | + </Button> |
| 72 | + </li> |
| 73 | + ))} |
| 74 | + </ul> |
| 75 | + </div> |
| 76 | + )} |
| 77 | + </div> |
| 78 | + ); |
| 79 | +} |
| 80 | + |
| 81 | +ContextMenu.propTypes = { |
| 82 | + label: PropTypes.string.isRequired, |
| 83 | + menuItems: PropTypes.arrayOf(PropTypes.shape({ |
| 84 | + label: PropTypes.string, |
| 85 | + onClick: PropTypes.func, |
| 86 | + })).isRequired, |
| 87 | + backgroundColor: PropTypes.string, |
| 88 | + left: PropTypes.bool, |
| 89 | +}; |
| 90 | + |
| 91 | +ContextMenu.defaultProps = { |
| 92 | + backgroundColor: 'white', |
| 93 | + left: true, |
| 94 | +}; |
| 95 | + |
| 96 | +export default ContextMenu; |
0 commit comments