UNPKG

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