'use client'; import * as React from 'react'; import { isFragment } from 'react-is'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import composeClasses from '@mui/utils/composeClasses'; import useId from '@mui/utils/useId'; import refType from '@mui/utils/refType'; import useTimeout from '@mui/utils/useTimeout'; import ownerDocument from '../utils/ownerDocument'; import Menu from '../Menu/Menu'; import { isFilled } from '../InputBase/utils'; import inputBaseClasses from '../InputBase/inputBaseClasses'; import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses'; import { styled } from '../zero-styled'; import slotShouldForwardProp from '../styles/slotShouldForwardProp'; import useEnhancedEffect from '../utils/useEnhancedEffect'; import useEventCallback from '../utils/useEventCallback'; import useForkRef from '../utils/useForkRef'; import useControlled from '../utils/useControlled'; import selectClasses, { getSelectUtilityClasses } from './selectClasses'; import { areEqualValues, isEmpty, getOpenInteractionType } from './utils'; import { canCycleRepeatedCharacter, getMatchingOptionIndex, getTypeaheadOptions, } from './utils/closedTypeahead'; import { SelectFocusSourceProvider } from './utils/SelectFocusSourceContext'; const OPENING_MOUSE_UP_BOUNDARY_OFFSET = 2; // The initial mouseup may land on an item when the menu opens over the trigger. const SELECTED_MOUSE_UP_DELAY = 400; const UNSELECTED_MOUSE_UP_DELAY = 200; const TYPEAHEAD_RESET_MS = 750; const SPACE = ' '; const ARROW_UP = 'ArrowUp'; const ARROW_DOWN = 'ArrowDown'; const ENTER = 'Enter'; /** * Returns true when a native mouse event should be treated as happening inside * the element, even if a portal or backdrop retargeted the event away from it. * * Select uses this for the opening mouseup: when the menu opens over the * trigger, the release can target the backdrop or portaled menu even though the * pointer is still inside the trigger or menu bounds. */ function isMouseEventInsideElement(event, element) { if (!element) { return false; } const eventPath = event.composedPath(); if (eventPath.includes(element)) { return true; } if (event.target?.nodeType && element.contains(event.target)) { return true; } const rect = element.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) { // Hidden or transition-mounted elements do not have useful bounds to hit-test. return false; } return ( event.clientX >= rect.left - OPENING_MOUSE_UP_BOUNDARY_OFFSET && event.clientX <= rect.right + OPENING_MOUSE_UP_BOUNDARY_OFFSET && event.clientY >= rect.top - OPENING_MOUSE_UP_BOUNDARY_OFFSET && event.clientY <= rect.bottom + OPENING_MOUSE_UP_BOUNDARY_OFFSET ); } const SelectSelect = styled('div', { name: 'MuiSelect', slot: 'Select', overridesResolver: (props, styles) => { const { ownerState } = props; return [ // Win specificity over the input base { [`&.${selectClasses.select}`]: styles.select }, { [`&.${selectClasses.select}`]: styles[ownerState.variant] }, { [`&.${selectClasses.error}`]: styles.error }, { [`&.${selectClasses.multiple}`]: styles.multiple }, ]; }, })(({ theme }) => ({ // When interacting quickly, the text can end up selected. // Native select can't be selected either. userSelect: 'none', // Reset borderRadius: 0, cursor: 'pointer', '&:focus': { // Reset Chrome style borderRadius: 0, }, [`&.${selectClasses.disabled}`]: { cursor: 'default', }, [`& ~ .${inputAdornmentClasses.root}`]: { position: 'absolute', top: '50%', transform: 'translateY(-50%)', right: 'calc(var(--_caret, 24px) + (var(--_endAdornment, 28px) - 1.5rem)/2)', // 1.5rem is the default icon size }, [`.${inputBaseClasses.root}:has(> &)`]: { '--_endAdornment': '0px', }, // Win specificity over the input base [`&.${selectClasses.select}`]: { height: 'auto', // Resets for multiple select with chips minHeight: '1.4375em', // Required for select\text-field height consistency textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', }, variants: [ { props: ({ ownerState }) => ownerState.variant !== 'filled' && ownerState.variant !== 'outlined', style: { [`.${inputBaseClasses.root}:has(> &)`]: { '--_caret': '24px', }, [`.${inputBaseClasses.root}:has(> & ~ .${inputAdornmentClasses.root})`]: { '--_endAdornment': '28px', }, // Bump specificity to allow extending custom inputs '&&&': { paddingRight: 'calc(var(--_caret, 24px) + var(--_endAdornment, 0px))', minWidth: 16, // So it doesn't collapse. }, }, }, { props: { variant: 'filled', }, style: { [`.${inputBaseClasses.root}:has(> &)`]: { '--_caret': '32px', }, [`.${inputBaseClasses.root}:has(> & ~ .${inputAdornmentClasses.root})`]: { '--_endAdornment': '28px', }, '&&&': { paddingRight: 'calc(var(--_caret, 32px) + var(--_endAdornment, 0px))', }, }, }, { props: { variant: 'outlined', }, style: { [`.${inputBaseClasses.root}:has(> &)`]: { '--_caret': '32px', }, [`.${inputBaseClasses.root}:has(> & ~ .${inputAdornmentClasses.root})`]: { '--_endAdornment': '28px', }, borderRadius: (theme.vars || theme).shape.borderRadius, '&:focus': { borderRadius: (theme.vars || theme).shape.borderRadius, // Reset the reset for Chrome style }, '&&&': { paddingRight: 'calc(var(--_caret, 32px) + var(--_endAdornment, 0px))', }, }, }, ], })); const SelectIcon = styled('svg', { name: 'MuiSelect', slot: 'Icon', overridesResolver: (props, styles) => { const { ownerState } = props; return [styles.icon, ownerState.open && styles.iconOpen]; }, })(({ theme }) => ({ // We use a position absolute over a flexbox in order to forward the pointer events // to the input and to support wrapping tags.. position: 'absolute', right: 0, // Center vertically, height is 1em top: 'calc(50% - .5em)', // Don't block pointer events on the select under the icon. pointerEvents: 'none', color: (theme.vars || theme).palette.action.active, [`&.${selectClasses.disabled}`]: { color: (theme.vars || theme).palette.action.disabled, }, variants: [ { props: ({ ownerState }) => ownerState.open, style: { transform: 'rotate(180deg)', }, }, { props: { variant: 'filled', }, style: { right: 7, }, }, { props: { variant: 'outlined', }, style: { right: 7, }, }, ], })); const SelectNativeInput = styled('input', { shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'classes', name: 'MuiSelect', slot: 'NativeInput', })({ bottom: 0, left: 0, position: 'absolute', opacity: 0, pointerEvents: 'none', width: '100%', boxSizing: 'border-box', }); const useUtilityClasses = (ownerState) => { const { classes, variant, disabled, multiple, open, error } = ownerState; const slots = { select: ['select', variant, disabled && 'disabled', multiple && 'multiple', error && 'error'], icon: ['icon', open && 'iconOpen', disabled && 'disabled'], nativeInput: ['nativeInput'], }; return composeClasses(slots, getSelectUtilityClasses, classes); }; /** * @ignore - internal component. */ const SelectInput = React.forwardRef(function SelectInput(props, ref) { const { 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, autoFocus, autoWidth, children, className, defaultOpen, defaultValue, disabled, displayEmpty, error = false, IconComponent, inputRef: inputRefProp, labelId, MenuProps = {}, multiple, name, onBlur, onChange, onClose, onFocus, // eslint-disable-next-line react/prop-types onKeyDown, // eslint-disable-next-line react/prop-types onMouseDown, onOpen, open: openProp, readOnly, renderValue, required, SelectDisplayProps = {}, tabIndex: tabIndexProp, // catching `type` from Input which makes no sense for SelectInput type, value: valueProp, variant = 'standard', ...other } = props; const [value, setValueState] = useControlled({ controlled: valueProp, default: defaultValue, name: 'Select', }); const [openState, setOpenState] = useControlled({ controlled: openProp, default: defaultOpen, name: 'Select', }); const inputRef = React.useRef(null); const displayRef = React.useRef(null); const paperRef = React.useRef(null); const openRef = React.useRef(false); const hasSelectedItemInListRef = React.useRef(false); const openingMouseUpListenerCleanupRef = React.useRef(null); const didPointerDownOnItemRef = React.useRef(false); const selectionRef = React.useRef({ allowSelectedMouseUp: false, allowUnselectedMouseUp: false, }); const closedTypeaheadRef = React.useRef({ buffer: '', previousSearchIndex: null, matchedIndex: null, }); const selectedMouseUpTimer = useTimeout(); const unselectedMouseUpTimer = useTimeout(); const typeaheadResetTimer = useTimeout(); const [displayNode, setDisplayNode] = React.useState(null); const { current: isOpenControlled } = React.useRef(openProp != null); const [menuMinWidthState, setMenuMinWidthState] = React.useState(); const [openInteractionType, setOpenInteractionType] = React.useState(null); const handleRef = useForkRef(ref, inputRefProp); const handleDisplayRef = React.useCallback((node) => { displayRef.current = node; if (node) { setDisplayNode(node); } }, []); const anchorElement = displayNode?.parentNode; React.useImperativeHandle( handleRef, () => ({ focus: () => { displayRef.current.focus(); }, node: inputRef.current, value, }), [value], ); const open = displayNode !== null && openState; const resetClosedTypeahead = React.useCallback(() => { typeaheadResetTimer.clear(); closedTypeaheadRef.current.buffer = ''; closedTypeaheadRef.current.previousSearchIndex = null; closedTypeaheadRef.current.matchedIndex = null; }, [typeaheadResetTimer]); useEnhancedEffect(() => { openRef.current = open; if (open) { resetClosedTypeahead(); } }, [open, resetClosedTypeahead]); const clearSelectionTimers = React.useCallback(() => { selectedMouseUpTimer.clear(); unselectedMouseUpTimer.clear(); }, [selectedMouseUpTimer, unselectedMouseUpTimer]); const resetMouseUpSelection = React.useCallback(() => { clearSelectionTimers(); didPointerDownOnItemRef.current = false; selectionRef.current = { allowSelectedMouseUp: false, allowUnselectedMouseUp: false, }; }, [clearSelectionTimers]); const clearOpeningMouseUpListener = React.useCallback(() => { if (openingMouseUpListenerCleanupRef.current) { openingMouseUpListenerCleanupRef.current(); openingMouseUpListenerCleanupRef.current = null; } }, []); React.useEffect(() => { if (!open) { resetMouseUpSelection(); clearOpeningMouseUpListener(); } }, [open, resetMouseUpSelection, clearOpeningMouseUpListener]); // Keep unmount cleanup separate from the `open` effect. Effect cleanups also run // before the next effect, which would clear the opening mouseup listener while opening. React.useEffect(() => { return () => { resetMouseUpSelection(); clearOpeningMouseUpListener(); resetClosedTypeahead(); }; }, [resetMouseUpSelection, clearOpeningMouseUpListener, resetClosedTypeahead]); React.useEffect(() => { if (!open || !anchorElement || autoWidth) { return undefined; } if (typeof ResizeObserver === 'undefined') { return undefined; } const observer = new ResizeObserver(() => { setMenuMinWidthState(anchorElement.clientWidth); }); observer.observe(anchorElement); return () => { observer.disconnect(); }; }, [open, anchorElement, autoWidth]); // Resize menu on `defaultOpen` automatic toggle. React.useEffect(() => { if (defaultOpen && openState && displayNode && !isOpenControlled) { setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth); displayRef.current.focus(); } // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps }, [displayNode, autoWidth]); // `isOpenControlled` is ignored because the component should never switch between controlled and uncontrolled modes. // `defaultOpen` and `openState` are ignored to avoid unnecessary callbacks. React.useEffect(() => { if (autoFocus) { displayRef.current.focus(); } }, [autoFocus]); React.useEffect(() => { if (!labelId) { return undefined; } const label = ownerDocument(displayRef.current).getElementById(labelId); if (label) { const handler = () => { if (getSelection().isCollapsed) { displayRef.current.focus(); } }; label.addEventListener('click', handler); return () => { label.removeEventListener('click', handler); }; } return undefined; }, [labelId]); const update = useEventCallback((openParam, event) => { if (!openParam) { resetMouseUpSelection(); clearOpeningMouseUpListener(); } if (openParam) { resetClosedTypeahead(); setOpenInteractionType(getOpenInteractionType(event)); if (onOpen) { onOpen(event); } } else { setOpenInteractionType(null); if (onClose) { onClose(event); } } if (!isOpenControlled) { openRef.current = openParam; setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth); setOpenState(openParam); } }); const scheduleMouseUpSelection = () => { resetMouseUpSelection(); // When there is no selected item in the list, a mousedown // on the trigger followed by a quick mouseup over the first option can accidentally select // within 200ms. Delay unselected mouseup to match the safer 400ms window. if (!hasSelectedItemInListRef.current) { selectedMouseUpTimer.start(SELECTED_MOUSE_UP_DELAY, () => { selectionRef.current.allowSelectedMouseUp = true; selectionRef.current.allowUnselectedMouseUp = true; }); } else { // Both timers start from the mousedown. Chaining the selected timer inside the // unselected callback would compound setTimeout jitter past the 400ms window. // mousedown -> move to unselected item -> mouseup should not select within 200ms. unselectedMouseUpTimer.start(UNSELECTED_MOUSE_UP_DELAY, () => { selectionRef.current.allowUnselectedMouseUp = true; }); // mousedown -> mouseup on selected item should not select within 400ms. selectedMouseUpTimer.start(SELECTED_MOUSE_UP_DELAY, () => { selectionRef.current.allowSelectedMouseUp = true; }); } }; const handleMouseDown = (event) => { onMouseDown?.(event); // Ignore everything but left-click if (event.button !== 0) { return; } // Hijack the default focus behavior. event.preventDefault(); if (!displayRef.current) { return; } displayRef.current.focus(); const doc = ownerDocument(event.currentTarget); scheduleMouseUpSelection(); clearOpeningMouseUpListener(); const handleMouseUp = (mouseEvent) => { openingMouseUpListenerCleanupRef.current = null; if (!displayRef.current) { return; } if ( isMouseEventInsideElement(mouseEvent, displayRef.current) || isMouseEventInsideElement(mouseEvent, paperRef.current) ) { return; } if (!openRef.current && isOpenControlled) { return; } update(false, mouseEvent); }; doc.addEventListener('mouseup', handleMouseUp, { capture: true, once: true }); openingMouseUpListenerCleanupRef.current = () => { doc.removeEventListener('mouseup', handleMouseUp, true); }; update(true, event); }; const handleClose = (event) => { update(false, event); }; const childrenArray = React.Children.toArray(children); // Support autofill. const handleChange = (event) => { const child = childrenArray.find((childItem) => childItem.props.value === event.target.value); if (child === undefined) { return; } setValueState(child.props.value); if (onChange) { onChange(event, child); } }; const handleValueChange = (event, child, newValue) => { setValueState(newValue); if (onChange) { // Redefine target to allow name and value to be read. // This allows seamless integration with the most popular form libraries. // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492 // Clone the event to not override `target` of the original event. const nativeEvent = event.nativeEvent || event; // eslint-disable-next-line new-cap const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent); Object.defineProperty(clonedEvent, 'target', { writable: true, value: { value: newValue, name }, }); onChange(clonedEvent, child); } }; const handleItemClick = (child) => (event) => { didPointerDownOnItemRef.current = false; let newValue; // We use the tabindex attribute to signal the available options. if (!event.currentTarget.hasAttribute('tabindex')) { return; } if (multiple) { newValue = Array.isArray(value) ? value.slice() : []; const itemIndex = value.indexOf(child.props.value); if (itemIndex === -1) { newValue.push(child.props.value); } else { newValue.splice(itemIndex, 1); } } else { newValue = child.props.value; } if (child.props.onClick) { child.props.onClick(event); } if (value !== newValue) { handleValueChange(event, child, newValue); } if (!multiple) { update(false, event); } }; const handleItemMouseUp = (child, selected) => (event) => { child.props.onMouseUp?.(event); if (didPointerDownOnItemRef.current) { didPointerDownOnItemRef.current = false; return; } const disallowSelectedMouseUp = !selectionRef.current.allowSelectedMouseUp && selected; const disallowUnselectedMouseUp = !selectionRef.current.allowUnselectedMouseUp && !selected; if (disallowSelectedMouseUp || disallowUnselectedMouseUp) { return; } event.currentTarget.click(); }; const handleClosedTypeahead = (event) => { const state = closedTypeaheadRef.current; const hasActiveBuffer = state.buffer !== ''; if ( open || multiple || disabled || event.defaultPrevented || event.nativeEvent?.isComposing || event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey || (event.key === SPACE && !hasActiveBuffer) ) { return false; } if (event.key === SPACE) { event.preventDefault(); } const isNewSession = state.buffer === ''; const { options: searchableOptions, selectedIndex } = getTypeaheadOptions(childrenArray, value); if (searchableOptions.length === 0) { if (event.key !== SPACE) { resetClosedTypeahead(); } return true; } if (isNewSession) { state.previousSearchIndex = selectedIndex; } const key = event.key.toLowerCase(); if (state.buffer === key && canCycleRepeatedCharacter(searchableOptions, key)) { state.buffer = ''; state.previousSearchIndex = state.matchedIndex; } state.buffer += key; typeaheadResetTimer.start(TYPEAHEAD_RESET_MS, resetClosedTypeahead); const matchingIndex = getMatchingOptionIndex( searchableOptions, state.buffer, (state.previousSearchIndex ?? -1) + 1, ); if (matchingIndex !== -1) { const matchedOption = searchableOptions[matchingIndex]; state.matchedIndex = matchingIndex; if (!areEqualValues(value, matchedOption.value)) { handleValueChange(event, matchedOption.child, matchedOption.value); } return true; } if (event.key !== SPACE) { resetClosedTypeahead(); } return true; }; const handleKeyDown = (event) => { if (!readOnly) { const isClosedTypeaheadHandled = handleClosedTypeahead(event); // The native select doesn't respond to Enter on macOS, but it's recommended by // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ const isOpenKey = event.key === SPACE || event.key === ARROW_UP || event.key === ARROW_DOWN || event.key === ENTER; if (!isClosedTypeaheadHandled && isOpenKey) { event.preventDefault(); update(true, event); } onKeyDown?.(event); } }; const handleBlur = (event) => { resetClosedTypeahead(); // if open event.stopImmediatePropagation if (!open && onBlur) { // Preact support, target is read only property on a native event. Object.defineProperty(event, 'target', { writable: true, value: { value, name } }); onBlur(event); } }; const handleItemKeyDown = (child) => (event) => { child?.props?.onKeyDown?.(event); if (event.key === SPACE && event.target === event.currentTarget && !event.defaultPrevented) { // Prevent the browser from scrolling the page event.preventDefault(); // Ignore auto-repeated keydowns to avoid toggling multiple times if (!event.repeat) { // Trigger via click so that onClick receives a click event, // consistent with Enter and pointer interactions. event.currentTarget.click(); } } }; delete other['aria-invalid']; let display; let displaySingle; const displayMultiple = []; let computeDisplay = false; let foundMatch = false; // No need to display any value if the field is empty. if (isFilled({ value }) || displayEmpty) { if (renderValue) { display = renderValue(value); } else { computeDisplay = true; } } const items = childrenArray.map((child) => { if (!React.isValidElement(child)) { return null; } if (process.env.NODE_ENV !== 'production') { if (isFragment(child)) { console.error( [ "MUI: The Select component doesn't accept a Fragment as a child.", 'Consider providing an array instead.', ].join('\n'), ); } } let selected; if (multiple) { if (!Array.isArray(value)) { throw /* minify-error */ new Error( 'MUI: The `value` prop must be an array ' + 'when using the `Select` component with `multiple`.', ); } selected = value.some((v) => areEqualValues(v, child.props.value)); if (selected && computeDisplay) { displayMultiple.push(child.props.children); } } else { selected = areEqualValues(value, child.props.value); if (selected && computeDisplay) { displaySingle = child.props.children; } } if (selected) { foundMatch = true; } return React.cloneElement(child, { 'aria-selected': selected ? 'true' : 'false', onMouseDown: (event) => { didPointerDownOnItemRef.current = true; child.props.onMouseDown?.(event); }, onPointerDown: (event) => { didPointerDownOnItemRef.current = true; child.props.onPointerDown?.(event); }, onClick: handleItemClick(child), onMouseUp: handleItemMouseUp(child, selected), onKeyUp: (event) => { if (event.key === SPACE) { // otherwise our MenuItems dispatches a click event // it's not behavior of the native