UNPKG

@mui/material

Version:

Material UI is an open-source React component library that implements Google's Material Design. It's comprehensive and can be used in production out of the box.

294 lines (290 loc) 11.3 kB
'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; import { isItemFocusable } from '@mui/utils/useRovingTabIndex'; import ownerDocument from "../utils/ownerDocument.mjs"; import getActiveElement from "../utils/getActiveElement.mjs"; import getScrollbarSize from "../utils/getScrollbarSize.mjs"; import focusWithVisible from "../utils/focusWithVisible.mjs"; import useEventCallback from "../utils/useEventCallback.mjs"; import useForkRef from "../utils/useForkRef.mjs"; import useEnhancedEffect from "../utils/useEnhancedEffect.mjs"; import { RovingTabIndexContext, useRovingTabIndexRoot } from "../utils/useRovingTabIndex.mjs"; import ownerWindow from "../utils/ownerWindow.mjs"; import List from "../List/index.mjs"; import { useSelectFocusSource } from "../Select/utils/index.mjs"; import { MenuListContext } from "./MenuListContext.mjs"; import { jsx as _jsx } from "react/jsx-runtime"; function getItemText(itemOrElement) { const element = itemOrElement?.element ?? itemOrElement; if (!element) { return ''; } if (itemOrElement?.textValue !== undefined) { return itemOrElement.textValue; } let text = element.innerText; if (text === undefined) { // jsdom doesn't support innerText text = element.textContent; } return text ?? ''; } function textCriteriaMatches(itemOrElement, textCriteria) { if (textCriteria === undefined) { return true; } let text = getItemText(itemOrElement); text = text.trim().toLowerCase(); if (text.length === 0) { return false; } if (textCriteria.repeating) { return text[0] === textCriteria.keys[0]; } return text.startsWith(textCriteria.keys.join('')); } function isItemFocusableWithTextCriteria(item, criteria) { if (!textCriteriaMatches(item, criteria)) { return false; } return isItemFocusable(item); } // Menu auto-focus is not always keyboard-driven. On open we often move focus to the // active item programmatically so arrow-key navigation starts from the right place. function focusInitialItem(element, focusSource) { focusWithVisible(element, focusSource); } /** * A permanently displayed menu following https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/. * It's exposed to help customization of the [`Menu`](/material-ui/api/menu/) component if you * use it separately you need to move focus into the component manually. Once * the focus is placed inside the component it is fully keyboard accessible. */ const MenuList = /*#__PURE__*/React.forwardRef(function MenuList(props, ref) { const { // private // eslint-disable-next-line react/prop-types actions, autoFocus: autoFocusList = false, autoFocusItem: autoFocusActiveItem = false, children, className, disabledItemsFocusable = false, disableListWrap = false, onKeyDown, variant = 'selectedMenu', ...other } = props; const listRef = React.useRef(null); const hasFocusedInitialTargetRef = React.useRef(false); // Escape hatch for <Menu variant="menu"> (items have no selection state). When opened with // mouse/pointer, the initial focused item should still receive DOM focus, but ButtonBase // should suppress its focus-visible state for that one initial handoff. const [suppressInitialFocusVisible, setSuppressInitialFocusVisible] = React.useState(false); // Current anchored <Menu>s cannot receive a `openInteractionType` signal from a trigger // the API only receives `open` and `anchorEl`. When <MenuList> is used in <Select>, the // internal <SelectInput> is able to achieve this via `useSelectFocusSource`. const focusSource = useSelectFocusSource(); const textCriteriaRef = React.useRef({ keys: [], repeating: true, previousKeyMatched: true, lastTime: null }); const getDefaultActiveItemId = React.useCallback(items => { if (variant === 'selectedMenu') { return items.find(item => item.selected && isItemFocusable(item))?.id ?? items.find(item => isItemFocusable(item))?.id ?? null; } return items.find(item => isItemFocusable(item))?.id ?? null; }, [variant]); const rovingContainer = useRovingTabIndexRoot({ activeItemId: undefined, getDefaultActiveItemId, orientation: 'vertical', wrap: !disableListWrap }); const { activeItemId, focusNext, getActiveItem, getContainerProps, getItemMap } = rovingContainer; const focusInitialTarget = useEventCallback((force = false) => { // `force` is used by the imperative action when `Menu` asks `MenuList` to restore its // initial focus target after the popover finishes entering, even if this list already // completed its normal one-time initial-focus path on an earlier render. if (!listRef.current || !force && hasFocusedInitialTargetRef.current) { return null; } if (autoFocusActiveItem) { const activeItem = getActiveItem(); if (activeItem?.element) { const hasSelectedItem = Array.from(getItemMap().values()).some(item => item.selected); const shouldSuppressInitialFocusVisible = variant === 'menu' && hasSelectedItem && !activeItem.selected && focusSource == null; setSuppressInitialFocusVisible(shouldSuppressInitialFocusVisible); focusInitialItem(activeItem.element, focusSource); hasFocusedInitialTargetRef.current = true; return activeItem.element; } if (!autoFocusList) { return null; } // Keep the list container focusable while waiting for items to register, // or when there is no focusable item to move to. setSuppressInitialFocusVisible(false); listRef.current.focus(); return listRef.current; } if (!autoFocusList) { setSuppressInitialFocusVisible(false); return null; } setSuppressInitialFocusVisible(false); listRef.current.focus(); hasFocusedInitialTargetRef.current = true; return listRef.current; }); useEnhancedEffect(() => { if (!autoFocusList && !autoFocusActiveItem) { hasFocusedInitialTargetRef.current = false; setSuppressInitialFocusVisible(false); return undefined; } focusInitialTarget(); return undefined; }, [activeItemId, autoFocusActiveItem, autoFocusList, focusInitialTarget]); React.useImperativeHandle(actions, () => ({ adjustStyleForScrollbar: (containerElement, { direction }) => { // Let's ignore that piece of logic if users are already overriding the width // of the menu. const noExplicitWidth = !listRef.current.style.width; if (containerElement.clientHeight < listRef.current.clientHeight && noExplicitWidth) { const scrollbarSize = `${getScrollbarSize(ownerWindow(containerElement))}px`; listRef.current.style[direction === 'rtl' ? 'paddingLeft' : 'paddingRight'] = scrollbarSize; listRef.current.style.width = `calc(100% + ${scrollbarSize})`; } return listRef.current; }, focusInitialTarget: () => { if (!listRef.current) { return null; } const currentFocus = getActiveElement(ownerDocument(listRef.current)); if (currentFocus && listRef.current.contains(currentFocus)) { return currentFocus; } return focusInitialTarget(true); } }), [focusInitialTarget]); const rovingContainerProps = getContainerProps(); const handleRef = useForkRef(listRef, rovingContainerProps.ref, ref); const menuListContextValue = React.useMemo(() => ({ itemsFocusableWhenDisabled: disabledItemsFocusable, suppressInitialFocusVisible, variant }), [disabledItemsFocusable, suppressInitialFocusVisible, variant]); const handleKeyDown = useEventCallback(event => { if (suppressInitialFocusVisible) { setSuppressInitialFocusVisible(false); } const isModifierKeyPressed = event.ctrlKey || event.metaKey || event.altKey; if (isModifierKeyPressed && onKeyDown) { onKeyDown(event); return; } rovingContainerProps.onKeyDown(event); if (event.key.length === 1) { const criteria = textCriteriaRef.current; const lowerKey = event.key.toLowerCase(); const currTime = performance.now(); if (criteria.keys.length > 0) { // Reset if (currTime - criteria.lastTime > 500) { criteria.keys = []; criteria.repeating = true; criteria.previousKeyMatched = true; } else if (criteria.repeating && lowerKey !== criteria.keys[0]) { criteria.repeating = false; } } criteria.lastTime = currTime; criteria.keys.push(lowerKey); const currentFocus = getActiveElement(ownerDocument(listRef.current)); const keepFocusOnCurrent = currentFocus && !criteria.repeating && textCriteriaMatches(currentFocus, criteria); if (criteria.previousKeyMatched && (keepFocusOnCurrent || focusNext(item => isItemFocusableWithTextCriteria(item, criteria)) != null)) { event.preventDefault(); } else { criteria.previousKeyMatched = false; } } if (onKeyDown) { onKeyDown(event); } }); return /*#__PURE__*/_jsx(List, { role: "menu", ref: handleRef, className: className, onKeyDown: handleKeyDown, onFocus: rovingContainerProps.onFocus, tabIndex: -1, ...other, children: /*#__PURE__*/_jsx(MenuListContext.Provider, { value: menuListContextValue, children: /*#__PURE__*/_jsx(RovingTabIndexContext.Provider, { value: rovingContainer, children: children }) }) }); }); process.env.NODE_ENV !== "production" ? MenuList.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the d.ts file and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ /** * If `true`, will focus the `[role="menu"]` container and move into tab order. * @default false */ autoFocus: PropTypes.bool, /** * If `true`, will focus the first menuitem if `variant="menu"` or selected item * if `variant="selectedMenu"`. * @default false */ autoFocusItem: PropTypes.bool, /** * MenuList contents, normally `MenuItem`s. */ children: PropTypes.node, /** * @ignore */ className: PropTypes.string, /** * If `true`, will allow focus on disabled items. * @default false */ disabledItemsFocusable: PropTypes.bool, /** * If `true`, the menu items will not wrap focus. * @default false */ disableListWrap: PropTypes.bool, /** * @ignore */ onKeyDown: PropTypes.func, /** * The variant to use. Use `menu` to prevent selected items from impacting the initial focus * and the vertical alignment relative to the anchor element. * @default 'selectedMenu' */ variant: PropTypes.oneOf(['menu', 'selectedMenu']) } : void 0; export default MenuList;