UNPKG

19.8 kBJavaScriptView Raw
1'use client';
2
3import _formatMuiErrorMessage from "@mui/utils/formatMuiErrorMessage";
4var _span;
5import * as React from 'react';
6import { isFragment } from 'react-is';
7import PropTypes from 'prop-types';
8import clsx from 'clsx';
9import composeClasses from '@mui/utils/composeClasses';
10import useId from '@mui/utils/useId';
11import refType from '@mui/utils/refType';
12import ownerDocument from "../utils/ownerDocument.js";
13import capitalize from "../utils/capitalize.js";
14import Menu from "../Menu/Menu.js";
15import { StyledSelectSelect, StyledSelectIcon } from "../NativeSelect/NativeSelectInput.js";
16import { isFilled } from "../InputBase/utils.js";
17import { styled } from "../zero-styled/index.js";
18import slotShouldForwardProp from "../styles/slotShouldForwardProp.js";
19import useForkRef from "../utils/useForkRef.js";
20import useControlled from "../utils/useControlled.js";
21import selectClasses, { getSelectUtilityClasses } from "./selectClasses.js";
22import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
23const SelectSelect = styled(StyledSelectSelect, {
24 name: 'MuiSelect',
25 slot: 'Select',
26 overridesResolver: (props, styles) => {
27 const {
28 ownerState
29 } = props;
30 return [
31 // Win specificity over the input base
32 {
33 [`&.${selectClasses.select}`]: styles.select
34 }, {
35 [`&.${selectClasses.select}`]: styles[ownerState.variant]
36 }, {
37 [`&.${selectClasses.error}`]: styles.error
38 }, {
39 [`&.${selectClasses.multiple}`]: styles.multiple
40 }];
41 }
42})({
43 // Win specificity over the input base
44 [`&.${selectClasses.select}`]: {
45 height: 'auto',
46 // Resets for multiple select with chips
47 minHeight: '1.4375em',
48 // Required for select\text-field height consistency
49 textOverflow: 'ellipsis',
50 whiteSpace: 'nowrap',
51 overflow: 'hidden'
52 }
53});
54const SelectIcon = styled(StyledSelectIcon, {
55 name: 'MuiSelect',
56 slot: 'Icon',
57 overridesResolver: (props, styles) => {
58 const {
59 ownerState
60 } = props;
61 return [styles.icon, ownerState.variant && styles[`icon${capitalize(ownerState.variant)}`], ownerState.open && styles.iconOpen];
62 }
63})({});
64const SelectNativeInput = styled('input', {
65 shouldForwardProp: prop => slotShouldForwardProp(prop) && prop !== 'classes',
66 name: 'MuiSelect',
67 slot: 'NativeInput',
68 overridesResolver: (props, styles) => styles.nativeInput
69})({
70 bottom: 0,
71 left: 0,
72 position: 'absolute',
73 opacity: 0,
74 pointerEvents: 'none',
75 width: '100%',
76 boxSizing: 'border-box'
77});
78function areEqualValues(a, b) {
79 if (typeof b === 'object' && b !== null) {
80 return a === b;
81 }
82
83 // The value could be a number, the DOM will stringify it anyway.
84 return String(a) === String(b);
85}
86function isEmpty(display) {
87 return display == null || typeof display === 'string' && !display.trim();
88}
89const useUtilityClasses = ownerState => {
90 const {
91 classes,
92 variant,
93 disabled,
94 multiple,
95 open,
96 error
97 } = ownerState;
98 const slots = {
99 select: ['select', variant, disabled && 'disabled', multiple && 'multiple', error && 'error'],
100 icon: ['icon', `icon${capitalize(variant)}`, open && 'iconOpen', disabled && 'disabled'],
101 nativeInput: ['nativeInput']
102 };
103 return composeClasses(slots, getSelectUtilityClasses, classes);
104};
105
106/**
107 * @ignore - internal component.
108 */
109const SelectInput = /*#__PURE__*/React.forwardRef(function SelectInput(props, ref) {
110 const {
111 'aria-describedby': ariaDescribedby,
112 'aria-label': ariaLabel,
113 autoFocus,
114 autoWidth,
115 children,
116 className,
117 defaultOpen,
118 defaultValue,
119 disabled,
120 displayEmpty,
121 error = false,
122 IconComponent,
123 inputRef: inputRefProp,
124 labelId,
125 MenuProps = {},
126 multiple,
127 name,
128 onBlur,
129 onChange,
130 onClose,
131 onFocus,
132 onOpen,
133 open: openProp,
134 readOnly,
135 renderValue,
136 SelectDisplayProps = {},
137 tabIndex: tabIndexProp,
138 // catching `type` from Input which makes no sense for SelectInput
139 type,
140 value: valueProp,
141 variant = 'standard',
142 ...other
143 } = props;
144 const [value, setValueState] = useControlled({
145 controlled: valueProp,
146 default: defaultValue,
147 name: 'Select'
148 });
149 const [openState, setOpenState] = useControlled({
150 controlled: openProp,
151 default: defaultOpen,
152 name: 'Select'
153 });
154 const inputRef = React.useRef(null);
155 const displayRef = React.useRef(null);
156 const [displayNode, setDisplayNode] = React.useState(null);
157 const {
158 current: isOpenControlled
159 } = React.useRef(openProp != null);
160 const [menuMinWidthState, setMenuMinWidthState] = React.useState();
161 const handleRef = useForkRef(ref, inputRefProp);
162 const handleDisplayRef = React.useCallback(node => {
163 displayRef.current = node;
164 if (node) {
165 setDisplayNode(node);
166 }
167 }, []);
168 const anchorElement = displayNode?.parentNode;
169 React.useImperativeHandle(handleRef, () => ({
170 focus: () => {
171 displayRef.current.focus();
172 },
173 node: inputRef.current,
174 value
175 }), [value]);
176
177 // Resize menu on `defaultOpen` automatic toggle.
178 React.useEffect(() => {
179 if (defaultOpen && openState && displayNode && !isOpenControlled) {
180 setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth);
181 displayRef.current.focus();
182 }
183 // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler
184 // eslint-disable-next-line react-hooks/exhaustive-deps
185 }, [displayNode, autoWidth]);
186 // `isOpenControlled` is ignored because the component should never switch between controlled and uncontrolled modes.
187 // `defaultOpen` and `openState` are ignored to avoid unnecessary callbacks.
188 React.useEffect(() => {
189 if (autoFocus) {
190 displayRef.current.focus();
191 }
192 }, [autoFocus]);
193 React.useEffect(() => {
194 if (!labelId) {
195 return undefined;
196 }
197 const label = ownerDocument(displayRef.current).getElementById(labelId);
198 if (label) {
199 const handler = () => {
200 if (getSelection().isCollapsed) {
201 displayRef.current.focus();
202 }
203 };
204 label.addEventListener('click', handler);
205 return () => {
206 label.removeEventListener('click', handler);
207 };
208 }
209 return undefined;
210 }, [labelId]);
211 const update = (open, event) => {
212 if (open) {
213 if (onOpen) {
214 onOpen(event);
215 }
216 } else if (onClose) {
217 onClose(event);
218 }
219 if (!isOpenControlled) {
220 setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth);
221 setOpenState(open);
222 }
223 };
224 const handleMouseDown = event => {
225 // Ignore everything but left-click
226 if (event.button !== 0) {
227 return;
228 }
229 // Hijack the default focus behavior.
230 event.preventDefault();
231 displayRef.current.focus();
232 update(true, event);
233 };
234 const handleClose = event => {
235 update(false, event);
236 };
237 const childrenArray = React.Children.toArray(children);
238
239 // Support autofill.
240 const handleChange = event => {
241 const child = childrenArray.find(childItem => childItem.props.value === event.target.value);
242 if (child === undefined) {
243 return;
244 }
245 setValueState(child.props.value);
246 if (onChange) {
247 onChange(event, child);
248 }
249 };
250 const handleItemClick = child => event => {
251 let newValue;
252
253 // We use the tabindex attribute to signal the available options.
254 if (!event.currentTarget.hasAttribute('tabindex')) {
255 return;
256 }
257 if (multiple) {
258 newValue = Array.isArray(value) ? value.slice() : [];
259 const itemIndex = value.indexOf(child.props.value);
260 if (itemIndex === -1) {
261 newValue.push(child.props.value);
262 } else {
263 newValue.splice(itemIndex, 1);
264 }
265 } else {
266 newValue = child.props.value;
267 }
268 if (child.props.onClick) {
269 child.props.onClick(event);
270 }
271 if (value !== newValue) {
272 setValueState(newValue);
273 if (onChange) {
274 // Redefine target to allow name and value to be read.
275 // This allows seamless integration with the most popular form libraries.
276 // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
277 // Clone the event to not override `target` of the original event.
278 const nativeEvent = event.nativeEvent || event;
279 const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
280 Object.defineProperty(clonedEvent, 'target', {
281 writable: true,
282 value: {
283 value: newValue,
284 name
285 }
286 });
287 onChange(clonedEvent, child);
288 }
289 }
290 if (!multiple) {
291 update(false, event);
292 }
293 };
294 const handleKeyDown = event => {
295 if (!readOnly) {
296 const validKeys = [' ', 'ArrowUp', 'ArrowDown',
297 // The native select doesn't respond to enter on macOS, but it's recommended by
298 // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
299 'Enter'];
300 if (validKeys.includes(event.key)) {
301 event.preventDefault();
302 update(true, event);
303 }
304 }
305 };
306 const open = displayNode !== null && openState;
307 const handleBlur = event => {
308 // if open event.stopImmediatePropagation
309 if (!open && onBlur) {
310 // Preact support, target is read only property on a native event.
311 Object.defineProperty(event, 'target', {
312 writable: true,
313 value: {
314 value,
315 name
316 }
317 });
318 onBlur(event);
319 }
320 };
321 delete other['aria-invalid'];
322 let display;
323 let displaySingle;
324 const displayMultiple = [];
325 let computeDisplay = false;
326 let foundMatch = false;
327
328 // No need to display any value if the field is empty.
329 if (isFilled({
330 value
331 }) || displayEmpty) {
332 if (renderValue) {
333 display = renderValue(value);
334 } else {
335 computeDisplay = true;
336 }
337 }
338 const items = childrenArray.map(child => {
339 if (! /*#__PURE__*/React.isValidElement(child)) {
340 return null;
341 }
342 if (process.env.NODE_ENV !== 'production') {
343 if (isFragment(child)) {
344 console.error(["MUI: The Select component doesn't accept a Fragment as a child.", 'Consider providing an array instead.'].join('\n'));
345 }
346 }
347 let selected;
348 if (multiple) {
349 if (!Array.isArray(value)) {
350 throw new Error(process.env.NODE_ENV !== "production" ? 'MUI: The `value` prop must be an array ' + 'when using the `Select` component with `multiple`.' : _formatMuiErrorMessage(2));
351 }
352 selected = value.some(v => areEqualValues(v, child.props.value));
353 if (selected && computeDisplay) {
354 displayMultiple.push(child.props.children);
355 }
356 } else {
357 selected = areEqualValues(value, child.props.value);
358 if (selected && computeDisplay) {
359 displaySingle = child.props.children;
360 }
361 }
362 if (selected) {
363 foundMatch = true;
364 }
365 return /*#__PURE__*/React.cloneElement(child, {
366 'aria-selected': selected ? 'true' : 'false',
367 onClick: handleItemClick(child),
368 onKeyUp: event => {
369 if (event.key === ' ') {
370 // otherwise our MenuItems dispatches a click event
371 // it's not behavior of the native <option> and causes
372 // the select to close immediately since we open on space keydown
373 event.preventDefault();
374 }
375 if (child.props.onKeyUp) {
376 child.props.onKeyUp(event);
377 }
378 },
379 role: 'option',
380 selected,
381 value: undefined,
382 // The value is most likely not a valid HTML attribute.
383 'data-value': child.props.value // Instead, we provide it as a data attribute.
384 });
385 });
386 if (process.env.NODE_ENV !== 'production') {
387 // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler
388 // eslint-disable-next-line react-hooks/rules-of-hooks
389 React.useEffect(() => {
390 if (!foundMatch && !multiple && value !== '') {
391 const values = childrenArray.map(child => child.props.value);
392 console.warn([`MUI: 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'));
393 }
394 }, [foundMatch, childrenArray, multiple, name, value]);
395 }
396 if (computeDisplay) {
397 if (multiple) {
398 if (displayMultiple.length === 0) {
399 display = null;
400 } else {
401 display = displayMultiple.reduce((output, child, index) => {
402 output.push(child);
403 if (index < displayMultiple.length - 1) {
404 output.push(', ');
405 }
406 return output;
407 }, []);
408 }
409 } else {
410 display = displaySingle;
411 }
412 }
413
414 // Avoid performing a layout computation in the render method.
415 let menuMinWidth = menuMinWidthState;
416 if (!autoWidth && isOpenControlled && displayNode) {
417 menuMinWidth = anchorElement.clientWidth;
418 }
419 let tabIndex;
420 if (typeof tabIndexProp !== 'undefined') {
421 tabIndex = tabIndexProp;
422 } else {
423 tabIndex = disabled ? null : 0;
424 }
425 const buttonId = SelectDisplayProps.id || (name ? `mui-component-select-${name}` : undefined);
426 const ownerState = {
427 ...props,
428 variant,
429 value,
430 open,
431 error
432 };
433 const classes = useUtilityClasses(ownerState);
434 const paperProps = {
435 ...MenuProps.PaperProps,
436 ...MenuProps.slotProps?.paper
437 };
438 const listboxId = useId();
439 return /*#__PURE__*/_jsxs(React.Fragment, {
440 children: [/*#__PURE__*/_jsx(SelectSelect, {
441 as: "div",
442 ref: handleDisplayRef,
443 tabIndex: tabIndex,
444 role: "combobox",
445 "aria-controls": listboxId,
446 "aria-disabled": disabled ? 'true' : undefined,
447 "aria-expanded": open ? 'true' : 'false',
448 "aria-haspopup": "listbox",
449 "aria-label": ariaLabel,
450 "aria-labelledby": [labelId, buttonId].filter(Boolean).join(' ') || undefined,
451 "aria-describedby": ariaDescribedby,
452 onKeyDown: handleKeyDown,
453 onMouseDown: disabled || readOnly ? null : handleMouseDown,
454 onBlur: handleBlur,
455 onFocus: onFocus,
456 ...SelectDisplayProps,
457 ownerState: ownerState,
458 className: clsx(SelectDisplayProps.className, classes.select, className)
459 // The id is required for proper a11y
460 ,
461 id: buttonId,
462 children: isEmpty(display) ? // notranslate needed while Google Translate will not fix zero-width space issue
463 _span || (_span = /*#__PURE__*/_jsx("span", {
464 className: "notranslate",
465 children: "\u200B"
466 })) : display
467 }), /*#__PURE__*/_jsx(SelectNativeInput, {
468 "aria-invalid": error,
469 value: Array.isArray(value) ? value.join(',') : value,
470 name: name,
471 ref: inputRef,
472 "aria-hidden": true,
473 onChange: handleChange,
474 tabIndex: -1,
475 disabled: disabled,
476 className: classes.nativeInput,
477 autoFocus: autoFocus,
478 ...other,
479 ownerState: ownerState
480 }), /*#__PURE__*/_jsx(SelectIcon, {
481 as: IconComponent,
482 className: classes.icon,
483 ownerState: ownerState
484 }), /*#__PURE__*/_jsx(Menu, {
485 id: `menu-${name || ''}`,
486 anchorEl: anchorElement,
487 open: open,
488 onClose: handleClose,
489 anchorOrigin: {
490 vertical: 'bottom',
491 horizontal: 'center'
492 },
493 transformOrigin: {
494 vertical: 'top',
495 horizontal: 'center'
496 },
497 ...MenuProps,
498 MenuListProps: {
499 'aria-labelledby': labelId,
500 role: 'listbox',
501 'aria-multiselectable': multiple ? 'true' : undefined,
502 disableListWrap: true,
503 id: listboxId,
504 ...MenuProps.MenuListProps
505 },
506 slotProps: {
507 ...MenuProps.slotProps,
508 paper: {
509 ...paperProps,
510 style: {
511 minWidth: menuMinWidth,
512 ...(paperProps != null ? paperProps.style : null)
513 }
514 }
515 },
516 children: items
517 })]
518 });
519});
520process.env.NODE_ENV !== "production" ? SelectInput.propTypes = {
521 /**
522 * @ignore
523 */
524 'aria-describedby': PropTypes.string,
525 /**
526 * @ignore
527 */
528 'aria-label': PropTypes.string,
529 /**
530 * @ignore
531 */
532 autoFocus: PropTypes.bool,
533 /**
534 * If `true`, the width of the popover will automatically be set according to the items inside the
535 * menu, otherwise it will be at least the width of the select input.
536 */
537 autoWidth: PropTypes.bool,
538 /**
539 * The option elements to populate the select with.
540 * Can be some `<MenuItem>` elements.
541 */
542 children: PropTypes.node,
543 /**
544 * Override or extend the styles applied to the component.
545 */
546 classes: PropTypes.object,
547 /**
548 * The CSS class name of the select element.
549 */
550 className: PropTypes.string,
551 /**
552 * If `true`, the component is toggled on mount. Use when the component open state is not controlled.
553 * You can only use it when the `native` prop is `false` (default).
554 */
555 defaultOpen: PropTypes.bool,
556 /**
557 * The default value. Use when the component is not controlled.
558 */
559 defaultValue: PropTypes.any,
560 /**
561 * If `true`, the select is disabled.
562 */
563 disabled: PropTypes.bool,
564 /**
565 * If `true`, the selected item is displayed even if its value is empty.
566 */
567 displayEmpty: PropTypes.bool,
568 /**
569 * If `true`, the `select input` will indicate an error.
570 */
571 error: PropTypes.bool,
572 /**
573 * The icon that displays the arrow.
574 */
575 IconComponent: PropTypes.elementType.isRequired,
576 /**
577 * Imperative handle implementing `{ value: T, node: HTMLElement, focus(): void }`
578 * Equivalent to `ref`
579 */
580 inputRef: refType,
581 /**
582 * The ID of an element that acts as an additional label. The Select will
583 * be labelled by the additional label and the selected value.
584 */
585 labelId: PropTypes.string,
586 /**
587 * Props applied to the [`Menu`](/material-ui/api/menu/) element.
588 */
589 MenuProps: PropTypes.object,
590 /**
591 * If `true`, `value` must be an array and the menu will support multiple selections.
592 */
593 multiple: PropTypes.bool,
594 /**
595 * Name attribute of the `select` or hidden `input` element.
596 */
597 name: PropTypes.string,
598 /**
599 * @ignore
600 */
601 onBlur: PropTypes.func,
602 /**
603 * Callback fired when a menu item is selected.
604 *
605 * @param {object} event The event source of the callback.
606 * You can pull out the new value by accessing `event.target.value` (any).
607 * @param {object} [child] The react element that was selected.
608 */
609 onChange: PropTypes.func,
610 /**
611 * Callback fired when the component requests to be closed.
612 * Use in controlled mode (see open).
613 *
614 * @param {object} event The event source of the callback.
615 */
616 onClose: PropTypes.func,
617 /**
618 * @ignore
619 */
620 onFocus: PropTypes.func,
621 /**
622 * Callback fired when the component requests to be opened.
623 * Use in controlled mode (see open).
624 *
625 * @param {object} event The event source of the callback.
626 */
627 onOpen: PropTypes.func,
628 /**
629 * If `true`, the component is shown.
630 */
631 open: PropTypes.bool,
632 /**
633 * @ignore
634 */
635 readOnly: PropTypes.bool,
636 /**
637 * Render the selected value.
638 *
639 * @param {any} value The `value` provided to the component.
640 * @returns {ReactNode}
641 */
642 renderValue: PropTypes.func,
643 /**
644 * Props applied to the clickable div element.
645 */
646 SelectDisplayProps: PropTypes.object,
647 /**
648 * @ignore
649 */
650 tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
651 /**
652 * @ignore
653 */
654 type: PropTypes.any,
655 /**
656 * The input value.
657 */
658 value: PropTypes.any,
659 /**
660 * The variant to use.
661 */
662 variant: PropTypes.oneOf(['standard', 'outlined', 'filled'])
663} : void 0;
664export default SelectInput;
\No newline at end of file