UNPKG

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