You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
'use client';
|
|
|
|
import _extends from "@babel/runtime/helpers/esm/extends";
|
|
import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
|
|
const _excluded = ["actions", "autoFocus", "autoFocusItem", "children", "className", "disabledItemsFocusable", "disableListWrap", "onKeyDown", "variant"];
|
|
import * as React from 'react';
|
|
import { isFragment } from 'react-is';
|
|
import PropTypes from 'prop-types';
|
|
import ownerDocument from '../utils/ownerDocument';
|
|
import List from '../List';
|
|
import getScrollbarSize from '../utils/getScrollbarSize';
|
|
import useForkRef from '../utils/useForkRef';
|
|
import useEnhancedEffect from '../utils/useEnhancedEffect';
|
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
function nextItem(list, item, disableListWrap) {
|
|
if (list === item) {
|
|
return list.firstChild;
|
|
}
|
|
if (item && item.nextElementSibling) {
|
|
return item.nextElementSibling;
|
|
}
|
|
return disableListWrap ? null : list.firstChild;
|
|
}
|
|
function previousItem(list, item, disableListWrap) {
|
|
if (list === item) {
|
|
return disableListWrap ? list.firstChild : list.lastChild;
|
|
}
|
|
if (item && item.previousElementSibling) {
|
|
return item.previousElementSibling;
|
|
}
|
|
return disableListWrap ? null : list.lastChild;
|
|
}
|
|
function textCriteriaMatches(nextFocus, textCriteria) {
|
|
if (textCriteria === undefined) {
|
|
return true;
|
|
}
|
|
let text = nextFocus.innerText;
|
|
if (text === undefined) {
|
|
// jsdom doesn't support innerText
|
|
text = nextFocus.textContent;
|
|
}
|
|
text = text.trim().toLowerCase();
|
|
if (text.length === 0) {
|
|
return false;
|
|
}
|
|
if (textCriteria.repeating) {
|
|
return text[0] === textCriteria.keys[0];
|
|
}
|
|
return text.indexOf(textCriteria.keys.join('')) === 0;
|
|
}
|
|
function moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, traversalFunction, textCriteria) {
|
|
let wrappedOnce = false;
|
|
let nextFocus = traversalFunction(list, currentFocus, currentFocus ? disableListWrap : false);
|
|
while (nextFocus) {
|
|
// Prevent infinite loop.
|
|
if (nextFocus === list.firstChild) {
|
|
if (wrappedOnce) {
|
|
return false;
|
|
}
|
|
wrappedOnce = true;
|
|
}
|
|
|
|
// Same logic as useAutocomplete.js
|
|
const nextFocusDisabled = disabledItemsFocusable ? false : nextFocus.disabled || nextFocus.getAttribute('aria-disabled') === 'true';
|
|
if (!nextFocus.hasAttribute('tabindex') || !textCriteriaMatches(nextFocus, textCriteria) || nextFocusDisabled) {
|
|
// Move to the next element.
|
|
nextFocus = traversalFunction(list, nextFocus, disableListWrap);
|
|
} else {
|
|
nextFocus.focus();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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 = false,
|
|
autoFocusItem = false,
|
|
children,
|
|
className,
|
|
disabledItemsFocusable = false,
|
|
disableListWrap = false,
|
|
onKeyDown,
|
|
variant = 'selectedMenu'
|
|
} = props,
|
|
other = _objectWithoutPropertiesLoose(props, _excluded);
|
|
const listRef = React.useRef(null);
|
|
const textCriteriaRef = React.useRef({
|
|
keys: [],
|
|
repeating: true,
|
|
previousKeyMatched: true,
|
|
lastTime: null
|
|
});
|
|
useEnhancedEffect(() => {
|
|
if (autoFocus) {
|
|
listRef.current.focus();
|
|
}
|
|
}, [autoFocus]);
|
|
React.useImperativeHandle(actions, () => ({
|
|
adjustStyleForScrollbar: (containerElement, theme) => {
|
|
// 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(ownerDocument(containerElement))}px`;
|
|
listRef.current.style[theme.direction === 'rtl' ? 'paddingLeft' : 'paddingRight'] = scrollbarSize;
|
|
listRef.current.style.width = `calc(100% + ${scrollbarSize})`;
|
|
}
|
|
return listRef.current;
|
|
}
|
|
}), []);
|
|
const handleKeyDown = event => {
|
|
const list = listRef.current;
|
|
const key = event.key;
|
|
/**
|
|
* @type {Element} - will always be defined since we are in a keydown handler
|
|
* attached to an element. A keydown event is either dispatched to the activeElement
|
|
* or document.body or document.documentElement. Only the first case will
|
|
* trigger this specific handler.
|
|
*/
|
|
const currentFocus = ownerDocument(list).activeElement;
|
|
if (key === 'ArrowDown') {
|
|
// Prevent scroll of the page
|
|
event.preventDefault();
|
|
moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, nextItem);
|
|
} else if (key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, previousItem);
|
|
} else if (key === 'Home') {
|
|
event.preventDefault();
|
|
moveFocus(list, null, disableListWrap, disabledItemsFocusable, nextItem);
|
|
} else if (key === 'End') {
|
|
event.preventDefault();
|
|
moveFocus(list, null, disableListWrap, disabledItemsFocusable, previousItem);
|
|
} else if (key.length === 1) {
|
|
const criteria = textCriteriaRef.current;
|
|
const lowerKey = 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 keepFocusOnCurrent = currentFocus && !criteria.repeating && textCriteriaMatches(currentFocus, criteria);
|
|
if (criteria.previousKeyMatched && (keepFocusOnCurrent || moveFocus(list, currentFocus, false, disabledItemsFocusable, nextItem, criteria))) {
|
|
event.preventDefault();
|
|
} else {
|
|
criteria.previousKeyMatched = false;
|
|
}
|
|
}
|
|
if (onKeyDown) {
|
|
onKeyDown(event);
|
|
}
|
|
};
|
|
const handleRef = useForkRef(listRef, ref);
|
|
|
|
/**
|
|
* the index of the item should receive focus
|
|
* in a `variant="selectedMenu"` it's the first `selected` item
|
|
* otherwise it's the very first item.
|
|
*/
|
|
let activeItemIndex = -1;
|
|
// since we inject focus related props into children we have to do a lookahead
|
|
// to check if there is a `selected` item. We're looking for the last `selected`
|
|
// item and use the first valid item as a fallback
|
|
React.Children.forEach(children, (child, index) => {
|
|
if (! /*#__PURE__*/React.isValidElement(child)) {
|
|
if (activeItemIndex === index) {
|
|
activeItemIndex += 1;
|
|
if (activeItemIndex >= children.length) {
|
|
// there are no focusable items within the list.
|
|
activeItemIndex = -1;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
if (isFragment(child)) {
|
|
console.error(["MUI: The Menu component doesn't accept a Fragment as a child.", 'Consider providing an array instead.'].join('\n'));
|
|
}
|
|
}
|
|
if (!child.props.disabled) {
|
|
if (variant === 'selectedMenu' && child.props.selected) {
|
|
activeItemIndex = index;
|
|
} else if (activeItemIndex === -1) {
|
|
activeItemIndex = index;
|
|
}
|
|
}
|
|
if (activeItemIndex === index && (child.props.disabled || child.props.muiSkipListHighlight || child.type.muiSkipListHighlight)) {
|
|
activeItemIndex += 1;
|
|
if (activeItemIndex >= children.length) {
|
|
// there are no focusable items within the list.
|
|
activeItemIndex = -1;
|
|
}
|
|
}
|
|
});
|
|
const items = React.Children.map(children, (child, index) => {
|
|
if (index === activeItemIndex) {
|
|
const newChildProps = {};
|
|
if (autoFocusItem) {
|
|
newChildProps.autoFocus = true;
|
|
}
|
|
if (child.props.tabIndex === undefined && variant === 'selectedMenu') {
|
|
newChildProps.tabIndex = 0;
|
|
}
|
|
return /*#__PURE__*/React.cloneElement(child, newChildProps);
|
|
}
|
|
return child;
|
|
});
|
|
return /*#__PURE__*/_jsx(List, _extends({
|
|
role: "menu",
|
|
ref: handleRef,
|
|
className: className,
|
|
onKeyDown: handleKeyDown,
|
|
tabIndex: autoFocus ? 0 : -1
|
|
}, other, {
|
|
children: items
|
|
}));
|
|
});
|
|
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; |