@mskcc/carbon-react
Version:
Carbon react components for the MSKCC DSM
271 lines (262 loc) • 8.39 kB
JavaScript
/**
* 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 };