@carbon/react
Version:
React components for the Carbon Design System
484 lines (464 loc) • 16.8 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.
*/
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js');
var React = require('react');
var iconsReact = require('@carbon/icons-react');
var cx = require('classnames');
var invariant = require('invariant');
var PropTypes = require('prop-types');
var FloatingMenu = require('../../internal/FloatingMenu.js');
var keys = require('../../internal/keyboard/keys.js');
var match = require('../../internal/keyboard/match.js');
var noopFn = require('../../internal/noopFn.js');
var usePrefix = require('../../internal/usePrefix.js');
var deprecate = require('../../prop-types/deprecate.js');
var mergeRefs = require('../../tools/mergeRefs.js');
var setupGetInstanceId = require('../../tools/setupGetInstanceId.js');
var index = require('../IconButton/index.js');
var useOutsideClick = require('../../internal/useOutsideClick.js');
var deprecateValuesWithin = require('../../prop-types/deprecateValuesWithin.js');
var mapPopoverAlign = require('../../tools/mapPopoverAlign.js');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var React__default = /*#__PURE__*/_interopDefaultLegacy(React);
var cx__default = /*#__PURE__*/_interopDefaultLegacy(cx);
var invariant__default = /*#__PURE__*/_interopDefaultLegacy(invariant);
var PropTypes__default = /*#__PURE__*/_interopDefaultLegacy(PropTypes);
const getInstanceId = setupGetInstanceId.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 = {
[FloatingMenu.DIRECTION_TOP]: 'bottom',
[FloatingMenu.DIRECTION_BOTTOM]: 'top'
};
/**
* Determines how the position of the arrow should affect the floating menu
* position.
*/
const triggerButtonPositionFactors = {
[FloatingMenu.DIRECTION_TOP]: -2,
[FloatingMenu.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__default["default"](false, '[OverflowMenu] wrong floating menu direction: `%s`', direction) : invariant__default["default"](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__*/React.forwardRef(({
align,
['aria-label']: ariaLabel = null,
ariaLabel: deprecatedAriaLabel,
children,
className,
direction = FloatingMenu.DIRECTION_BOTTOM,
flipped = false,
focusTrap = true,
iconClass,
iconDescription = 'Options',
id,
light,
menuOffset = getMenuOffset,
menuOffsetFlip = getMenuOffset,
menuOptionsClass,
onClick = noopFn.noopFn,
onClose = noopFn.noopFn,
onOpen = noopFn.noopFn,
open: openProp,
renderIcon: IconElement = iconsReact.OverflowMenuVertical,
selectorPrimaryFocus = '[data-floating-menu-primary-focus]',
size = 'md',
innerRef,
...other
}, ref) => {
const prefix = React.useContext(usePrefix.PrefixContext);
const [open, setOpen] = React.useState(openProp ?? false);
const [click, setClick] = React.useState(false);
const [hasMountedTrigger, setHasMountedTrigger] = React.useState(false);
/** The handle of `onfocusin` or `focus` event handler. */
const hFocusIn = React.useRef(null);
const instanceId = React.useRef(getInstanceId());
const menuBodyRef = React.useRef(null);
const menuItemRefs = React.useRef({});
const prevOpenProp = React.useRef(openProp);
const prevOpenState = React.useRef(open);
/** The element ref of the tooltip's trigger button. */
const triggerRef = React.useRef(null);
const wrapperRef = React.useRef(null);
// Sync open prop changes.
React.useEffect(() => {
if (prevOpenProp.current !== openProp) {
setOpen(!!openProp);
prevOpenProp.current = openProp;
}
}, [openProp]);
// Mark trigger as mounted.
React.useEffect(() => {
if (triggerRef.current) {
setHasMountedTrigger(true);
}
}, []);
React.useEffect(() => {
if (open && !prevOpenState.current) {
onOpen();
} else if (!open && prevOpenState.current) {
onClose();
}
prevOpenState.current = open;
}, [open, onClose, onOpen]);
useOutsideClick.useOutsideClick(wrapperRef, ({
target
}) => {
if (open && (!menuBodyRef.current || target instanceof Node && !menuBodyRef.current.contains(target))) {
closeMenu();
}
});
const focusMenuEl = React.useCallback(() => {
if (triggerRef.current) {
triggerRef.current.focus();
}
}, []);
const closeMenu = React.useCallback(onCloseMenu => {
setOpen(false);
// Optional callback to be executed after the state as been set to close
if (onCloseMenu) {
onCloseMenu();
}
onClose();
}, [onClose]);
const closeMenuAndFocus = React.useCallback(() => {
const wasClicked = click;
const wasOpen = open;
closeMenu(() => {
if (wasOpen && !wasClicked) {
focusMenuEl();
}
});
}, [click, open, closeMenu, focusMenuEl]);
const closeMenuOnEscape = React.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 && match.matches(evt, [keys.ArrowUp, keys.ArrowRight, keys.ArrowDown, keys.ArrowLeft])) {
evt.preventDefault();
}
// Close the overflow menu on escape
if (match.matches(evt, [keys.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 = React.Children.toArray(children).reduce((acc, curr, i) => {
if (/*#__PURE__*/React__default["default"].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__default["default"](className, `${prefix}--overflow-menu`, {
[`${prefix}--overflow-menu--open`]: open,
[`${prefix}--overflow-menu--light`]: light,
[`${prefix}--overflow-menu--${size}`]: size
});
const overflowMenuOptionsClasses = cx__default["default"](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__default["default"](`${prefix}--overflow-menu__icon`, iconClass);
const childrenWithProps = React.Children.toArray(children).map((child, index) => {
if (/*#__PURE__*/React.isValidElement(child)) {
const childElement = child;
return /*#__PURE__*/React.cloneElement(childElement, {
closeMenu: childElement.props.closeMenu || closeMenuAndFocus,
handleOverflowMenuItemFocus,
ref: el => {
menuItemRefs.current[index] = el;
},
index
});
}
return null;
});
const menuBody = /*#__PURE__*/React__default["default"].createElement("ul", {
className: overflowMenuOptionsClasses,
tabIndex: -1,
role: "menu",
"aria-label": ariaLabel || deprecatedAriaLabel,
onKeyDown: handleKeyPress,
id: menuBodyId
}, childrenWithProps);
const wrappedMenuBody = /*#__PURE__*/React__default["default"].createElement(FloatingMenu.FloatingMenu, {
focusTrap: focusTrap,
triggerRef: triggerRef,
menuDirection: direction,
menuOffset: flipped ? menuOffsetFlip : menuOffset,
menuRef: bindMenuBody,
flipped: flipped,
target: getTarget,
onPlace: handlePlace,
selectorPrimaryFocus: selectorPrimaryFocus
}, /*#__PURE__*/React.cloneElement(menuBody, {
'data-floating-menu-direction': direction
}));
const combinedRef = innerRef ? mergeRefs["default"](triggerRef, innerRef, ref) : mergeRefs["default"](triggerRef, ref);
return /*#__PURE__*/React__default["default"].createElement(React__default["default"].Fragment, null, /*#__PURE__*/React__default["default"].createElement("span", {
className: `${prefix}--overflow-menu__wrapper`,
"aria-owns": open ? menuBodyId : undefined,
ref: wrapperRef
}, /*#__PURE__*/React__default["default"].createElement(index.IconButton, _rollupPluginBabelHelpers["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__default["default"].createElement(IconElement, {
className: overflowMenuIconClasses,
"aria-label": iconDescription
})), open && hasMountedTrigger && wrappedMenuBody));
});
OverflowMenu.propTypes = {
/**
* Specify how the trigger should align with the tooltip
*/
align: deprecateValuesWithin["default"](PropTypes__default["default"].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.mapPopoverAlign),
/**
* Specify a label to be read by screen readers on the container node
*/
['aria-label']: PropTypes__default["default"].string,
/**
* Deprecated, please use `aria-label` instead.
* Specify a label to be read by screen readers on the container note.
*/
ariaLabel: deprecate["default"](PropTypes__default["default"].string, 'This prop syntax has been deprecated. Please use the new `aria-label`.'),
/**
* The child nodes.
*/
children: PropTypes__default["default"].node,
/**
* The CSS class names.
*/
className: PropTypes__default["default"].string,
/**
* The menu direction.
*/
direction: PropTypes__default["default"].oneOf([FloatingMenu.DIRECTION_TOP, FloatingMenu.DIRECTION_BOTTOM]),
/**
* `true` if the menu alignment should be flipped.
*/
flipped: PropTypes__default["default"].bool,
/**
* Enable or disable focus trap behavior
*/
focusTrap: PropTypes__default["default"].bool,
/**
* The CSS class for the icon.
*/
iconClass: PropTypes__default["default"].string,
/**
* The icon description.
*/
iconDescription: PropTypes__default["default"].string,
/**
* The element ID.
*/
id: PropTypes__default["default"].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["default"](PropTypes__default["default"].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__default["default"].oneOfType([PropTypes__default["default"].shape({
top: PropTypes__default["default"].number.isRequired,
left: PropTypes__default["default"].number.isRequired
}), PropTypes__default["default"].func]),
/**
* The adjustment in position applied to the floating menu.
*/
menuOffsetFlip: PropTypes__default["default"].oneOfType([PropTypes__default["default"].shape({
top: PropTypes__default["default"].number.isRequired,
left: PropTypes__default["default"].number.isRequired
}), PropTypes__default["default"].func]),
/**
* The class to apply to the menu options
*/
menuOptionsClass: PropTypes__default["default"].string,
/**
* The event handler for the `click` event.
*/
onClick: PropTypes__default["default"].func,
/**
* Function called when menu is closed
*/
onClose: PropTypes__default["default"].func,
/**
* The event handler for the `focus` event.
*/
onFocus: PropTypes__default["default"].func,
/**
* The event handler for the `keydown` event.
*/
onKeyDown: PropTypes__default["default"].func,
/**
* Function called when menu is opened
*/
onOpen: PropTypes__default["default"].func,
/**
* `true` if the menu should be open.
*/
open: PropTypes__default["default"].bool,
/**
* A component used to render an icon.
*/
renderIcon: PropTypes__default["default"].oneOfType([PropTypes__default["default"].func, PropTypes__default["default"].object]),
/**
* Specify a CSS selector that matches the DOM element that should
* be focused when the OverflowMenu opens
*/
selectorPrimaryFocus: PropTypes__default["default"].string,
/**
* Specify the size of the OverflowMenu. Currently supports either `sm`, `md` (default) or `lg` as an option.
*/
size: PropTypes__default["default"].oneOf(['sm', 'md', 'lg'])
};
exports.OverflowMenu = OverflowMenu;
exports["default"] = OverflowMenu;
exports.getMenuOffset = getMenuOffset;