@carbon/react
Version:
React components for the Carbon Design System
471 lines (455 loc) • 15.1 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 React, { forwardRef, useContext, useState, useRef, useEffect, useCallback, Children, isValidElement, cloneElement } from 'react';
import { OverflowMenuVertical } from '@carbon/icons-react';
import cx from 'classnames';
import invariant from 'invariant';
import PropTypes from 'prop-types';
import { DIRECTION_TOP, DIRECTION_BOTTOM, FloatingMenu } from '../../internal/FloatingMenu.js';
import { ArrowUp, ArrowRight, ArrowDown, ArrowLeft, Escape } from '../../internal/keyboard/keys.js';
import { matches } from '../../internal/keyboard/match.js';
import { noopFn } from '../../internal/noopFn.js';
import { PrefixContext } from '../../internal/usePrefix.js';
import { deprecate } from '../../prop-types/deprecate.js';
import mergeRefs from '../../tools/mergeRefs.js';
import { setupGetInstanceId } from '../../tools/setupGetInstanceId.js';
import { IconButton } from '../IconButton/index.js';
import { useOutsideClick } from '../../internal/useOutsideClick.js';
import deprecateValuesWithin from '../../prop-types/deprecateValuesWithin.js';
import { mapPopoverAlign } from '../../tools/mapPopoverAlign.js';
const getInstanceId = setupGetInstanceId();
const on = (target, ...args) => {
target.addEventListener(...args);
return {
release() {
target.removeEventListener(...args);
return null;
}
};
};
/**
* The CSS property names of the arrow keyed by the floating menu direction.
*/
const triggerButtonPositionProps = {
[DIRECTION_TOP]: 'bottom',
[DIRECTION_BOTTOM]: 'top'
};
/**
* Determines how the position of the arrow should affect the floating menu
* position.
*/
const triggerButtonPositionFactors = {
[DIRECTION_TOP]: -2,
[DIRECTION_BOTTOM]: -1
};
/**
* Calculates the offset for the floating menu.
*
* @param menuBody - The menu body with the menu arrow.
* @param direction - The floating menu direction.
* @returns The adjustment of the floating menu position, upon the position of
* the menu arrow.
*/
const getMenuOffset = (menuBody, direction, trigger, flip) => {
const triggerButtonPositionProp = triggerButtonPositionProps[direction];
const triggerButtonPositionFactor = triggerButtonPositionFactors[direction];
if (process.env.NODE_ENV !== 'production') {
!(triggerButtonPositionProp && triggerButtonPositionFactor) ? process.env.NODE_ENV !== "production" ? invariant(false, '[OverflowMenu] wrong floating menu direction: `%s`', direction) : invariant(false) : void 0;
}
const {
offsetWidth: menuWidth,
offsetHeight: menuHeight
} = menuBody;
switch (triggerButtonPositionProp) {
case 'top':
case 'bottom':
{
// TODO: Ensure `trigger` is there for `<OverflowMenu open>`
const triggerWidth = !trigger ? 0 : trigger.offsetWidth;
return {
left: (!flip ? 1 : -1) * (menuWidth / 2 - triggerWidth / 2),
top: 0
};
}
default:
return {
left: 0,
top: 0
};
}
};
const OverflowMenu = /*#__PURE__*/forwardRef(({
align,
['aria-label']: ariaLabel = null,
ariaLabel: deprecatedAriaLabel,
children,
className,
direction = DIRECTION_BOTTOM,
flipped = false,
focusTrap = true,
iconClass,
iconDescription = 'Options',
id,
light,
menuOffset = getMenuOffset,
menuOffsetFlip = getMenuOffset,
menuOptionsClass,
onClick = noopFn,
onClose = noopFn,
onOpen = noopFn,
open: openProp,
renderIcon: IconElement = OverflowMenuVertical,
selectorPrimaryFocus = '[data-floating-menu-primary-focus]',
size = 'md',
innerRef,
...other
}, ref) => {
const prefix = useContext(PrefixContext);
const [open, setOpen] = useState(openProp ?? false);
const [click, setClick] = useState(false);
const [hasMountedTrigger, setHasMountedTrigger] = useState(false);
/** The handle of `onfocusin` or `focus` event handler. */
const hFocusIn = useRef(null);
const instanceId = useRef(getInstanceId());
const menuBodyRef = useRef(null);
const menuItemRefs = useRef({});
const prevOpenProp = useRef(openProp);
const prevOpenState = useRef(open);
/** The element ref of the tooltip's trigger button. */
const triggerRef = useRef(null);
const wrapperRef = useRef(null);
// Sync open prop changes.
useEffect(() => {
if (prevOpenProp.current !== openProp) {
setOpen(!!openProp);
prevOpenProp.current = openProp;
}
}, [openProp]);
// Mark trigger as mounted.
useEffect(() => {
if (triggerRef.current) {
setHasMountedTrigger(true);
}
}, []);
useEffect(() => {
if (open && !prevOpenState.current) {
onOpen();
} else if (!open && prevOpenState.current) {
onClose();
}
prevOpenState.current = open;
}, [open, onClose, onOpen]);
useOutsideClick(wrapperRef, ({
target
}) => {
if (open && (!menuBodyRef.current || target instanceof Node && !menuBodyRef.current.contains(target))) {
closeMenu();
}
});
const focusMenuEl = useCallback(() => {
if (triggerRef.current) {
triggerRef.current.focus();
}
}, []);
const closeMenu = useCallback(onCloseMenu => {
setOpen(false);
// Optional callback to be executed after the state as been set to close
if (onCloseMenu) {
onCloseMenu();
}
onClose();
}, [onClose]);
const closeMenuAndFocus = useCallback(() => {
const wasClicked = click;
const wasOpen = open;
closeMenu(() => {
if (wasOpen && !wasClicked) {
focusMenuEl();
}
});
}, [click, open, closeMenu, focusMenuEl]);
const closeMenuOnEscape = useCallback(() => {
const wasOpen = open;
closeMenu(() => {
if (wasOpen) {
focusMenuEl();
}
});
}, [open, closeMenu, focusMenuEl]);
const handleClick = evt => {
setClick(true);
if (!menuBodyRef.current || !menuBodyRef.current.contains(evt.target)) {
setOpen(prev => !prev);
onClick(evt);
}
};
const handleKeyPress = evt => {
if (open && matches(evt, [ArrowUp, ArrowRight, ArrowDown, ArrowLeft])) {
evt.preventDefault();
}
// Close the overflow menu on escape
if (matches(evt, [Escape])) {
closeMenuOnEscape();
// Stop the esc keypress from bubbling out and closing something it shouldn't
evt.stopPropagation();
}
};
/**
* Focuses the next enabled overflow menu item given the currently focused
* item index and direction to move.
*/
const handleOverflowMenuItemFocus = ({
currentIndex = 0,
direction
}) => {
const enabledIndices = Children.toArray(children).reduce((acc, curr, i) => {
if (/*#__PURE__*/React.isValidElement(curr) && !curr.props.disabled) {
acc.push(i);
}
return acc;
}, []);
const nextValidIndex = (() => {
const nextIndex = enabledIndices.indexOf(currentIndex) + direction;
switch (nextIndex) {
case -1:
return enabledIndices.length - 1;
case enabledIndices.length:
return 0;
default:
return nextIndex;
}
})();
const overflowMenuItem = menuItemRefs.current[enabledIndices[nextValidIndex]];
overflowMenuItem?.focus();
};
const bindMenuBody = menuBody => {
if (!menuBody) {
menuBodyRef.current = menuBody;
}
if (!menuBody && hFocusIn.current) {
hFocusIn.current = hFocusIn.current.release();
}
};
const handlePlace = menuBody => {
if (!menuBody) return;
menuBodyRef.current = menuBody;
const hasFocusin = 'onfocusin' in window;
const focusinEventName = hasFocusin ? 'focusin' : 'focus';
hFocusIn.current = on(menuBody.ownerDocument, focusinEventName, event => {
const target = event.target;
const triggerEl = triggerRef.current;
if (typeof target.matches === 'function') {
if (!menuBody.contains(target) && triggerEl && !target.matches(`.${prefix}--overflow-menu:first-child, .${prefix}--overflow-menu-options:first-child`)) {
closeMenuAndFocus();
}
}
}, !hasFocusin);
};
const getTarget = () => {
const triggerEl = triggerRef.current;
if (triggerEl instanceof Element) {
return triggerEl.closest('[data-floating-menu-container]') || document.body;
}
return document.body;
};
const menuBodyId = `overflow-menu-${instanceId.current}__menu-body`;
const overflowMenuClasses = cx(className, `${prefix}--overflow-menu`, {
[`${prefix}--overflow-menu--open`]: open,
[`${prefix}--overflow-menu--light`]: light,
[`${prefix}--overflow-menu--${size}`]: size
});
const overflowMenuOptionsClasses = cx(menuOptionsClass, `${prefix}--overflow-menu-options`, {
[`${prefix}--overflow-menu--flip`]: flipped,
[`${prefix}--overflow-menu-options--open`]: open,
[`${prefix}--overflow-menu-options--light`]: light,
[`${prefix}--overflow-menu-options--${size}`]: size
});
const overflowMenuIconClasses = cx(`${prefix}--overflow-menu__icon`, iconClass);
const childrenWithProps = Children.toArray(children).map((child, index) => {
if (/*#__PURE__*/isValidElement(child)) {
const childElement = child;
return /*#__PURE__*/cloneElement(childElement, {
closeMenu: childElement.props.closeMenu || closeMenuAndFocus,
handleOverflowMenuItemFocus,
ref: el => {
menuItemRefs.current[index] = el;
},
index
});
}
return null;
});
const menuBody = /*#__PURE__*/React.createElement("ul", {
className: overflowMenuOptionsClasses,
tabIndex: -1,
role: "menu",
"aria-label": ariaLabel || deprecatedAriaLabel,
onKeyDown: handleKeyPress,
id: menuBodyId
}, childrenWithProps);
const wrappedMenuBody = /*#__PURE__*/React.createElement(FloatingMenu, {
focusTrap: focusTrap,
triggerRef: triggerRef,
menuDirection: direction,
menuOffset: flipped ? menuOffsetFlip : menuOffset,
menuRef: bindMenuBody,
flipped: flipped,
target: getTarget,
onPlace: handlePlace,
selectorPrimaryFocus: selectorPrimaryFocus
}, /*#__PURE__*/cloneElement(menuBody, {
'data-floating-menu-direction': direction
}));
const combinedRef = innerRef ? mergeRefs(triggerRef, innerRef, ref) : mergeRefs(triggerRef, ref);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", {
className: `${prefix}--overflow-menu__wrapper`,
"aria-owns": open ? menuBodyId : undefined,
ref: wrapperRef
}, /*#__PURE__*/React.createElement(IconButton, _extends({}, other, {
align: align,
type: "button",
"aria-haspopup": true,
"aria-expanded": open,
"aria-controls": open ? menuBodyId : undefined,
className: overflowMenuClasses,
onClick: handleClick,
id: id,
ref: combinedRef,
size: size,
label: iconDescription,
kind: "ghost"
}), /*#__PURE__*/React.createElement(IconElement, {
className: overflowMenuIconClasses,
"aria-label": iconDescription
})), open && hasMountedTrigger && wrappedMenuBody));
});
OverflowMenu.propTypes = {
/**
* Specify how the trigger should align with the tooltip
*/
align: deprecateValuesWithin(PropTypes.oneOf(['top', 'top-left',
// deprecated use top-start instead
'top-right',
// deprecated use top-end instead
'bottom', 'bottom-left',
// deprecated use bottom-start instead
'bottom-right',
// deprecated use bottom-end instead
'left', 'left-bottom',
// deprecated use left-end instead
'left-top',
// deprecated use left-start instead
'right', 'right-bottom',
// deprecated use right-end instead
'right-top',
// deprecated use right-start instead
// new values to match floating-ui
'top-start', 'top-end', 'bottom-start', 'bottom-end', 'left-end', 'left-start', 'right-end', 'right-start']), ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'left-start', 'left-end', 'right', 'right-start', 'right-end'], mapPopoverAlign),
/**
* Specify a label to be read by screen readers on the container node
*/
['aria-label']: PropTypes.string,
/**
* Deprecated, please use `aria-label` instead.
* Specify a label to be read by screen readers on the container note.
*/
ariaLabel: deprecate(PropTypes.string, 'This prop syntax has been deprecated. Please use the new `aria-label`.'),
/**
* The child nodes.
*/
children: PropTypes.node,
/**
* The CSS class names.
*/
className: PropTypes.string,
/**
* The menu direction.
*/
direction: PropTypes.oneOf([DIRECTION_TOP, DIRECTION_BOTTOM]),
/**
* `true` if the menu alignment should be flipped.
*/
flipped: PropTypes.bool,
/**
* Enable or disable focus trap behavior
*/
focusTrap: PropTypes.bool,
/**
* The CSS class for the icon.
*/
iconClass: PropTypes.string,
/**
* The icon description.
*/
iconDescription: PropTypes.string,
/**
* The element ID.
*/
id: PropTypes.string,
/**
* `true` to use the light version. For use on $ui-01 backgrounds only.
* Don't use this to make OverflowMenu background color same as container background color.
*/
light: deprecate(PropTypes.bool, 'The `light` prop for `OverflowMenu` is no longer needed and has been deprecated. It will be removed in the next major release. Use the Layer component instead.'),
/**
* The adjustment in position applied to the floating menu.
*/
menuOffset: PropTypes.oneOfType([PropTypes.shape({
top: PropTypes.number.isRequired,
left: PropTypes.number.isRequired
}), PropTypes.func]),
/**
* The adjustment in position applied to the floating menu.
*/
menuOffsetFlip: PropTypes.oneOfType([PropTypes.shape({
top: PropTypes.number.isRequired,
left: PropTypes.number.isRequired
}), PropTypes.func]),
/**
* The class to apply to the menu options
*/
menuOptionsClass: PropTypes.string,
/**
* The event handler for the `click` event.
*/
onClick: PropTypes.func,
/**
* Function called when menu is closed
*/
onClose: PropTypes.func,
/**
* The event handler for the `focus` event.
*/
onFocus: PropTypes.func,
/**
* The event handler for the `keydown` event.
*/
onKeyDown: PropTypes.func,
/**
* Function called when menu is opened
*/
onOpen: PropTypes.func,
/**
* `true` if the menu should be open.
*/
open: PropTypes.bool,
/**
* A component used to render an icon.
*/
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
/**
* Specify a CSS selector that matches the DOM element that should
* be focused when the OverflowMenu opens
*/
selectorPrimaryFocus: PropTypes.string,
/**
* Specify the size of the OverflowMenu. Currently supports either `sm`, `md` (default) or `lg` as an option.
*/
size: PropTypes.oneOf(['sm', 'md', 'lg'])
};
export { OverflowMenu, OverflowMenu as default, getMenuOffset };