UNPKG

14.9 kBJavaScriptView Raw
1import _extends from "@babel/runtime/helpers/esm/extends";
2import _objectWithoutPropertiesLoose from "@babel/runtime/helpers/esm/objectWithoutPropertiesLoose";
3import { formatMuiErrorMessage as _formatMuiErrorMessage } from "@material-ui/utils";
4import * as React from 'react';
5import { isFragment } from 'react-is';
6import PropTypes from 'prop-types';
7import clsx from 'clsx';
8import ownerDocument from '../utils/ownerDocument';
9import capitalize from '../utils/capitalize';
10import { refType } from '@material-ui/utils';
11import Menu from '../Menu/Menu';
12import { isFilled } from '../InputBase/utils';
13import useForkRef from '../utils/useForkRef';
14import useControlled from '../utils/useControlled';
15
16function areEqualValues(a, b) {
17 if (typeof b === 'object' && b !== null) {
18 return a === b;
19 }
20
21 return String(a) === String(b);
22}
23
24function isEmpty(display) {
25 return display == null || typeof display === 'string' && !display.trim();
26}
27/**
28 * @ignore - internal component.
29 */
30
31
32const SelectInput = /*#__PURE__*/React.forwardRef(function SelectInput(props, ref) {
33 const {
34 'aria-label': ariaLabel,
35 autoFocus,
36 autoWidth,
37 children,
38 classes,
39 className,
40 defaultValue,
41 disabled,
42 displayEmpty,
43 IconComponent,
44 inputRef: inputRefProp,
45 labelId,
46 MenuProps = {},
47 multiple,
48 name,
49 onBlur,
50 onChange,
51 onClose,
52 onFocus,
53 onOpen,
54 open: openProp,
55 readOnly,
56 renderValue,
57 SelectDisplayProps = {},
58 tabIndex: tabIndexProp,
59 value: valueProp,
60 variant = 'standard'
61 } = props,
62 other = _objectWithoutPropertiesLoose(props, ["aria-label", "autoFocus", "autoWidth", "children", "classes", "className", "defaultValue", "disabled", "displayEmpty", "IconComponent", "inputRef", "labelId", "MenuProps", "multiple", "name", "onBlur", "onChange", "onClose", "onFocus", "onOpen", "open", "readOnly", "renderValue", "SelectDisplayProps", "tabIndex", "type", "value", "variant"]);
63
64 const [value, setValue] = useControlled({
65 controlled: valueProp,
66 default: defaultValue,
67 name: 'Select'
68 });
69 const inputRef = React.useRef(null);
70 const [displayNode, setDisplayNode] = React.useState(null);
71 const {
72 current: isOpenControlled
73 } = React.useRef(openProp != null);
74 const [menuMinWidthState, setMenuMinWidthState] = React.useState();
75 const [openState, setOpenState] = React.useState(false);
76 const handleRef = useForkRef(ref, inputRefProp);
77 React.useImperativeHandle(handleRef, () => ({
78 focus: () => {
79 displayNode.focus();
80 },
81 node: inputRef.current,
82 value
83 }), [displayNode, value]);
84 React.useEffect(() => {
85 if (autoFocus && displayNode) {
86 displayNode.focus();
87 }
88 }, [autoFocus, displayNode]);
89 React.useEffect(() => {
90 if (displayNode) {
91 const label = ownerDocument(displayNode).getElementById(labelId);
92
93 if (label) {
94 const handler = () => {
95 if (getSelection().isCollapsed) {
96 displayNode.focus();
97 }
98 };
99
100 label.addEventListener('click', handler);
101 return () => {
102 label.removeEventListener('click', handler);
103 };
104 }
105 }
106
107 return undefined;
108 }, [labelId, displayNode]);
109
110 const update = (open, event) => {
111 if (open) {
112 if (onOpen) {
113 onOpen(event);
114 }
115 } else if (onClose) {
116 onClose(event);
117 }
118
119 if (!isOpenControlled) {
120 setMenuMinWidthState(autoWidth ? null : displayNode.clientWidth);
121 setOpenState(open);
122 }
123 };
124
125 const handleMouseDown = event => {
126 // Ignore everything but left-click
127 if (event.button !== 0) {
128 return;
129 } // Hijack the default focus behavior.
130
131
132 event.preventDefault();
133 displayNode.focus();
134 update(true, event);
135 };
136
137 const handleClose = event => {
138 update(false, event);
139 };
140
141 const childrenArray = React.Children.toArray(children); // Support autofill.
142
143 const handleChange = event => {
144 const index = childrenArray.map(child => child.props.value).indexOf(event.target.value);
145
146 if (index === -1) {
147 return;
148 }
149
150 const child = childrenArray[index];
151 setValue(child.props.value);
152
153 if (onChange) {
154 onChange(event, child);
155 }
156 };
157
158 const handleItemClick = child => event => {
159 if (!multiple) {
160 update(false, event);
161 }
162
163 let newValue;
164
165 if (multiple) {
166 newValue = Array.isArray(value) ? value.slice() : [];
167 const itemIndex = value.indexOf(child.props.value);
168
169 if (itemIndex === -1) {
170 newValue.push(child.props.value);
171 } else {
172 newValue.splice(itemIndex, 1);
173 }
174 } else {
175 newValue = child.props.value;
176 }
177
178 if (child.props.onClick) {
179 child.props.onClick(event);
180 }
181
182 if (value === newValue) {
183 return;
184 }
185
186 setValue(newValue);
187
188 if (onChange) {
189 event.persist(); // Preact support, target is read only property on a native event.
190
191 Object.defineProperty(event, 'target', {
192 writable: true,
193 value: {
194 value: newValue,
195 name
196 }
197 });
198 onChange(event, child);
199 }
200 };
201
202 const handleKeyDown = event => {
203 if (!readOnly) {
204 const validKeys = [' ', 'ArrowUp', 'ArrowDown', // The native select doesn't respond to enter on MacOS, but it's recommended by
205 // https://www.w3.org/TR/wai-aria-practices/examples/listbox/listbox-collapsible.html
206 'Enter'];
207
208 if (validKeys.indexOf(event.key) !== -1) {
209 event.preventDefault();
210 update(true, event);
211 }
212 }
213 };
214
215 const open = displayNode !== null && (isOpenControlled ? openProp : openState);
216
217 const handleBlur = event => {
218 // if open event.stopImmediatePropagation
219 if (!open && onBlur) {
220 event.persist(); // Preact support, target is read only property on a native event.
221
222 Object.defineProperty(event, 'target', {
223 writable: true,
224 value: {
225 value,
226 name
227 }
228 });
229 onBlur(event);
230 }
231 };
232
233 delete other['aria-invalid'];
234 let display;
235 let displaySingle;
236 const displayMultiple = [];
237 let computeDisplay = false;
238 let foundMatch = false; // No need to display any value if the field is empty.
239
240 if (isFilled({
241 value
242 }) || displayEmpty) {
243 if (renderValue) {
244 display = renderValue(value);
245 } else {
246 computeDisplay = true;
247 }
248 }
249
250 const items = childrenArray.map(child => {
251 if (! /*#__PURE__*/React.isValidElement(child)) {
252 return null;
253 }
254
255 if (process.env.NODE_ENV !== 'production') {
256 if (isFragment(child)) {
257 console.error(["Material-UI: The Select component doesn't accept a Fragment as a child.", 'Consider providing an array instead.'].join('\n'));
258 }
259 }
260
261 let selected;
262
263 if (multiple) {
264 if (!Array.isArray(value)) {
265 throw new Error(process.env.NODE_ENV !== "production" ? `Material-UI: The \`value\` prop must be an array when using the \`Select\` component with \`multiple\`.` : _formatMuiErrorMessage(2));
266 }
267
268 selected = value.some(v => areEqualValues(v, child.props.value));
269
270 if (selected && computeDisplay) {
271 displayMultiple.push(child.props.children);
272 }
273 } else {
274 selected = areEqualValues(value, child.props.value);
275
276 if (selected && computeDisplay) {
277 displaySingle = child.props.children;
278 }
279 }
280
281 if (selected) {
282 foundMatch = true;
283 }
284
285 return /*#__PURE__*/React.cloneElement(child, {
286 'aria-selected': selected ? 'true' : undefined,
287 onClick: handleItemClick(child),
288 onKeyUp: event => {
289 if (event.key === ' ') {
290 // otherwise our MenuItems dispatches a click event
291 // it's not behavior of the native <option> and causes
292 // the select to close immediately since we open on space keydown
293 event.preventDefault();
294 }
295
296 if (child.props.onKeyUp) {
297 child.props.onKeyUp(event);
298 }
299 },
300 role: 'option',
301 selected,
302 value: undefined,
303 // The value is most likely not a valid HTML attribute.
304 'data-value': child.props.value // Instead, we provide it as a data attribute.
305
306 });
307 });
308
309 if (process.env.NODE_ENV !== 'production') {
310 // eslint-disable-next-line react-hooks/rules-of-hooks
311 React.useEffect(() => {
312 if (!foundMatch && !multiple && value !== '') {
313 const values = childrenArray.map(child => child.props.value);
314 console.warn([`Material-UI: You have provided an out-of-range value \`${value}\` for the select ${name ? `(name="${name}") ` : ''}component.`, "Consider providing a value that matches one of the available options or ''.", `The available values are ${values.filter(x => x != null).map(x => `\`${x}\``).join(', ') || '""'}.`].join('\n'));
315 }
316 }, [foundMatch, childrenArray, multiple, name, value]);
317 }
318
319 if (computeDisplay) {
320 display = multiple ? displayMultiple.join(', ') : displaySingle;
321 } // Avoid performing a layout computation in the render method.
322
323
324 let menuMinWidth = menuMinWidthState;
325
326 if (!autoWidth && isOpenControlled && displayNode) {
327 menuMinWidth = displayNode.clientWidth;
328 }
329
330 let tabIndex;
331
332 if (typeof tabIndexProp !== 'undefined') {
333 tabIndex = tabIndexProp;
334 } else {
335 tabIndex = disabled ? null : 0;
336 }
337
338 const buttonId = SelectDisplayProps.id || (name ? `mui-component-select-${name}` : undefined);
339 return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", _extends({
340 className: clsx(classes.root, // TODO v5: merge root and select
341 classes.select, classes.selectMenu, classes[variant], className, disabled && classes.disabled),
342 ref: setDisplayNode,
343 tabIndex: tabIndex,
344 role: "button",
345 "aria-disabled": disabled ? 'true' : undefined,
346 "aria-expanded": open ? 'true' : undefined,
347 "aria-haspopup": "listbox",
348 "aria-label": ariaLabel,
349 "aria-labelledby": [labelId, buttonId].filter(Boolean).join(' ') || undefined,
350 onKeyDown: handleKeyDown,
351 onMouseDown: disabled || readOnly ? null : handleMouseDown,
352 onBlur: handleBlur,
353 onFocus: onFocus
354 }, SelectDisplayProps, {
355 // The id is required for proper a11y
356 id: buttonId
357 }), isEmpty(display) ?
358 /*#__PURE__*/
359 // eslint-disable-next-line react/no-danger
360 React.createElement("span", {
361 dangerouslySetInnerHTML: {
362 __html: '&#8203;'
363 }
364 }) : display), /*#__PURE__*/React.createElement("input", _extends({
365 value: Array.isArray(value) ? value.join(',') : value,
366 name: name,
367 ref: inputRef,
368 "aria-hidden": true,
369 onChange: handleChange,
370 tabIndex: -1,
371 className: classes.nativeInput,
372 autoFocus: autoFocus
373 }, other)), /*#__PURE__*/React.createElement(IconComponent, {
374 className: clsx(classes.icon, classes[`icon${capitalize(variant)}`], open && classes.iconOpen, disabled && classes.disabled)
375 }), /*#__PURE__*/React.createElement(Menu, _extends({
376 id: `menu-${name || ''}`,
377 anchorEl: displayNode,
378 open: open,
379 onClose: handleClose
380 }, MenuProps, {
381 MenuListProps: _extends({
382 'aria-labelledby': labelId,
383 role: 'listbox',
384 disableListWrap: true
385 }, MenuProps.MenuListProps),
386 PaperProps: _extends({}, MenuProps.PaperProps, {
387 style: _extends({
388 minWidth: menuMinWidth
389 }, MenuProps.PaperProps != null ? MenuProps.PaperProps.style : null)
390 })
391 }), items));
392});
393process.env.NODE_ENV !== "production" ? SelectInput.propTypes = {
394 /**
395 * @ignore
396 */
397 'aria-label': PropTypes.string,
398
399 /**
400 * @ignore
401 */
402 autoFocus: PropTypes.bool,
403
404 /**
405 * If `true`, the width of the popover will automatically be set according to the items inside the
406 * menu, otherwise it will be at least the width of the select input.
407 */
408 autoWidth: PropTypes.bool,
409
410 /**
411 * The option elements to populate the select with.
412 * Can be some `<MenuItem>` elements.
413 */
414 children: PropTypes.node,
415
416 /**
417 * Override or extend the styles applied to the component.
418 * See [CSS API](#css) below for more details.
419 */
420 classes: PropTypes.object.isRequired,
421
422 /**
423 * The CSS class name of the select element.
424 */
425 className: PropTypes.string,
426
427 /**
428 * The default element value. Use when the component is not controlled.
429 */
430 defaultValue: PropTypes.any,
431
432 /**
433 * If `true`, the select will be disabled.
434 */
435 disabled: PropTypes.bool,
436
437 /**
438 * If `true`, the selected item is displayed even if its value is empty.
439 */
440 displayEmpty: PropTypes.bool,
441
442 /**
443 * The icon that displays the arrow.
444 */
445 IconComponent: PropTypes.elementType.isRequired,
446
447 /**
448 * Imperative handle implementing `{ value: T, node: HTMLElement, focus(): void }`
449 * Equivalent to `ref`
450 */
451 inputRef: refType,
452
453 /**
454 * The ID of an element that acts as an additional label. The Select will
455 * be labelled by the additional label and the selected value.
456 */
457 labelId: PropTypes.string,
458
459 /**
460 * Props applied to the [`Menu`](/api/menu/) element.
461 */
462 MenuProps: PropTypes.object,
463
464 /**
465 * If `true`, `value` must be an array and the menu will support multiple selections.
466 */
467 multiple: PropTypes.bool,
468
469 /**
470 * Name attribute of the `select` or hidden `input` element.
471 */
472 name: PropTypes.string,
473
474 /**
475 * @ignore
476 */
477 onBlur: PropTypes.func,
478
479 /**
480 * Callback function fired when a menu item is selected.
481 *
482 * @param {object} event The event source of the callback.
483 * You can pull out the new value by accessing `event.target.value` (any).
484 * @param {object} [child] The react element that was selected.
485 */
486 onChange: PropTypes.func,
487
488 /**
489 * Callback fired when the component requests to be closed.
490 * Use in controlled mode (see open).
491 *
492 * @param {object} event The event source of the callback.
493 */
494 onClose: PropTypes.func,
495
496 /**
497 * @ignore
498 */
499 onFocus: PropTypes.func,
500
501 /**
502 * Callback fired when the component requests to be opened.
503 * Use in controlled mode (see open).
504 *
505 * @param {object} event The event source of the callback.
506 */
507 onOpen: PropTypes.func,
508
509 /**
510 * Control `select` open state.
511 */
512 open: PropTypes.bool,
513
514 /**
515 * @ignore
516 */
517 readOnly: PropTypes.bool,
518
519 /**
520 * Render the selected value.
521 *
522 * @param {any} value The `value` provided to the component.
523 * @returns {ReactNode}
524 */
525 renderValue: PropTypes.func,
526
527 /**
528 * Props applied to the clickable div element.
529 */
530 SelectDisplayProps: PropTypes.object,
531
532 /**
533 * @ignore
534 */
535 tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
536
537 /**
538 * @ignore
539 */
540 type: PropTypes.any,
541
542 /**
543 * The input value.
544 */
545 value: PropTypes.any,
546
547 /**
548 * The variant to use.
549 */
550 variant: PropTypes.oneOf(['standard', 'outlined', 'filled'])
551} : void 0;
552export default SelectInput;
\No newline at end of file