@carbon/react
Version:
React components for the Carbon Design System
441 lines (433 loc) • 14.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.
*/
'use strict';
var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js');
var cx = require('classnames');
var PropTypes = require('prop-types');
var React = require('react');
var react = require('@floating-ui/react');
var iconsReact = require('@carbon/icons-react');
var keys = require('../../internal/keyboard/keys.js');
var match = require('../../internal/keyboard/match.js');
var useControllableState = require('../../internal/useControllableState.js');
var useMergedRefs = require('../../internal/useMergedRefs.js');
var usePrefix = require('../../internal/usePrefix.js');
var useId = require('../../internal/useId.js');
var Menu = require('./Menu.js');
var MenuContext = require('./MenuContext.js');
require('../LayoutDirection/LayoutDirection.js');
var useLayoutDirection = require('../LayoutDirection/useLayoutDirection.js');
var Text = require('../Text/Text.js');
require('../Text/TextDirection.js');
var defaultItemToString = require('../../internal/defaultItemToString.js');
var _Checkmark, _CaretLeft, _CaretRight;
const MenuItem = /*#__PURE__*/React.forwardRef(function MenuItem({
children,
className,
dangerDescription = 'danger',
disabled,
kind = 'default',
label,
onClick,
renderIcon: IconElement,
shortcut,
...rest
}, forwardRef) {
const [submenuOpen, setSubmenuOpen] = React.useState(false);
const [rtl, setRtl] = React.useState(false);
const {
refs,
floatingStyles,
context: floatingContext
} = react.useFloating({
open: submenuOpen,
onOpenChange: setSubmenuOpen,
placement: rtl ? 'left-start' : 'right-start',
whileElementsMounted: react.autoUpdate,
middleware: [react.offset({
mainAxis: -6,
crossAxis: -6
})],
strategy: 'fixed'
});
const {
getReferenceProps,
getFloatingProps
} = react.useInteractions([react.useHover(floatingContext, {
delay: 100,
enabled: true,
handleClose: react.safePolygon({
requireIntent: false
})
})]);
const prefix = usePrefix.usePrefix();
const context = React.useContext(MenuContext.MenuContext);
const menuItem = React.useRef(null);
const ref = useMergedRefs.useMergedRefs([forwardRef, menuItem, refs.setReference]);
const hasChildren = Boolean(children);
const isDisabled = disabled && !hasChildren;
const isDanger = kind === 'danger' && !hasChildren;
function registerItem() {
context.dispatch({
type: 'registerItem',
payload: {
ref: menuItem,
disabled: Boolean(disabled)
}
});
}
function openSubmenu() {
if (!menuItem.current) {
return;
}
setSubmenuOpen(true);
}
function closeSubmenu() {
setSubmenuOpen(false);
}
function handleClick(e) {
if (!isDisabled) {
if (hasChildren) {
openSubmenu();
} else {
context.state.requestCloseRoot(e);
if (onClick) {
onClick(e);
}
}
}
}
// Avoid stray keyup event from MenuButton affecting MenuItem, and vice versa.
// Keyboard click is handled differently for <button> vs. <li> and for Enter vs. Space. See
// https://www.stefanjudis.com/today-i-learned/keyboard-button-clicks-with-space-and-enter-behave-differently/.
const pendingKeyboardClick = React.useRef(false);
const keyboardClickEvent = e => match.match(e, keys.Enter) || match.match(e, keys.Space);
function handleKeyDown(e) {
if (hasChildren && match.match(e, keys.ArrowRight)) {
openSubmenu();
requestAnimationFrame(() => {
refs.floating.current?.focus();
});
e.stopPropagation();
e.preventDefault();
}
pendingKeyboardClick.current = keyboardClickEvent(e);
if (rest.onKeyDown) {
rest.onKeyDown(e);
}
}
function handleKeyUp(e) {
if (pendingKeyboardClick.current && keyboardClickEvent(e)) {
handleClick(e);
}
pendingKeyboardClick.current = false;
}
const classNames = cx(className, `${prefix}--menu-item`, {
[`${prefix}--menu-item--disabled`]: isDisabled,
[`${prefix}--menu-item--danger`]: isDanger
});
// on first render, register this menuitem in the context's state
// (used for keyboard navigation)
React.useEffect(() => {
registerItem();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Set RTL based on the document direction or `LayoutDirection`
const {
direction
} = useLayoutDirection.useLayoutDirection();
React.useEffect(() => {
if (document?.dir === 'rtl' || direction === 'rtl') {
setRtl(true);
} else {
setRtl(false);
}
}, [direction]);
React.useEffect(() => {
if (IconElement && !context.state.hasIcons) {
// @ts-expect-error - TODO: Should we be passing payload?
context.dispatch({
type: 'enableIcons'
});
}
}, [IconElement, context.state.hasIcons, context]);
React.useEffect(() => {
Object.keys(floatingStyles).forEach(style => {
if (refs.floating.current && style !== 'position') {
refs.floating.current.style[style] = floatingStyles[style];
}
});
}, [floatingStyles, refs.floating]);
const assistiveId = useId.useId('danger-description');
return /*#__PURE__*/React.createElement(react.FloatingFocusManager, {
context: floatingContext,
order: ['reference', 'floating'],
modal: false
}, /*#__PURE__*/React.createElement("li", _rollupPluginBabelHelpers.extends({
role: "menuitem"
}, rest, {
ref: ref,
className: classNames,
tabIndex: !disabled ? 0 : -1,
"aria-disabled": isDisabled ?? undefined,
"aria-haspopup": hasChildren ?? undefined,
"aria-expanded": hasChildren ? submenuOpen : undefined,
onClick: handleClick,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
title: label
}, getReferenceProps()), /*#__PURE__*/React.createElement("div", {
className: `${prefix}--menu-item__selection-icon`
}, rest['aria-checked'] && (_Checkmark || (_Checkmark = /*#__PURE__*/React.createElement(iconsReact.Checkmark, null)))), /*#__PURE__*/React.createElement("div", {
className: `${prefix}--menu-item__icon`
}, IconElement && /*#__PURE__*/React.createElement(IconElement, null)), /*#__PURE__*/React.createElement(Text.Text, {
as: "div",
className: `${prefix}--menu-item__label`
}, label), isDanger && /*#__PURE__*/React.createElement("span", {
id: assistiveId,
className: `${prefix}--visually-hidden`
}, dangerDescription), shortcut && !hasChildren && /*#__PURE__*/React.createElement("div", {
className: `${prefix}--menu-item__shortcut`
}, shortcut), hasChildren && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
className: `${prefix}--menu-item__shortcut`
}, rtl ? _CaretLeft || (_CaretLeft = /*#__PURE__*/React.createElement(iconsReact.CaretLeft, null)) : _CaretRight || (_CaretRight = /*#__PURE__*/React.createElement(iconsReact.CaretRight, null))), /*#__PURE__*/React.createElement(Menu.Menu, _rollupPluginBabelHelpers.extends({
label: label,
open: submenuOpen,
onClose: () => {
closeSubmenu();
menuItem.current?.focus();
},
ref: refs.setFloating
}, getFloatingProps()), children))));
});
MenuItem.propTypes = {
/**
* Optionally provide another Menu to create a submenu. props.children can't be used to specify the content of the MenuItem itself. Use props.label instead.
*/
children: PropTypes.node,
/**
* Additional CSS class names.
*/
className: PropTypes.string,
/**
* Specify the message read by screen readers for the danger menu item variant
*/
dangerDescription: PropTypes.string,
/**
* Specify whether the MenuItem is disabled or not.
*/
disabled: PropTypes.bool,
/**
* Specify the kind of the MenuItem.
*/
kind: PropTypes.oneOf(['default', 'danger']),
/**
* A required label titling the MenuItem. Will be rendered as its text content.
*/
label: PropTypes.string.isRequired,
/**
* Provide an optional function to be called when the MenuItem is clicked.
*/
onClick: PropTypes.func,
/**
* A component used to render an icon.
*/
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
/**
* Provide a shortcut for the action of this MenuItem. Note that the component will only render it as a hint but not actually register the shortcut.
*/
shortcut: PropTypes.string
};
const MenuItemSelectable = /*#__PURE__*/React.forwardRef(function MenuItemSelectable({
className,
defaultSelected,
label,
onChange,
selected,
...rest
}, forwardRef) {
const prefix = usePrefix.usePrefix();
const context = React.useContext(MenuContext.MenuContext);
const [checked, setChecked] = useControllableState.useControllableState({
value: selected,
onChange,
defaultValue: defaultSelected ?? false
});
function handleClick() {
setChecked(!checked);
}
React.useEffect(() => {
if (!context.state.hasSelectableItems) {
// @ts-expect-error - TODO: Should we be passing payload?
context.dispatch({
type: 'enableSelectableItems'
});
}
}, [context.state.hasSelectableItems, context]);
const classNames = cx(className, `${prefix}--menu-item-selectable--selected`);
return /*#__PURE__*/React.createElement(MenuItem, _rollupPluginBabelHelpers.extends({}, rest, {
ref: forwardRef,
label: label,
className: classNames,
role: "menuitemcheckbox",
"aria-checked": checked,
onClick: handleClick
}));
});
MenuItemSelectable.propTypes = {
/**
* Additional CSS class names.
*/
className: PropTypes.string,
/**
* Specify whether the option should be selected by default.
*/
defaultSelected: PropTypes.bool,
/**
* A required label titling this option.
*/
label: PropTypes.string.isRequired,
/**
* Provide an optional function to be called when the selection state changes.
*/
onChange: PropTypes.func,
/**
* Pass a bool to props.selected to control the state of this option.
*/
selected: PropTypes.bool
};
const MenuItemGroup = /*#__PURE__*/React.forwardRef(function MenuItemGroup({
children,
className,
label,
...rest
}, forwardRef) {
const prefix = usePrefix.usePrefix();
const classNames = cx(className, `${prefix}--menu-item-group`);
return /*#__PURE__*/React.createElement("li", {
className: classNames,
role: "none",
ref: forwardRef
}, /*#__PURE__*/React.createElement("ul", _rollupPluginBabelHelpers.extends({}, rest, {
role: "group",
"aria-label": label
}), children));
});
MenuItemGroup.propTypes = {
/**
* A collection of MenuItems to be rendered within this group.
*/
children: PropTypes.node,
/**
* Additional CSS class names.
*/
className: PropTypes.string,
/**
* A required label titling this group.
*/
label: PropTypes.string.isRequired
};
const MenuItemRadioGroup = /*#__PURE__*/React.forwardRef(function MenuItemRadioGroup({
className,
defaultSelectedItem,
items,
itemToString = defaultItemToString.defaultItemToString,
label,
onChange,
selectedItem,
...rest
}, forwardRef) {
const prefix = usePrefix.usePrefix();
const context = React.useContext(MenuContext.MenuContext);
const [selection, setSelection] = useControllableState.useControllableState({
value: selectedItem,
onChange,
defaultValue: defaultSelectedItem ?? {}
});
//eslint-disable-next-line @typescript-eslint/no-unused-vars -- https://github.com/carbon-design-system/carbon/issues/20452
function handleClick(item, e) {
setSelection(item);
}
React.useEffect(() => {
if (!context.state.hasSelectableItems) {
// @ts-expect-error - TODO: Should we be passing payload?
context.dispatch({
type: 'enableSelectableItems'
});
}
}, [context.state.hasSelectableItems, context]);
const classNames = cx(className, `${prefix}--menu-item-radio-group`);
return /*#__PURE__*/React.createElement("li", {
className: classNames,
role: "none",
ref: forwardRef
}, /*#__PURE__*/React.createElement("ul", _rollupPluginBabelHelpers.extends({}, rest, {
role: "group",
"aria-label": label
}), items.map((item, i) => /*#__PURE__*/React.createElement(MenuItem, {
key: i,
label: itemToString(item),
role: "menuitemradio",
"aria-checked": item === selection,
onClick: e => {
handleClick(item);
}
}))));
});
MenuItemRadioGroup.propTypes = {
/**
* Additional CSS class names.
*/
className: PropTypes.string,
/**
* Specify the default selected item. Must match the type of props.items.
*/
defaultSelectedItem: PropTypes.any,
/**
* Converts an item into a string for display.
*/
itemToString: PropTypes.func,
/**
* Provide the options for this radio group. Can be of any type, as long as you provide an appropriate props.itemToString function.
*/
items: PropTypes.array,
/**
* A required label titling this radio group.
*/
label: PropTypes.string.isRequired,
/**
* Provide an optional function to be called when the selection changes.
*/
onChange: PropTypes.func,
/**
* Provide props.selectedItem to control the state of this radio group. Must match the type of props.items.
*/
selectedItem: PropTypes.any
};
const MenuItemDivider = /*#__PURE__*/React.forwardRef(function MenuItemDivider({
className,
...rest
}, forwardRef) {
const prefix = usePrefix.usePrefix();
const classNames = cx(className, `${prefix}--menu-item-divider`);
return /*#__PURE__*/React.createElement("li", _rollupPluginBabelHelpers.extends({}, rest, {
className: classNames,
role: "separator",
ref: forwardRef
}));
});
MenuItemDivider.propTypes = {
/**
* Additional CSS class names.
*/
className: PropTypes.string
};
exports.MenuItem = MenuItem;
exports.MenuItemDivider = MenuItemDivider;
exports.MenuItemGroup = MenuItemGroup;
exports.MenuItemRadioGroup = MenuItemRadioGroup;
exports.MenuItemSelectable = MenuItemSelectable;