UNPKG

@pantheon-systems/design-toolkit-react

Version:
770 lines (708 loc) 22.9 kB
import _slicedToArray from '@babel/runtime/helpers/slicedToArray'; import { useState, useRef, useLayoutEffect } from 'react'; import { v4 } from 'uuid'; import { useFloating, flip, autoUpdate } from '@floating-ui/react-dom'; import { faChevronDown, faEllipsisV } from '@fortawesome/pro-solid-svg-icons'; import Icon from '../../Icons/Icon.js'; import { jsx, jsxs } from 'react/jsx-runtime'; import Button from '../Button/Button.js'; var createShortUUID = function createShortUUID() { return v4().substring(0, 8); }; var isSeparatorItemType = function isSeparatorItemType(item) { return item.isSeparator; }; var isHeadingItemType = function isHeadingItemType(item) { return item.isHeading; }; var isNodeItemType = function isNodeItemType(item) { return item.isNode; }; /** * Menu Button UI component */ var MenuButton = function MenuButton(_ref) { var label = _ref.label, id = _ref.id, testId = _ref.testId, icon = _ref.icon, variant = _ref.variant, displayType = _ref.displayType, disabled = _ref.disabled, menuItems = _ref.menuItems, className = _ref.className, listboxPositionInitial = _ref.listboxPositionInitial; // Floating UI support var _useFloating = useFloating({ placement: listboxPositionInitial || 'bottom-start', middleware: [flip()], whileElementsMounted: autoUpdate }), x = _useFloating.x, y = _useFloating.y, reference = _useFloating.reference, floating = _useFloating.floating, strategy = _useFloating.strategy, FUI_Update = _useFloating.update, placement = _useFloating.placement, refs = _useFloating.refs; // Display type modifier class. var displayTypeClass = displayType != 'default' ? "pds-menu-button__".concat(displayType) : undefined; // generate a short unique ID to add to ID attributes var salt = createShortUUID(); var _useState = useState("menu-button-".concat(salt)), _useState2 = _slicedToArray(_useState, 1), fallbackTriggerID = _useState2[0]; var _useState3 = useState("menu-listbox-".concat(salt)), _useState4 = _slicedToArray(_useState3, 1), listboxID = _useState4[0]; // If ID is not passed into component, generate one. var triggerID = id ? id : fallbackTriggerID; var menuRefActual = refs.floating; var nodeRef = useRef(null); var _useState5 = useState(false), _useState6 = _slicedToArray(_useState5, 2), isOpen = _useState6[0], setIsOpen = _useState6[1]; var _useState7 = useState(false), _useState8 = _slicedToArray(_useState7, 2), focusMenu = _useState8[0], setFocusMenu = _useState8[1]; var _useState9 = useState(''), _useState10 = _slicedToArray(_useState9, 2), activeDescendant = _useState10[0], setActiveDescendant = _useState10[1]; var _useState11 = useState(0), _useState12 = _slicedToArray(_useState11, 2), activeMenuItemIndex = _useState12[0], setActiveMenuItemIndex = _useState12[1]; // Non-state instance trackers var groupIDs = useRef(new Array()); var menuItemIDs = useRef(new Array()); var focusableMenuItems = useRef(new Array()); useLayoutEffect(function () { // setup the onClick outside handler window.addEventListener('mousedown', handleClickOutside, true); // Update Floating UI placement FUI_Update(); // Add the Floating UI data attribute var menuElem = menuRefActual.current; if (menuElem) { menuElem.dataset.fuiPlacement = placement; } // only focus the menu when state instructs us to if (focusMenu) { // focus the menu element // NOTE: setTimeout is used to prevent focus change from conflicting with Floating UI setTimeout(function () { menuElem.focus(); }, 0); // reset state setFocusMenu(false); } return function () { window.removeEventListener('mousedown', handleClickOutside, true); }; }, [isOpen, placement]); // // Internal/support methods // var setActiveMenuItem = function setActiveMenuItem(elemID, index) { setActiveDescendant(elemID); setActiveMenuItemIndex(index); }; var openMenu = function openMenu() { setIsOpen(true); setFocusMenu(true); }; var closeMenu = function closeMenu() { setIsOpen(false); setActiveMenuItem(undefined, -1); // remove current selection to reset menu nodeRef.current.querySelector("#".concat(triggerID)).focus(); }; var setFocusByFirstCharacter = function setFocusByFirstCharacter(_char) { // ensure lowercase for easier comparison _char = _char.toLowerCase(); // track if we've found an item yet var itemFound = false; // loop from current active index to end of focusable items for (var i = activeMenuItemIndex + 1; i < focusableMenuItems.current.length; i++) { var item = focusableMenuItems.current[i]; if (item.label.toLowerCase().startsWith(_char)) { setActiveMenuItem(menuItemIDs.current[i], i); // report that we found an item itemFound = true; // break out of loop break; } } // loop from beginning of focusable items to item prior to current active index // !! Continue search only if we haven't found an item yet if (itemFound === false) { for (var _i = 0; _i < activeMenuItemIndex; _i++) { var _item = focusableMenuItems.current[_i]; if (_item.label.toLowerCase().startsWith(_char)) { setActiveMenuItem(menuItemIDs.current[_i], _i); // break out of loop break; } } } }; var activateMenuItem = function activateMenuItem(itemID) { var itemIndex = menuItemIDs.current.indexOf(itemID); var item = focusableMenuItems.current[itemIndex]; // only execute callback if there is one and the item is not disabled if (item.callback && !item.disabled) { item.callback(item); } }; var activateCurrentMenuItem = function activateCurrentMenuItem() { var currentItemID = menuItemIDs.current[activeMenuItemIndex]; activateMenuItem(currentItemID); }; // // Event handler functions // var handleTriggerClick = function handleTriggerClick(event) { if (isOpen) { closeMenu(); } else { openMenu(); } setFocusMenu(function (prevState) { return !prevState; }); event.stopPropagation(); event.preventDefault(); }; var handleMenuItemClick = function handleMenuItemClick(event) { activateMenuItem(event.currentTarget.id); closeMenu(); }; var handleButtonKeyDown = function handleButtonKeyDown(event) { var key = event.key, flag = false; switch (key) { // open menu and focus on first menu item case ' ': case 'Enter': case 'ArrowDown': case 'Down': openMenu(); setActiveMenuItem(menuItemIDs.current[0], 0); flag = true; break; // close menu case 'Esc': case 'Escape': closeMenu(); flag = true; break; // open menu and focus on last menu item case 'Up': case 'ArrowUp': var lastIndex = menuItemIDs.current.length - 1; openMenu(); setActiveMenuItem(menuItemIDs.current[lastIndex], lastIndex); flag = true; break; } // if something desired happened prevent default behavior if (flag) { event.stopPropagation(); event.preventDefault(); } }; var handleMenuKeydown = function handleMenuKeydown(event) { var key = event.key, flag = false, moveToItemID = '', moveToIndex = 0; function isPrintableCharacter(str) { return str.length === 1 && str.match(/\S/); } if (event.ctrlKey || event.altKey || event.metaKey) { return; } if (event.shiftKey) { if (isPrintableCharacter(key)) { setFocusByFirstCharacter(key); flag = true; } if (event.key === 'Tab') { closeMenu(); flag = true; } } else { switch (key) { case ' ': case 'Enter': closeMenu(); activateCurrentMenuItem(); flag = true; break; case 'Esc': case 'Escape': closeMenu(); flag = true; break; case 'Up': case 'ArrowUp': moveToIndex = activeMenuItemIndex - 1; if (moveToIndex < 0) { moveToIndex = menuItemIDs.current.length - 1; } moveToItemID = menuItemIDs.current[moveToIndex]; setActiveMenuItem(moveToItemID, moveToIndex); flag = true; break; case 'ArrowDown': case 'Down': moveToIndex = activeMenuItemIndex + 1; if (moveToIndex > menuItemIDs.current.length - 1) { moveToIndex = 0; } moveToItemID = menuItemIDs.current[moveToIndex]; setActiveMenuItem(moveToItemID, moveToIndex); flag = true; break; case 'Home': case 'PageUp': setActiveMenuItem(menuItemIDs.current[0], 0); flag = true; break; case 'End': case 'PageDown': var lastIndex = menuItemIDs.current.length - 1; setActiveMenuItem(menuItemIDs.current[lastIndex], lastIndex); flag = true; break; case 'Tab': closeMenu(); break; default: if (isPrintableCharacter(key)) { setFocusByFirstCharacter(key); flag = true; } break; } } if (flag) { event.stopPropagation(); event.preventDefault(); } }; // Handle click outside event to close menu if open var handleClickOutside = function handleClickOutside(event) { if (nodeRef.current && !nodeRef.current.contains(event.target)) { if (isOpen) { closeMenu(); } } }; // function to render each menu item correctly var renderItem = function renderItem(item, index) { var groupID = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : ''; var groupHeadingID = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : ''; var groupIDString = groupID ? "-group-".concat(groupID) : ''; var fallbackItemID = triggerID + groupIDString + '-item-' + (index + 1); // If ID is not passed into menu item, generate one. var itemID = item.id ? item.id : fallbackItemID; // render a separator if found if (isSeparatorItemType(item)) { return /*#__PURE__*/jsx("li", { role: "separator" }, itemID); } // render a heading if found if (isHeadingItemType(item)) { return /*#__PURE__*/jsx("li", { role: "presentation", id: groupHeadingID, className: "pds-menu-button__listbox-heading", children: item.label }, groupHeadingID); } // render a node if found if (isNodeItemType(item)) { return /*#__PURE__*/jsx("li", { role: "presentation", children: item.nodeContent }, itemID); } // render a menu item if it has a label value if (item.label) { menuItemIDs.current.push(itemID); focusableMenuItems.current.push(item); // decide if this item should be shown to have focus var shouldFocus = false; if (activeDescendant !== '') { shouldFocus = activeDescendant === itemID; } return /*#__PURE__*/jsxs("li", { id: itemID, "data-testid": item.testId, className: shouldFocus ? 'pds-menu-button__item-focused' : undefined, role: "menuitem", tabIndex: -1, onClick: handleMenuItemClick, "aria-disabled": item.disabled ? true : undefined, children: [item.icon ? /*#__PURE__*/jsx(Icon, { "aria-hidden": true, className: "pds-menu-button__item-icon", icon: item.icon }) : null, item.label] }, itemID); } }; // Function to render grouped items var renderMenuItemsGrouped = function renderMenuItemsGrouped(items, menuItemBreaks) { var groupedItems = new Array(); // get the first break item var breakItem = menuItemBreaks.shift(); // setup our temporary storage for this group var group = new Array(); // iterate over all menu items items.map(function (item, index) { // if the item isn't the first break item then add it to the group if (item !== breakItem) { group.push(item); // if this is the last item then add it to the group if (index + 1 === items.length) { groupedItems.push(group); } } else { // if we have a break item then add the group to the overall items data, // reset the temporary group storage, // add the break item to the new group, // get the next break item groupedItems.push(group); group = new Array(); group.push(item); breakItem = menuItemBreaks.shift(); } }); // filter out empty groups return groupedItems.filter(function (group) { return group.length > 0; }); }; // Function to render the items var renderMenuItems = function renderMenuItems(items) { // reset tracking variables menuItemIDs.current = new Array(); focusableMenuItems.current = new Array(); // Chunk menu items into groups if needed var itemsData = items; // check if we have any separators or headings in the dataset var menuItemBreaks = items.filter(isHeadingItemType || isSeparatorItemType); var haveBreaks = menuItemBreaks.length > 0; // if we have breaks then split into groups at each marker if (haveBreaks) { // group the items together var groupedItemsData = renderMenuItemsGrouped(items, menuItemBreaks); // setup group IDs groupedItemsData.forEach(function () { groupIDs.current.push(createShortUUID()); }); var currentGroupID = groupIDs.current[0]; // locate first valid item to properly set initial value for aria-activedescendant on menu/listbox var firstMenuItemIndex = groupedItemsData[0].findIndex(function (item) { return !isSeparatorItemType(item) && !isHeadingItemType(item); }); // set active descendant ID value in component state var initialDescendant = "".concat(triggerID, "-group-").concat(currentGroupID, "-item-").concat(firstMenuItemIndex + 1); // Render grouped items in listbox return /*#__PURE__*/jsx("div", { id: listboxID, role: "menu", tabIndex: -1, "aria-labelledby": triggerID, "aria-activedescendant": activeDescendant !== '' ? activeDescendant : initialDescendant, style: { display: isOpen ? 'block' : 'none', position: strategy, transform: "translate(".concat(Math.round(x), "px,").concat(Math.round(y), "px)") }, onKeyDown: handleMenuKeydown, ref: floating, children: groupedItemsData.map(function (group, index) { currentGroupID = groupIDs.current[index]; var hasHeading = group[0].isHeading; var groupHeadingID = hasHeading ? "".concat(triggerID, "-group-").concat(currentGroupID, "-heading") : ''; var keyID = "".concat(triggerID, "-group-").concat(currentGroupID); // TODO convert next line to translatable string var groupLabel = hasHeading ? '' : "Unlabeled group ".concat(index + 1); return /*#__PURE__*/jsx("ul", { role: "group", "aria-labelledby": groupHeadingID, "aria-label": groupLabel, children: group.map(function (item, index) { return renderItem(item, index, currentGroupID, groupHeadingID); }) }, keyID); }) }); } else { // Render plain listbox without any groups // set initial active descendant ID value var _initialDescendant = "".concat(triggerID, "-item-1"); return /*#__PURE__*/jsx("ul", { id: listboxID, role: "menu", tabIndex: -1, "aria-labelledby": triggerID, "aria-activedescendant": activeDescendant !== '' ? activeDescendant : _initialDescendant, style: { display: isOpen ? 'block' : 'none', position: strategy, transform: "translate(".concat(Math.round(x), "px,").concat(Math.round(y), "px)") }, onKeyDown: handleMenuKeydown, ref: floating, children: itemsData.map(function (item, index) { return renderItem(item, index); }) }); } }; // Preprocess FA icons. var iconSize; if (displayType === 'icon-only' || displayType === 'icon-with-label') { iconSize = '2x'; } else { iconSize = 'lg'; } var defaultIcon = /*#__PURE__*/jsx(Icon, { "aria-hidden": true, icon: icon.icon ? icon.icon : faChevronDown, size: iconSize, className: icon.icon ? undefined : 'trigger-indicator' }, "".concat(triggerID, "-default-icon")); var chevronDownIcon = /*#__PURE__*/jsx(Icon, { "aria-hidden": true, icon: faChevronDown, size: icon.size }, "".concat(triggerID, "-down-icon")); var meatballsIcon = /*#__PURE__*/jsx(Icon, { "aria-hidden": true, icon: faEllipsisV, size: icon.size }, "".concat(triggerID, "-meatballs-icon")); var menuIcon = defaultIcon; // if display type is meatballs, use the meatballs icon if (displayType === 'meatballs') { menuIcon = meatballsIcon; } // build the button content based on icon position value if icon was provided var buttonContent = []; // if display type is 'default' then show the label if (displayType === 'default') { buttonContent.push(label); } // if display type is 'icon-with-label' then show the icon, label, and chevron. if (displayType === 'icon-with-label') { buttonContent.push(label); buttonContent.push(chevronDownIcon); icon.position = 'start'; } // place the icon either before or after the label based on icon.position. if (icon.position === 'start') { buttonContent.unshift(menuIcon); } else { buttonContent.push(menuIcon); } // if display type is 'meatballs' or 'icon-only', use the label as the aria-label var ariaLabel; if (displayType === 'meatballs' || displayType === 'icon-only') { ariaLabel = label; } // provide a sensible default label if the trigger is only an icon if (!label && icon && icon.icon) { if (isOpen) { ariaLabel = 'Hide menu'; } else { ariaLabel = 'Show menu'; } } // Render the output return /*#__PURE__*/jsxs("span", { className: ['pds-menu-button', displayTypeClass, className].join(' ').trim(), ref: nodeRef, children: [/*#__PURE__*/jsx(Button, { label: buttonContent, id: triggerID, "data-testid": testId, type: "button", variant: displayType === 'default' ? variant : null, disabled: disabled, className: "pds-menu-button__trigger", "aria-haspopup": "true", "aria-controls": listboxID, "aria-expanded": isOpen, "aria-label": ariaLabel, onClick: handleTriggerClick, onKeyDown: handleButtonKeyDown, ref: reference }), renderMenuItems(menuItems)] }); }; // // Prop types // const MenuItemType = PropTypes.exact({ // /** // * Label for a menu item // */ // label: PropTypes.string.isRequired, // /** // * (optional) Callback function to execute when menu item is clicked/tapped/activated // */ // callback: PropTypes.func, // /** // * Optional ID value for this item. One will be generated if not provided. // */ // id: PropTypes.string, // /** // * Optional `data-testid` value for this item. // */ // testId: PropTypes.string, // /** // * Optional icon for this item. // */ // icon: FAIcon, // /** // * Is this item disabled? // */ // disabled: PropTypes.bool, // }); // const HeadingItemType = PropTypes.exact({ // /** // * Label for a menu item // */ // label: PropTypes.string.isRequired, // /** // * Is the item a heading? // */ // isHeading: PropTypes.bool.isRequired, // }); // const SeparatorItemType = PropTypes.exact({ // /** // * Is the item a separator? // */ // isSeparator: PropTypes.bool.isRequired, // }); // const NodeItemType = PropTypes.exact({ // /** // * Is the item a node? // */ // isNode: PropTypes.bool.isRequired, // /** // * The content of the node. // */ // nodeContent: PropTypes.node, // }); // // // // Component propType definitions // MenuButton.propTypes = { // /** // * The text of the button/trigger. Will serve as the aria-label text on "meatballs" and "icon-only" display types. // */ // label: PropTypes.string, // /** // * Optional ID value for this component. One will be generated if not provided. // */ // id: PropTypes.string, // /** // * Optional `data-testid` value for this component // */ // testId: PropTypes.string, // /** // * The icon element to render in the button/trigger and its location (start or end) // */ // icon: PropTypes.shape({ // /** // * Position of the icon // */ // position: PropTypes.oneOf(['start', 'end']), // /** // * Icon element/node to render // */ // icon: FAIcon, // /** // * Icon size // */ // size: PropTypes.oneOf([ // 'xs', // 'sm', // 'lg', // '2x', // '3x', // '4x', // '5x', // '6x', // '7x', // '8x', // '9x', // '10x', // ]), // }), // /** // * Button variant. Only valid for displayType: 'default'. // */ // variant: PropTypes.oneOf(['primary', 'secondary', 'tertiary', 'subtle']) // .isRequired, // /** // * How the button is displayed. // */ // displayType: PropTypes.oneOf([ // 'default', // 'icon-only', // 'icon-with-label', // 'meatballs', // ]).isRequired, // /** // * Is the button disabled? // */ // disabled: PropTypes.bool, // /** // * Array of menu items // */ // menuItems: PropTypes.arrayOf( // PropTypes.oneOfType([ // MenuItemType, // HeadingItemType, // SeparatorItemType, // NodeItemType, // ]), // ).isRequired, // /** // * Initial location, if space permits, of the listbox // */ // listboxPositionInitial: PropTypes.oneOf([ // 'top', // 'top-start', // 'top-end', // 'right', // 'right-start', // 'right-end', // 'bottom', // 'bottom-start', // 'bottom-end', // 'left', // 'left-start', // 'left-end', // ]), // }; MenuButton.defaultProps = { icon: { position: 'end' }, variant: 'secondary', displayType: 'default', listboxPositionInitial: 'bottom-start' }; var MenuButton$1 = MenuButton; export { MenuButton$1 as default }; //# sourceMappingURL=MenuButton.js.map