@carbon/react
Version:
React components for the Carbon Design System
433 lines (426 loc) • 13.7 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, useState, useContext, useRef, useEffect } from 'react';
import { useFloating, offset, autoUpdate, useInteractions, useHover, safePolygon, FloatingFocusManager } from '@floating-ui/react';
import { Checkmark, CaretLeft, CaretRight } from '@carbon/icons-react';
import { ArrowRight, Enter, Space } from '../../internal/keyboard/keys.js';
import { match } from '../../internal/keyboard/match.js';
import { useControllableState } from '../../internal/useControllableState.js';
import { useMergedRefs } from '../../internal/useMergedRefs.js';
import { usePrefix } from '../../internal/usePrefix.js';
import { Menu } from './Menu.js';
import { MenuContext } from './MenuContext.js';
import '../LayoutDirection/LayoutDirection.js';
import { useLayoutDirection } from '../LayoutDirection/useLayoutDirection.js';
import '../Text/index.js';
import { Text } from '../Text/Text.js';
var _Checkmark, _CaretLeft, _CaretRight;
const MenuItem = /*#__PURE__*/forwardRef(function MenuItem({
children,
className,
disabled,
kind = 'default',
label,
onClick,
renderIcon: IconElement,
shortcut,
...rest
}, forwardRef) {
const [submenuOpen, setSubmenuOpen] = useState(false);
const [rtl, setRtl] = useState(false);
const {
refs,
floatingStyles,
context: floatingContext
} = useFloating({
open: submenuOpen,
onOpenChange: setSubmenuOpen,
placement: rtl ? 'left-start' : 'right-start',
whileElementsMounted: autoUpdate,
middleware: [offset({
mainAxis: -6,
crossAxis: -6
})],
strategy: 'fixed'
});
const {
getReferenceProps,
getFloatingProps
} = useInteractions([useHover(floatingContext, {
delay: 100,
enabled: true,
handleClose: safePolygon({
requireIntent: false
})
})]);
const prefix = usePrefix();
const context = useContext(MenuContext);
const menuItem = useRef(null);
const ref = 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 = useRef(false);
const keyboardClickEvent = e => match(e, Enter) || match(e, Space);
function handleKeyDown(e) {
if (hasChildren && match(e, 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)
useEffect(() => {
registerItem();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Set RTL based on the document direction or `LayoutDirection`
const {
direction
} = useLayoutDirection();
useEffect(() => {
if (document?.dir === 'rtl' || direction === 'rtl') {
setRtl(true);
} else {
setRtl(false);
}
}, [direction]);
useEffect(() => {
if (IconElement && !context.state.hasIcons) {
// @ts-ignore - TODO: Should we be passing payload?
context.dispatch({
type: 'enableIcons'
});
}
}, [IconElement, context.state.hasIcons, context]);
useEffect(() => {
Object.keys(floatingStyles).forEach(style => {
if (refs.floating.current && style !== 'position') {
refs.floating.current.style[style] = floatingStyles[style];
}
});
}, [floatingStyles, refs.floating]);
return /*#__PURE__*/React.createElement(FloatingFocusManager, {
context: floatingContext,
order: ['reference', 'floating'],
modal: false
}, /*#__PURE__*/React.createElement("li", _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
}, getReferenceProps()), /*#__PURE__*/React.createElement("div", {
className: `${prefix}--menu-item__selection-icon`
}, rest['aria-checked'] && (_Checkmark || (_Checkmark = /*#__PURE__*/React.createElement(Checkmark, null)))), /*#__PURE__*/React.createElement("div", {
className: `${prefix}--menu-item__icon`
}, IconElement && /*#__PURE__*/React.createElement(IconElement, null)), /*#__PURE__*/React.createElement(Text, {
as: "div",
className: `${prefix}--menu-item__label`,
title: label
}, label), 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(CaretLeft, null)) : _CaretRight || (_CaretRight = /*#__PURE__*/React.createElement(CaretRight, null))), /*#__PURE__*/React.createElement(Menu, _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 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.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
onClick: PropTypes.func,
/**
* A component used to render an icon.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
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.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
shortcut: PropTypes.string
};
const MenuItemSelectable = /*#__PURE__*/forwardRef(function MenuItemSelectable({
className,
defaultSelected,
label,
onChange,
selected,
...rest
}, forwardRef) {
const prefix = usePrefix();
const context = useContext(MenuContext);
const [checked, setChecked] = useControllableState({
value: selected,
onChange,
defaultValue: defaultSelected ?? false
});
function handleClick(e) {
setChecked(!checked);
}
useEffect(() => {
if (!context.state.hasSelectableItems) {
// @ts-ignore - 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, _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.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
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.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
onChange: PropTypes.func,
/**
* Pass a bool to props.selected to control the state of this option.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
selected: PropTypes.bool
};
const MenuItemGroup = /*#__PURE__*/forwardRef(function MenuItemGroup({
children,
className,
label,
...rest
}, forwardRef) {
const prefix = usePrefix();
const classNames = cx(className, `${prefix}--menu-item-group`);
return /*#__PURE__*/React.createElement("li", {
className: classNames,
role: "none",
ref: forwardRef
}, /*#__PURE__*/React.createElement("ul", _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 defaultItemToString = item => item.toString();
const MenuItemRadioGroup = /*#__PURE__*/forwardRef(function MenuItemRadioGroup({
className,
defaultSelectedItem,
items,
itemToString = defaultItemToString,
label,
onChange,
selectedItem,
...rest
}, forwardRef) {
const prefix = usePrefix();
const context = useContext(MenuContext);
const [selection, setSelection] = useControllableState({
value: selectedItem,
onChange,
defaultValue: defaultSelectedItem ?? {}
});
function handleClick(item, e) {
setSelection(item);
}
useEffect(() => {
if (!context.state.hasSelectableItems) {
// @ts-ignore - 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", _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,
/**
* Provide a function to convert an item to the string that will be rendered. Defaults to item.toString().
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
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.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
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.
*/
// @ts-ignore-next-line -- avoid spurious (?) TS2322 error
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__*/forwardRef(function MenuItemDivider({
className,
...rest
}, forwardRef) {
const prefix = usePrefix();
const classNames = cx(className, `${prefix}--menu-item-divider`);
return /*#__PURE__*/React.createElement("li", _extends({}, rest, {
className: classNames,
role: "separator",
ref: forwardRef
}));
});
MenuItemDivider.propTypes = {
/**
* Additional CSS class names.
*/
className: PropTypes.string
};
export { MenuItem, MenuItemDivider, MenuItemGroup, MenuItemRadioGroup, MenuItemSelectable };