@carbon/react
Version:
React components for the Carbon Design System
358 lines (344 loc) • 12.2 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2023
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js';
import cx from 'classnames';
import PropTypes from 'prop-types';
import React, { forwardRef, useRef, useContext, useReducer, useMemo, useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Escape, ArrowLeft, ArrowUp, ArrowDown } from '../../internal/keyboard/keys.js';
import { match } from '../../internal/keyboard/match.js';
import { useMergedRefs } from '../../internal/useMergedRefs.js';
import { usePrefix } from '../../internal/usePrefix.js';
import { deprecate } from '../../prop-types/deprecate.js';
import { MenuContext, menuReducer } from './MenuContext.js';
import '../LayoutDirection/LayoutDirection.js';
import { useLayoutDirection } from '../LayoutDirection/useLayoutDirection.js';
import { canUseDOM } from '../../internal/environment.js';
const spacing = 8; // distance to keep to window edges, in px
const Menu = /*#__PURE__*/forwardRef(function Menu({
children,
className,
containerRef,
label,
menuAlignment,
mode,
onClose,
onOpen,
open,
size = 'sm',
legacyAutoalign = 'true',
target = canUseDOM && document.body,
x = 0,
y = 0,
...rest
}, forwardRef) {
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(null);
const ref = useMergedRefs([forwardRef, menu]);
const [position, setPosition] = useState([-1, -1]);
const focusableItems = childContext.state.items.filter(item => !item.disabled && item.ref.current);
// Getting the width from the parent container element - controlled
let actionButtonWidth;
if (containerRef?.current) {
const {
width: w
} = containerRef.current.getBoundingClientRect();
actionButtonWidth = w;
}
// Set RTL based on the document direction or `LayoutDirection`
const {
direction
} = useLayoutDirection();
function returnFocus() {
if (focusReturn.current) {
focusReturn.current.focus();
}
}
function handleOpen() {
if (menu.current) {
focusReturn.current = document.activeElement;
if (legacyAutoalign) {
const pos = calculatePosition();
if ((document?.dir === 'rtl' || direction === 'rtl') && !rest?.id?.includes('MenuButton')) {
menu.current.style.insetInlineStart = `initial`;
menu.current.style.insetInlineEnd = `${pos[0]}px`;
} else {
menu.current.style.insetInlineStart = `${pos[0]}px`;
menu.current.style.insetInlineEnd = `initial`;
}
menu.current.style.insetBlockStart = `${pos[1]}px`;
setPosition(pos);
}
menu.current.focus();
if (onOpen) {
onOpen();
}
}
}
function handleClose() {
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();
} 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();
e?.preventDefault();
}
}
function handleBlur(e) {
if (open && onClose && isRoot && !menu.current?.contains(e.relatedTarget)) {
handleClose();
}
}
function fitValue(range, axis) {
if (!menu.current) {
return;
}
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
}
};
// Avoid that the Menu render incorrectly when the position is set in the right side of the screen
if (actionButtonWidth && actionButtonWidth < axes.x.size && (menuAlignment === 'bottom' || menuAlignment === 'top')) {
axes.x.size = actionButtonWidth;
}
// if 'axes.x.anchor' is lower than 87px dynamically switch render side
if (actionButtonWidth && (menuAlignment === 'bottom-end' || menuAlignment === 'top-end') && axes.x.anchor >= 87 && actionButtonWidth < axes.x.size) {
const diff = axes.x.anchor + axes.x.reversedAnchor;
axes.x.anchor = axes.x.anchor + diff;
}
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 topAlignment = menuAlignment === 'top' || menuAlignment === 'top-end' || menuAlignment === 'top-start';
// If the tooltip is not visible in the top, switch to the bottom
if (typeof options[0] === 'number' && topAlignment && options[0] >= 0 && !options[1] && axis === 'y') {
menu.current.style.transform = 'translate(0)';
} else if (topAlignment && !options[0] && axis === 'y') {
options[0] = anchor - offset;
}
// Previous array `options`, has at least one item that is a number (the last one - second fallback).
// That guarantees that the return of `find()` will always be a number
// and we can safely add the numeric casting `as number`.
const bestOption = options.find(option => option !== false);
return bestOption >= spacing ? bestOption : spacing;
}
function notEmpty(value) {
return value !== null && value !== undefined;
}
function getPosition(x) {
if (Array.isArray(x)) {
// has to be of length 2
const filtered = x.filter(notEmpty);
if (filtered.length === 2) {
return filtered;
} else {
return;
}
} else {
return [x, x];
}
}
function calculatePosition() {
const ranges = {
x: getPosition(x),
y: getPosition(y)
};
if (!ranges.x || !ranges.y) {
return [-1, -1];
}
return [fitValue(ranges.x, 'x') ?? -1, fitValue(ranges.y, 'y') ?? -1];
}
useEffect(() => {
if (open) {
const raf = requestAnimationFrame(() => {
if (focusableItems.length > 0) {
focusItem();
}
});
return () => cancelAnimationFrame(raf);
}
// 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--box-shadow-top`]: menuAlignment && menuAlignment.slice(0, 3) === 'top',
[`${prefix}--menu--open`]: open,
[`${prefix}--menu--shown`]: open && !legacyAutoalign || position[0] >= 0 && position[1] >= 0,
[`${prefix}--menu--with-icons`]: childContext.state.hasIcons,
[`${prefix}--menu--with-selectable-items`]: childContext.state.hasSelectableItems,
[`${prefix}--autoalign`]: !legacyAutoalign
});
const rendered = /*#__PURE__*/React.createElement(MenuContext.Provider, {
value: childContext
}, /*#__PURE__*/React.createElement("ul", _extends({}, rest, {
className: classNames,
role: "menu",
ref: ref,
"aria-label": label,
tabIndex: -1,
onKeyDown: handleKeyDown,
onBlur: handleBlur
}), children));
if (!target) {
return rendered;
}
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,
/**
* A label describing the Menu.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
label: PropTypes.string,
/**
* Specify how the menu should align with the button element
*/
menuAlignment: PropTypes.string,
/**
* **Deprecated**: Menus now always support both icons as well as selectable items and nesting.
* The mode of this menu. Defaults to full.
* `full` supports nesting and selectable menu items, but no icons.
* `basic` supports icons but no nesting or selectable menu items.
*
* **This prop is not intended for use and will be set by the respective implementation (like useContextMenu, MenuButton, and ComboButton).**
*/
mode: deprecate(PropTypes.oneOf(['full', 'basic']), 'Menus now always support both icons as well as selectable items and nesting.'),
/**
* Provide an optional function to be called when the Menu should be closed,
* including if the Menu is blurred, the user presses escape, or the Menu is
* a submenu and the user presses ArrowLeft.
*/
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.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
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])
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
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])
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
y: PropTypes.oneOfType([PropTypes.number, PropTypes.arrayOf(PropTypes.number)])
};
export { Menu };