UNPKG

@carbon/react

Version:

React components for the Carbon Design System

433 lines (426 loc) 13.7 kB
/** * 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 };