UNPKG

10.3 kBJavaScriptView Raw
1'use client';
2
3import * as React from 'react';
4import { isFragment } from 'react-is';
5import PropTypes from 'prop-types';
6import ownerDocument from "../utils/ownerDocument.js";
7import List from "../List/index.js";
8import getScrollbarSize from "../utils/getScrollbarSize.js";
9import useForkRef from "../utils/useForkRef.js";
10import useEnhancedEffect from "../utils/useEnhancedEffect.js";
11import { ownerWindow } from "../utils/index.js";
12import { jsx as _jsx } from "react/jsx-runtime";
13function nextItem(list, item, disableListWrap) {
14 if (list === item) {
15 return list.firstChild;
16 }
17 if (item && item.nextElementSibling) {
18 return item.nextElementSibling;
19 }
20 return disableListWrap ? null : list.firstChild;
21}
22function previousItem(list, item, disableListWrap) {
23 if (list === item) {
24 return disableListWrap ? list.firstChild : list.lastChild;
25 }
26 if (item && item.previousElementSibling) {
27 return item.previousElementSibling;
28 }
29 return disableListWrap ? null : list.lastChild;
30}
31function textCriteriaMatches(nextFocus, textCriteria) {
32 if (textCriteria === undefined) {
33 return true;
34 }
35 let text = nextFocus.innerText;
36 if (text === undefined) {
37 // jsdom doesn't support innerText
38 text = nextFocus.textContent;
39 }
40 text = text.trim().toLowerCase();
41 if (text.length === 0) {
42 return false;
43 }
44 if (textCriteria.repeating) {
45 return text[0] === textCriteria.keys[0];
46 }
47 return text.startsWith(textCriteria.keys.join(''));
48}
49function moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, traversalFunction, textCriteria) {
50 let wrappedOnce = false;
51 let nextFocus = traversalFunction(list, currentFocus, currentFocus ? disableListWrap : false);
52 while (nextFocus) {
53 // Prevent infinite loop.
54 if (nextFocus === list.firstChild) {
55 if (wrappedOnce) {
56 return false;
57 }
58 wrappedOnce = true;
59 }
60
61 // Same logic as useAutocomplete.js
62 const nextFocusDisabled = disabledItemsFocusable ? false : nextFocus.disabled || nextFocus.getAttribute('aria-disabled') === 'true';
63 if (!nextFocus.hasAttribute('tabindex') || !textCriteriaMatches(nextFocus, textCriteria) || nextFocusDisabled) {
64 // Move to the next element.
65 nextFocus = traversalFunction(list, nextFocus, disableListWrap);
66 } else {
67 nextFocus.focus();
68 return true;
69 }
70 }
71 return false;
72}
73
74/**
75 * A permanently displayed menu following https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/.
76 * It's exposed to help customization of the [`Menu`](/material-ui/api/menu/) component if you
77 * use it separately you need to move focus into the component manually. Once
78 * the focus is placed inside the component it is fully keyboard accessible.
79 */
80const MenuList = /*#__PURE__*/React.forwardRef(function MenuList(props, ref) {
81 const {
82 // private
83 // eslint-disable-next-line react/prop-types
84 actions,
85 autoFocus = false,
86 autoFocusItem = false,
87 children,
88 className,
89 disabledItemsFocusable = false,
90 disableListWrap = false,
91 onKeyDown,
92 variant = 'selectedMenu',
93 ...other
94 } = props;
95 const listRef = React.useRef(null);
96 const textCriteriaRef = React.useRef({
97 keys: [],
98 repeating: true,
99 previousKeyMatched: true,
100 lastTime: null
101 });
102 useEnhancedEffect(() => {
103 if (autoFocus) {
104 listRef.current.focus();
105 }
106 }, [autoFocus]);
107 React.useImperativeHandle(actions, () => ({
108 adjustStyleForScrollbar: (containerElement, {
109 direction
110 }) => {
111 // Let's ignore that piece of logic if users are already overriding the width
112 // of the menu.
113 const noExplicitWidth = !listRef.current.style.width;
114 if (containerElement.clientHeight < listRef.current.clientHeight && noExplicitWidth) {
115 const scrollbarSize = `${getScrollbarSize(ownerWindow(containerElement))}px`;
116 listRef.current.style[direction === 'rtl' ? 'paddingLeft' : 'paddingRight'] = scrollbarSize;
117 listRef.current.style.width = `calc(100% + ${scrollbarSize})`;
118 }
119 return listRef.current;
120 }
121 }), []);
122 const handleKeyDown = event => {
123 const list = listRef.current;
124 const key = event.key;
125 const isModifierKeyPressed = event.ctrlKey || event.metaKey || event.altKey;
126 if (isModifierKeyPressed) {
127 if (onKeyDown) {
128 onKeyDown(event);
129 }
130 return;
131 }
132
133 /**
134 * @type {Element} - will always be defined since we are in a keydown handler
135 * attached to an element. A keydown event is either dispatched to the activeElement
136 * or document.body or document.documentElement. Only the first case will
137 * trigger this specific handler.
138 */
139 const currentFocus = ownerDocument(list).activeElement;
140 if (key === 'ArrowDown') {
141 // Prevent scroll of the page
142 event.preventDefault();
143 moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, nextItem);
144 } else if (key === 'ArrowUp') {
145 event.preventDefault();
146 moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, previousItem);
147 } else if (key === 'Home') {
148 event.preventDefault();
149 moveFocus(list, null, disableListWrap, disabledItemsFocusable, nextItem);
150 } else if (key === 'End') {
151 event.preventDefault();
152 moveFocus(list, null, disableListWrap, disabledItemsFocusable, previousItem);
153 } else if (key.length === 1) {
154 const criteria = textCriteriaRef.current;
155 const lowerKey = key.toLowerCase();
156 const currTime = performance.now();
157 if (criteria.keys.length > 0) {
158 // Reset
159 if (currTime - criteria.lastTime > 500) {
160 criteria.keys = [];
161 criteria.repeating = true;
162 criteria.previousKeyMatched = true;
163 } else if (criteria.repeating && lowerKey !== criteria.keys[0]) {
164 criteria.repeating = false;
165 }
166 }
167 criteria.lastTime = currTime;
168 criteria.keys.push(lowerKey);
169 const keepFocusOnCurrent = currentFocus && !criteria.repeating && textCriteriaMatches(currentFocus, criteria);
170 if (criteria.previousKeyMatched && (keepFocusOnCurrent || moveFocus(list, currentFocus, false, disabledItemsFocusable, nextItem, criteria))) {
171 event.preventDefault();
172 } else {
173 criteria.previousKeyMatched = false;
174 }
175 }
176 if (onKeyDown) {
177 onKeyDown(event);
178 }
179 };
180 const handleRef = useForkRef(listRef, ref);
181
182 /**
183 * the index of the item should receive focus
184 * in a `variant="selectedMenu"` it's the first `selected` item
185 * otherwise it's the very first item.
186 */
187 let activeItemIndex = -1;
188 // since we inject focus related props into children we have to do a lookahead
189 // to check if there is a `selected` item. We're looking for the last `selected`
190 // item and use the first valid item as a fallback
191 React.Children.forEach(children, (child, index) => {
192 if (! /*#__PURE__*/React.isValidElement(child)) {
193 if (activeItemIndex === index) {
194 activeItemIndex += 1;
195 if (activeItemIndex >= children.length) {
196 // there are no focusable items within the list.
197 activeItemIndex = -1;
198 }
199 }
200 return;
201 }
202 if (process.env.NODE_ENV !== 'production') {
203 if (isFragment(child)) {
204 console.error(["MUI: The Menu component doesn't accept a Fragment as a child.", 'Consider providing an array instead.'].join('\n'));
205 }
206 }
207 if (!child.props.disabled) {
208 if (variant === 'selectedMenu' && child.props.selected) {
209 activeItemIndex = index;
210 } else if (activeItemIndex === -1) {
211 activeItemIndex = index;
212 }
213 }
214 if (activeItemIndex === index && (child.props.disabled || child.props.muiSkipListHighlight || child.type.muiSkipListHighlight)) {
215 activeItemIndex += 1;
216 if (activeItemIndex >= children.length) {
217 // there are no focusable items within the list.
218 activeItemIndex = -1;
219 }
220 }
221 });
222 const items = React.Children.map(children, (child, index) => {
223 if (index === activeItemIndex) {
224 const newChildProps = {};
225 if (autoFocusItem) {
226 newChildProps.autoFocus = true;
227 }
228 if (child.props.tabIndex === undefined && variant === 'selectedMenu') {
229 newChildProps.tabIndex = 0;
230 }
231 return /*#__PURE__*/React.cloneElement(child, newChildProps);
232 }
233 return child;
234 });
235 return /*#__PURE__*/_jsx(List, {
236 role: "menu",
237 ref: handleRef,
238 className: className,
239 onKeyDown: handleKeyDown,
240 tabIndex: autoFocus ? 0 : -1,
241 ...other,
242 children: items
243 });
244});
245process.env.NODE_ENV !== "production" ? MenuList.propTypes /* remove-proptypes */ = {
246 // ┌────────────────────────────── Warning ──────────────────────────────┐
247 // │ These PropTypes are generated from the TypeScript type definitions. │
248 // │ To update them, edit the d.ts file and run `pnpm proptypes`. │
249 // └─────────────────────────────────────────────────────────────────────┘
250 /**
251 * If `true`, will focus the `[role="menu"]` container and move into tab order.
252 * @default false
253 */
254 autoFocus: PropTypes.bool,
255 /**
256 * If `true`, will focus the first menuitem if `variant="menu"` or selected item
257 * if `variant="selectedMenu"`.
258 * @default false
259 */
260 autoFocusItem: PropTypes.bool,
261 /**
262 * MenuList contents, normally `MenuItem`s.
263 */
264 children: PropTypes.node,
265 /**
266 * @ignore
267 */
268 className: PropTypes.string,
269 /**
270 * If `true`, will allow focus on disabled items.
271 * @default false
272 */
273 disabledItemsFocusable: PropTypes.bool,
274 /**
275 * If `true`, the menu items will not wrap focus.
276 * @default false
277 */
278 disableListWrap: PropTypes.bool,
279 /**
280 * @ignore
281 */
282 onKeyDown: PropTypes.func,
283 /**
284 * The variant to use. Use `menu` to prevent selected items from impacting the initial focus
285 * and the vertical alignment relative to the anchor element.
286 * @default 'selectedMenu'
287 */
288 variant: PropTypes.oneOf(['menu', 'selectedMenu'])
289} : void 0;
290export default MenuList;
\No newline at end of file