UNPKG

@mskcc/carbon-react

Version:

Carbon react components for the MSKCC DSM

281 lines (268 loc) 9.25 kB
/** * MSKCC 2021, 2024 */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js'); var cx = require('classnames'); var PropTypes = require('prop-types'); var React = require('react'); var ReactDOM = require('react-dom'); var useMergedRefs = require('../../internal/useMergedRefs.js'); var usePrefix = require('../../internal/usePrefix.js'); var MenuContext = require('./MenuContext.js'); var match = require('../../internal/keyboard/match.js'); var keys = require('../../internal/keyboard/keys.js'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var cx__default = /*#__PURE__*/_interopDefaultLegacy(cx); var PropTypes__default = /*#__PURE__*/_interopDefaultLegacy(PropTypes); var React__default = /*#__PURE__*/_interopDefaultLegacy(React); const spacing = 8; // distance to keep to window edges, in px const Menu = /*#__PURE__*/React__default["default"].forwardRef(function Menu(_ref, forwardRef) { let { children, className, label = '', onClose, onOpen, open, size = 'sm', target = document.body, x = 0, y = 0, disableFocus = false, ...rest } = _ref; const prefix = usePrefix.usePrefix(); const focusReturn = React.useRef(null); const context = React.useContext(MenuContext.MenuContext); const isRoot = context.state.isRoot; const menuSize = isRoot ? size : context.state.size; const [childState, childDispatch] = React.useReducer(MenuContext.menuReducer, { ...context.state, isRoot: false, size, requestCloseRoot: isRoot ? handleClose : context.state.requestCloseRoot }); const childContext = React.useMemo(() => { return { state: childState, dispatch: childDispatch }; }, [childState, childDispatch]); const menu = React.useRef(); const ref = useMergedRefs.useMergedRefs([forwardRef, menu]); const [position, setPosition] = React.useState([-1, -1]); const focusableItems = childContext.state.items.filter(item => !item.disabled && item.ref.current); function returnFocus() { if (focusReturn.current) { focusReturn.current.focus(); } } function handleOpen() { if (menu.current) { focusReturn.current = document.activeElement; const pos = calculatePosition(); menu.current.style.left = `${pos[0]}px`; menu.current.style.top = `${pos[1]}px`; setPosition(pos); if (!disableFocus) { menu.current.focus(); } if (onOpen) { onOpen(); } } } function handleClose(e) { if (/^key/.test(e.type)) { window.addEventListener('keyup', returnFocus, { once: true }); } else if (e.type === 'click' && menu.current) { menu.current.addEventListener('focusout', returnFocus, { once: true }); } else { returnFocus(); } if (onClose) { onClose(); } } function handleKeyDown(e) { e.stopPropagation(); // if the user presses escape or this is a submenu // and the user presses ArrowLeft, close it if ((match.match(e, keys.Escape) || !isRoot && match.match(e, keys.ArrowLeft)) && onClose) { handleClose(e); } else { focusItem(e); } } function focusItem(e) { const currentItem = focusableItems.findIndex(item => item.ref.current.contains(document.activeElement)); let indexToFocus = currentItem; // if currentItem is -1, no menu item is focused yet. // in this case, the first item should receive focus. if (currentItem === -1) { indexToFocus = 0; } else if (e) { if (match.match(e, keys.ArrowUp)) { indexToFocus = indexToFocus - 1; } if (match.match(e, keys.ArrowDown)) { indexToFocus = indexToFocus + 1; } } if (indexToFocus < 0) { indexToFocus = focusableItems.length - 1; } if (indexToFocus >= focusableItems.length) { indexToFocus = 0; } if (indexToFocus !== currentItem) { const nodeToFocus = focusableItems[indexToFocus]; nodeToFocus.ref.current.focus(); } } function handleBlur(e) { if (open && onClose && isRoot && !menu.current.contains(e.relatedTarget)) { handleClose(e); } } function fitValue(range, axis) { const { width, height } = menu.current.getBoundingClientRect(); const alignment = isRoot ? 'vertical' : 'horizontal'; const axes = { x: { max: window.innerWidth, size: width, anchor: alignment === 'horizontal' ? range[1] : range[0], reversedAnchor: alignment === 'horizontal' ? range[0] : range[1], offset: 0 }, y: { max: window.innerHeight, size: height, anchor: alignment === 'horizontal' ? range[0] : range[1], reversedAnchor: alignment === 'horizontal' ? range[1] : range[0], offset: isRoot ? 0 : 4 // top padding in menu, used to align the menu items } }; const { max, size, anchor, reversedAnchor, offset } = axes[axis]; // get values for different scenarios, set to false if they don't work const options = [ // towards max (preferred) max - spacing - size - anchor >= 0 ? anchor - offset : false, // towards min / reversed (first fallback) reversedAnchor - size >= 0 ? reversedAnchor - size + offset : false, // align at max (second fallback) max - spacing - size]; const bestOption = options.find(option => option !== false); return bestOption >= spacing ? bestOption : spacing; } function calculatePosition() { if (menu.current) { const ranges = { x: typeof x === 'object' && x.length === 2 ? x : [x, x], y: typeof y === 'object' && y.length === 2 ? y : [y, y] }; return [fitValue(ranges.x, 'x'), fitValue(ranges.y, 'y')]; } return [-1, -1]; } React.useEffect(() => { if (open && focusableItems.length > 0 && !disableFocus) { focusItem(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, focusableItems]); React.useEffect(() => { if (open) { handleOpen(); } else { // reset position when menu is closed in order for the --shown // modifier to be applied correctly setPosition(-1, -1); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const classNames = cx__default["default"](className, `${prefix}--menu`, `${prefix}--menu--${menuSize}`, { // --open sets visibility and --shown sets opacity. // visibility is needed for focusing elements. // opacity is only set once the position has been set correctly // to avoid a flicker effect when opening. [`${prefix}--menu--open`]: open, [`${prefix}--menu--shown`]: position[0] >= 0 && position[1] >= 0, [`${prefix}--menu--with-icons`]: childContext.state.hasIcons }); const rendered = /*#__PURE__*/React__default["default"].createElement(MenuContext.MenuContext.Provider, { value: childContext }, /*#__PURE__*/React__default["default"].createElement("ul", _rollupPluginBabelHelpers["extends"]({}, rest, { className: classNames, role: "menu", ref: ref, "aria-label": label, tabIndex: -1, onKeyDown: handleKeyDown, onBlur: handleBlur }), children)); return isRoot ? open && /*#__PURE__*/ReactDOM.createPortal(rendered, target) || null : rendered; }); Menu.propTypes = { /** * A collection of MenuItems to be rendered within this Menu. */ children: PropTypes__default["default"].node, /** * Additional CSS class names. */ className: PropTypes__default["default"].string, /** * Whether to disable focus on the Menu. */ disableFocus: PropTypes__default["default"].bool, /** * A label describing the Menu. */ label: PropTypes__default["default"].string, /** * Provide an optional function to be called when the Menu should be closed. */ onClose: PropTypes__default["default"].func, /** * Provide an optional function to be called when the Menu is opened. */ onOpen: PropTypes__default["default"].func, /** * Whether the Menu is open or not. */ open: PropTypes__default["default"].bool, /** * Specify the size of the Menu. */ size: PropTypes__default["default"].oneOf(['xs', 'sm', 'md', 'lg']), /** * Specify a DOM node where the Menu should be rendered in. Defaults to document.body. */ target: PropTypes__default["default"].object, /** * Specify the x position of the Menu. Either pass a single number or an array with two numbers describing your activator's boundaries ([x1, x2]) */ x: PropTypes__default["default"].oneOfType([PropTypes__default["default"].number, PropTypes__default["default"].arrayOf(PropTypes__default["default"].number)]), /** * Specify the y position of the Menu. Either pass a single number or an array with two numbers describing your activator's boundaries ([y1, y2]) */ y: PropTypes__default["default"].oneOfType([PropTypes__default["default"].number, PropTypes__default["default"].arrayOf(PropTypes__default["default"].number)]) }; exports.Menu = Menu;