@pantheon-systems/design-toolkit-react
Version:
Pantheon's React Design Toolkit
770 lines (708 loc) • 22.9 kB
JavaScript
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