UNPKG

@mskcc/carbon-react

Version:

Carbon react components for the MSKCC DSM

271 lines (262 loc) 8.39 kB
/** * MSKCC 2021, 2024 */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import cx from 'classnames'; import PropTypes from 'prop-types'; import React__default, { useRef, useContext, useReducer, useMemo, useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useMergedRefs } from '../../internal/useMergedRefs.js'; import { usePrefix } from '../../internal/usePrefix.js'; import { MenuContext, menuReducer } from './MenuContext.js'; import { match } from '../../internal/keyboard/match.js'; import { Escape, ArrowLeft, ArrowUp, ArrowDown } from '../../internal/keyboard/keys.js'; const spacing = 8; // distance to keep to window edges, in px const Menu = /*#__PURE__*/React__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(); const focusReturn = useRef(null); const context = useContext(MenuContext); const isRoot = context.state.isRoot; const menuSize = isRoot ? size : context.state.size; const [childState, childDispatch] = useReducer(menuReducer, { ...context.state, isRoot: false, size, requestCloseRoot: isRoot ? handleClose : context.state.requestCloseRoot }); const childContext = useMemo(() => { return { state: childState, dispatch: childDispatch }; }, [childState, childDispatch]); const menu = useRef(); const ref = useMergedRefs([forwardRef, menu]); const [position, setPosition] = 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(e, Escape) || !isRoot && match(e, 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(e, ArrowUp)) { indexToFocus = indexToFocus - 1; } if (match(e, 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]; } useEffect(() => { if (open && focusableItems.length > 0 && !disableFocus) { focusItem(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, focusableItems]); 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(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.createElement(MenuContext.Provider, { value: childContext }, /*#__PURE__*/React__default.createElement("ul", _extends({}, rest, { className: classNames, role: "menu", ref: ref, "aria-label": label, tabIndex: -1, onKeyDown: handleKeyDown, onBlur: handleBlur }), children)); return isRoot ? open && /*#__PURE__*/createPortal(rendered, target) || null : rendered; }); Menu.propTypes = { /** * A collection of MenuItems to be rendered within this Menu. */ children: PropTypes.node, /** * Additional CSS class names. */ className: PropTypes.string, /** * Whether to disable focus on the Menu. */ disableFocus: PropTypes.bool, /** * A label describing the Menu. */ label: PropTypes.string, /** * Provide an optional function to be called when the Menu should be closed. */ onClose: PropTypes.func, /** * Provide an optional function to be called when the Menu is opened. */ onOpen: PropTypes.func, /** * Whether the Menu is open or not. */ open: PropTypes.bool, /** * Specify the size of the Menu. */ size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), /** * Specify a DOM node where the Menu should be rendered in. Defaults to document.body. */ target: PropTypes.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.oneOfType([PropTypes.number, PropTypes.arrayOf(PropTypes.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.oneOfType([PropTypes.number, PropTypes.arrayOf(PropTypes.number)]) }; export { Menu };