diff --git a/packages/mui-material-next/src/Select/Select.d.ts b/packages/mui-material-next/src/Select/Select.d.ts new file mode 100644 index 00000000000000..afbfacbaabbe18 --- /dev/null +++ b/packages/mui-material-next/src/Select/Select.d.ts @@ -0,0 +1,174 @@ +import * as React from 'react'; +import { SxProps } from '@mui/system'; +// TODO v6: replace material Theme with material-next Theme when Material You design is implemented +import { InternalStandardProps as StandardProps, Theme } from '@mui/material'; +// TODO v6: replace with material-next Input components props https://github.com/mui/material-ui/pull/39188#discussion_r1339645381 +import { InputProps } from '@mui/material/Input'; +// TODO v6: replace with material-next Menu https://github.com/mui/material-ui/pull/38934 +import { MenuProps } from '@mui/material/Menu'; +// TODO v6: replace with material-next OutlinedInput when available +import { OutlinedInputProps } from '@mui/material/OutlinedInput'; +import { SelectChangeEvent, SelectInputProps } from './SelectInput'; +import { SelectClasses } from './selectClasses'; + +export { SelectChangeEvent }; + +export interface SelectProps + extends StandardProps, + Omit, + Pick, 'onChange'> { + /** + * If `true`, the width of the popover will automatically be set according to the items inside the + * menu, otherwise it will be at least the width of the select input. + * @default false + */ + autoWidth?: boolean; + /** + * The option elements to populate the select with. + * Can be some `MenuItem` when `native` is false and `option` when `native` is true. + * + * ⚠️The `MenuItem` elements **must** be direct descendants when `native` is false. + */ + children?: React.ReactNode; + /** + * Override or extend the styles applied to the component. + * @default {} + */ + classes?: Partial; + /** + * If `true`, the component is initially open. Use when the component open state is not controlled (i.e. the `open` prop is not defined). + * You can only use it when the `native` prop is `false` (default). + * @default false + */ + defaultOpen?: boolean; + /** + * The default value. Use when the component is not controlled. + */ + defaultValue?: Value; + /** + * If `true`, a value is displayed even if no items are selected. + * + * In order to display a meaningful value, a function can be passed to the `renderValue` prop which + * returns the value to be displayed when no items are selected. + * + * ⚠️ When using this prop, make sure the label doesn't overlap with the empty displayed value. + * The label should either be hidden or forced to a shrunk state. + * @default false + */ + displayEmpty?: boolean; + /** + * The icon that displays the arrow. + * @default ArrowDropDownIcon + */ + IconComponent?: React.ElementType; + /** + * The `id` of the wrapper element or the `select` element when `native`. + */ + id?: string; + /** + * An `Input` element; does not have to be a material-ui specific `Input`. + */ + input?: React.ReactElement; + /** + * [Attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Attributes) applied to the `input` element. + * When `native` is `true`, the attributes are applied on the `select` element. + */ + inputProps?: InputProps['inputProps']; + /** + * See [OutlinedInput#label](/material-ui/api/outlined-input/#props) + */ + label?: React.ReactNode; + /** + * The ID of an element that acts as an additional label. The Select will + * be labelled by the additional label and the selected value. + */ + labelId?: string; + /** + * Props applied to the [`Menu`](/material-ui/api/menu/) element. + */ + MenuProps?: Partial; + /** + * If `true`, `value` must be an array and the menu will support multiple selections. + * @default false + */ + multiple?: boolean; + /** + * If `true`, the component uses a native `select` element. + * @default false + */ + native?: boolean; + /** + * Callback fired when a menu item is selected. + * + * @param {SelectChangeEvent} event The event source of the callback. + * You can pull out the new value by accessing `event.target.value` (any). + * **Warning**: This is a generic event, not a change event, unless the change event is caused by browser autofill. + * @param {object} [child] The react element that was selected when `native` is `false` (default). + */ + onChange?: SelectInputProps['onChange']; + /** + * Callback fired when the component requests to be closed. + * Use it in either controlled (see the `open` prop), or uncontrolled mode (to detect when the Select collapses). + * + * @param {object} event The event source of the callback. + */ + onClose?: (event: React.SyntheticEvent) => void; + /** + * Callback fired when the component requests to be opened. + * Use it in either controlled (see the `open` prop), or uncontrolled mode (to detect when the Select expands). + * + * @param {object} event The event source of the callback. + */ + onOpen?: (event: React.SyntheticEvent) => void; + /** + * If `true`, the component is shown. + * You can only use it when the `native` prop is `false` (default). + */ + open?: boolean; + /** + * Render the selected value. + * You can only use it when the `native` prop is `false` (default). + * + * @param {any} value The `value` provided to the component. + * @returns {ReactNode} + */ + renderValue?: (value: Value) => React.ReactNode; + /** + * Props applied to the clickable div element. + */ + SelectDisplayProps?: React.HTMLAttributes; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + /** + * The `input` value. Providing an empty string will select no options. + * Set to an empty string `''` if you don't want any of the available options to be selected. + * + * If the value is an object it must have reference equality with the option in order to be selected. + * If the value is not an object, the string representation must match with the string representation of the option in order to be selected. + */ + value?: Value | ''; + /** + * The variant to use. + * @default 'outlined' + */ + variant?: 'standard' | 'outlined' | 'filled'; +} + +/** + * + * Demos: + * + * - [Select](https://mui.com/material-ui/react-select/) + * + * API: + * + * - [Select API](https://mui.com/material-ui/api/select/) + * - inherits [OutlinedInput API](https://mui.com/material-ui/api/outlined-input/) + */ +declare const Select: ((props: SelectProps) => JSX.Element) & { + muiName: string; +}; + +export default Select; diff --git a/packages/mui-material-next/src/Select/Select.js b/packages/mui-material-next/src/Select/Select.js new file mode 100644 index 00000000000000..176d20f5503ac5 --- /dev/null +++ b/packages/mui-material-next/src/Select/Select.js @@ -0,0 +1,287 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { deepmerge, unstable_useForkRef as useForkRef } from '@mui/utils'; +import NativeSelectInput from '@mui/material/NativeSelect/NativeSelectInput'; +// TODO v6: Remove Input component after implementing Material You design +import Input from '@mui/material/Input'; +// TODO v6: replace with material-next FilledInput when available https://github.com/mui/material-ui/issues/39052 +import FilledInput from '@mui/material/FilledInput'; +// TODO v6: replace with material-next OutlinedInput when available +import OutlinedInput from '@mui/material/OutlinedInput'; +import SelectInput from './SelectInput'; +import formControlState from '../FormControl/formControlState'; +import useFormControl from '../FormControl/useFormControl'; +import ArrowDropDownIcon from '../internal/svg-icons/ArrowDropDown'; +import useThemeProps from '../styles/useThemeProps'; +import styled, { rootShouldForwardProp } from '../styles/styled'; + +const useUtilityClasses = (ownerState) => { + const { classes } = ownerState; + + return classes; +}; + +const styledRootConfig = { + name: 'MuiSelect', + overridesResolver: (props, styles) => styles.root, + shouldForwardProp: (prop) => rootShouldForwardProp(prop) && prop !== 'variant', + slot: 'Root', +}; + +const StyledInput = styled(Input, styledRootConfig)(''); + +const StyledOutlinedInput = styled(OutlinedInput, styledRootConfig)(''); + +const StyledFilledInput = styled(FilledInput, styledRootConfig)(''); + +const Select = React.forwardRef(function Select(inProps, ref) { + const props = useThemeProps({ name: 'MuiSelect', props: inProps }); + const { + autoWidth = false, + children, + classes: classesProp = {}, + className, + defaultOpen = false, + displayEmpty = false, + IconComponent = ArrowDropDownIcon, + id, + input, + inputProps, + label, + labelId, + MenuProps, + multiple = false, + native = false, + onClose, + onOpen, + open, + renderValue, + SelectDisplayProps, + variant: variantProp = 'outlined', + ...other + } = props; + + const inputComponent = native ? NativeSelectInput : SelectInput; + + const muiFormControl = useFormControl(); + const fcs = formControlState({ + props, + muiFormControl, + states: ['variant', 'error'], + }); + + const variant = fcs.variant || variantProp; + + const ownerState = { ...props, variant, classes: classesProp }; + const classes = useUtilityClasses(ownerState); + const { root, ...restOfClasses } = classes; + + const InputComponent = + input || + { + standard: , + outlined: , + filled: , + }[variant]; + + const inputComponentRef = useForkRef(ref, InputComponent.ref); + + return ( + + {React.cloneElement(InputComponent, { + // Most of the logic is implemented in `SelectInput`. + // The `Select` component is a simple API wrapper to expose something better to play with. + inputComponent, + inputProps: { + children, + error: fcs.error, + IconComponent, + variant, + type: undefined, // We render a select. We can ignore the type provided by the `Input`. + multiple, + ...(native + ? { id } + : { + autoWidth, + defaultOpen, + displayEmpty, + labelId, + MenuProps, + onClose, + onOpen, + open, + renderValue, + SelectDisplayProps: { id, ...SelectDisplayProps }, + }), + ...inputProps, + classes: inputProps ? deepmerge(restOfClasses, inputProps.classes) : restOfClasses, + ...(input ? input.props.inputProps : {}), + }, + ...(multiple && native && variant === 'outlined' ? { notched: true } : {}), + ref: inputComponentRef, + className: clsx(InputComponent.props.className, className, classes.root), + // If a custom input is provided via 'input' prop, do not allow 'variant' to be propagated to it's root element. See https://github.com/mui/material-ui/issues/33894. + ...(!input && { variant }), + ...other, + })} + + ); +}); + +Select.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * If `true`, the width of the popover will automatically be set according to the items inside the + * menu, otherwise it will be at least the width of the select input. + * @default false + */ + autoWidth: PropTypes.bool, + /** + * The option elements to populate the select with. + * Can be some `MenuItem` when `native` is false and `option` when `native` is true. + * + * ⚠️The `MenuItem` elements **must** be direct descendants when `native` is false. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + * @default {} + */ + classes: PropTypes.object, + /** + * @ignore + */ + className: PropTypes.string, + /** + * If `true`, the component is initially open. Use when the component open state is not controlled (i.e. the `open` prop is not defined). + * You can only use it when the `native` prop is `false` (default). + * @default false + */ + defaultOpen: PropTypes.bool, + /** + * The default value. Use when the component is not controlled. + */ + defaultValue: PropTypes.any, + /** + * If `true`, a value is displayed even if no items are selected. + * + * In order to display a meaningful value, a function can be passed to the `renderValue` prop which + * returns the value to be displayed when no items are selected. + * + * ⚠️ When using this prop, make sure the label doesn't overlap with the empty displayed value. + * The label should either be hidden or forced to a shrunk state. + * @default false + */ + displayEmpty: PropTypes.bool, + /** + * The icon that displays the arrow. + * @default ArrowDropDownIcon + */ + IconComponent: PropTypes.elementType, + /** + * The `id` of the wrapper element or the `select` element when `native`. + */ + id: PropTypes.string, + /** + * An `Input` element; does not have to be a material-ui specific `Input`. + */ + input: PropTypes.element, + /** + * [Attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Attributes) applied to the `input` element. + * When `native` is `true`, the attributes are applied on the `select` element. + */ + inputProps: PropTypes.object, + /** + * See [OutlinedInput#label](/material-ui/api/outlined-input/#props) + */ + label: PropTypes.node, + /** + * The ID of an element that acts as an additional label. The Select will + * be labelled by the additional label and the selected value. + */ + labelId: PropTypes.string, + /** + * Props applied to the [`Menu`](/material-ui/api/menu/) element. + */ + MenuProps: PropTypes.object, + /** + * If `true`, `value` must be an array and the menu will support multiple selections. + * @default false + */ + multiple: PropTypes.bool, + /** + * If `true`, the component uses a native `select` element. + * @default false + */ + native: PropTypes.bool, + /** + * Callback fired when a menu item is selected. + * + * @param {SelectChangeEvent} event The event source of the callback. + * You can pull out the new value by accessing `event.target.value` (any). + * **Warning**: This is a generic event, not a change event, unless the change event is caused by browser autofill. + * @param {object} [child] The react element that was selected when `native` is `false` (default). + */ + onChange: PropTypes.func, + /** + * Callback fired when the component requests to be closed. + * Use it in either controlled (see the `open` prop), or uncontrolled mode (to detect when the Select collapses). + * + * @param {object} event The event source of the callback. + */ + onClose: PropTypes.func, + /** + * Callback fired when the component requests to be opened. + * Use it in either controlled (see the `open` prop), or uncontrolled mode (to detect when the Select expands). + * + * @param {object} event The event source of the callback. + */ + onOpen: PropTypes.func, + /** + * If `true`, the component is shown. + * You can only use it when the `native` prop is `false` (default). + */ + open: PropTypes.bool, + /** + * Render the selected value. + * You can only use it when the `native` prop is `false` (default). + * + * @param {any} value The `value` provided to the component. + * @returns {ReactNode} + */ + renderValue: PropTypes.func, + /** + * Props applied to the clickable div element. + */ + SelectDisplayProps: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), + /** + * The `input` value. Providing an empty string will select no options. + * Set to an empty string `''` if you don't want any of the available options to be selected. + * + * If the value is an object it must have reference equality with the option in order to be selected. + * If the value is not an object, the string representation must match with the string representation of the option in order to be selected. + */ + value: PropTypes.oneOfType([PropTypes.oneOf(['']), PropTypes.any]), + /** + * The variant to use. + * @default 'outlined' + */ + variant: PropTypes.oneOf(['filled', 'outlined', 'standard']), +}; + +Select.muiName = 'Select'; + +export default Select; diff --git a/packages/mui-material-next/src/Select/Select.spec.tsx b/packages/mui-material-next/src/Select/Select.spec.tsx new file mode 100644 index 00000000000000..0d9dc50b093893 --- /dev/null +++ b/packages/mui-material-next/src/Select/Select.spec.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +// TODO v6: replace with material-next Menu when available https://github.com/mui/material-ui/pull/38934 +import MenuItem from '@mui/material/MenuItem'; +// TODO v6: replace with material-next's extendTheme when implementing Material You design +import { createTheme } from '@mui/material/styles'; +import Select, { SelectChangeEvent } from '@mui/material-next/Select'; + +function genericValueTest() { + function handleChangeWithSameTypeAsSelect(event: SelectChangeEvent) {} + onChange={handleChangeWithSameTypeAsSelect} />; + + function handleChangeWithDifferentTypeFromSelect( + event: React.ChangeEvent<{ name?: string; value: string }>, + ) {} + + // @ts-expect-error + onChange={handleChangeWithDifferentTypeFromSelect} + />; + + + // @ts-expect-error defaultValue should be a string + defaultValue={1} + // @ts-expect-error Value should be a string + value={10} + />; + + console.log(event.target.value)} value="1"> + + {/* Whoops. The value in onChange won't be a string */} + + ; + + // notched prop should be available (inherited from OutlinedInputProps) and NOT throw typescript error + ; + + // Tests presence of `root` class in SelectClasses + const theme = createTheme({ + components: { + MuiSelect: { + styleOverrides: { + root: { + borderRadius: '8px', + }, + }, + }, + }, + }); + + // tests deep slot prop forwarding up to the modal backdrop + ', () => { + const { clock, render } = createRenderer({ clock: 'fake' }); + + describeConformance(, + ); + expect(document.querySelector(`.${classes.select}`)).to.have.class('select'); + }); + }); + + it('should be able to mount the component', () => { + const { container } = render( + , + ); + + expect(container.querySelector('input')).to.have.property('value', '10'); + }); + + specify('the trigger is in tab order', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).to.have.property('tabIndex', 0); + }); + + it('should accept null child', () => { + render( + , + ); + }); + + ['', 0, false, undefined, NaN].forEach((value) => + it(`should support conditional rendering with "${value}"`, () => { + render( + , + ); + }), + ); + + it('should have an input with [aria-hidden] by default', () => { + const { container } = render( + , + ); + + expect(container.querySelector('input')).to.have.attribute('aria-hidden', 'true'); + }); + + it('should ignore onBlur when the menu opens', () => { + // mousedown calls focus while click opens moving the focus to an item + // this means the trigger is blurred immediately + const handleBlur = spy(); + const { getByRole, getAllByRole, queryByRole } = render( + , + ); + const trigger = getByRole('combobox'); + + fireEvent.mouseDown(trigger); + + expect(handleBlur.callCount).to.equal(0); + expect(getByRole('listbox')).not.to.equal(null); + + act(() => { + const options = getAllByRole('option'); + fireEvent.mouseDown(options[0]); + options[0].click(); + }); + + expect(handleBlur.callCount).to.equal(0); + expect(queryByRole('listbox', { hidden: false })).to.equal(null); + }); + + it('options should have a data-value attribute', () => { + render( + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).to.have.attribute('data-value', '10'); + expect(options[1]).to.have.attribute('data-value', '20'); + }); + + [' ', 'ArrowUp', 'ArrowDown', 'Enter'].forEach((key) => { + it(`should open menu when pressed ${key} key on select`, () => { + render( + , + ); + const trigger = screen.getByRole('combobox'); + act(() => { + trigger.focus(); + }); + + fireEvent.keyDown(trigger, { key }); + expect(screen.getByRole('listbox', { hidden: false })).not.to.equal(null); + + fireEvent.keyUp(screen.getAllByRole('option')[0], { key }); + expect(screen.getByRole('listbox', { hidden: false })).not.to.equal(null); + }); + }); + + it('should pass "name" as part of the event.target for onBlur', () => { + const handleBlur = stub().callsFake((event) => event.target.name); + const { getByRole } = render( + , + ); + const button = getByRole('combobox'); + act(() => { + button.focus(); + }); + + act(() => { + button.blur(); + }); + + expect(handleBlur.callCount).to.equal(1); + expect(handleBlur.firstCall.returnValue).to.equal('blur-testing'); + }); + + it('should call onClose when the backdrop is clicked', () => { + const handleClose = spy(); + const { getByTestId } = render( + , + ); + + act(() => { + getByTestId('backdrop').click(); + }); + + expect(handleClose.callCount).to.equal(1); + }); + + it('should call onClose when the same option is selected', () => { + const handleChange = spy(); + const handleClose = spy(); + render( + , + ); + + screen.getByRole('option', { selected: true }).click(); + + expect(handleChange.callCount).to.equal(0); + expect(handleClose.callCount).to.equal(1); + }); + + it('should focus select when its label is clicked', () => { + const { getByRole, getByTestId } = render( + + + ); + + fireEvent.mouseDown(getByRole('combobox')); + + // TODO not matching WAI-ARIA authoring practices. It should focus the first (or selected) item. + expect(getByRole('listbox')).toHaveFocus(); + }); + + describe('prop: onChange', () => { + it('should get selected element from arguments', () => { + const onChangeHandler = spy(); + const { getAllByRole, getByRole } = render( + , + ); + fireEvent.mouseDown(getByRole('combobox')); + act(() => { + getAllByRole('option')[1].click(); + }); + + expect(onChangeHandler.calledOnce).to.equal(true); + const selected = onChangeHandler.args[0][1]; + expect(React.isValidElement(selected)).to.equal(true); + }); + + it('should call onChange before onClose', () => { + const eventLog = []; + const onChangeHandler = spy(() => eventLog.push('CHANGE_EVENT')); + const onCloseHandler = spy(() => eventLog.push('CLOSE_EVENT')); + const { getAllByRole, getByRole } = render( + , + ); + + fireEvent.mouseDown(getByRole('combobox')); + act(() => { + getAllByRole('option')[1].click(); + }); + + expect(eventLog).to.deep.equal(['CHANGE_EVENT', 'CLOSE_EVENT']); + }); + + it('should not be called if selected element has the current value (value did not change)', () => { + const onChangeHandler = spy(); + const { getAllByRole, getByRole } = render( + , + ); + fireEvent.mouseDown(getByRole('combobox')); + act(() => { + getAllByRole('option')[1].click(); + }); + + expect(onChangeHandler.callCount).to.equal(0); + }); + }); + + describe('prop: defaultOpen', () => { + it('should be open on mount', () => { + const { getByRole } = render( + Ten + Twenty + Thirty + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).not.to.have.attribute('aria-selected', 'true'); + expect(options[1]).to.have.attribute('aria-selected', 'true'); + expect(options[2]).not.to.have.attribute('aria-selected', 'true'); + }); + + it('should select the option based on the string value', () => { + render( + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).not.to.have.attribute('aria-selected', 'true'); + expect(options[1]).to.have.attribute('aria-selected', 'true'); + expect(options[2]).not.to.have.attribute('aria-selected', 'true'); + }); + + it('should select only the option that matches the object', () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + render( + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).not.to.have.attribute('aria-selected', 'true'); + }); + + it('should be able to use an object', () => { + const value = {}; + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).to.have.text('Twenty'); + }); + + describe('warnings', () => { + it('warns when the value is not present in any option', () => { + expect(() => + render( + , + ), + ).toWarnDev([ + 'MUI: You have provided an out-of-range value `20` for the select component.', + // React 18 Strict Effects run mount effects twice + React.version.startsWith('18') && + 'MUI: You have provided an out-of-range value `20` for the select component.', + 'MUI: You have provided an out-of-range value `20` for the select component.', + ]); + }); + }); + }); + + it('should not have the selectable option selected when inital value provided is empty string on Select with ListSubHeader item', () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + expect(options[1]).not.to.have.class(menuItemClasses.selected); + }); + + describe('SVG icon', () => { + it('should not present an SVG icon when native and multiple are specified', () => { + const { container } = render( + , + ); + expect(container.querySelector('svg')).to.equal(null); + }); + + it('should present an SVG icon', () => { + const { container } = render( + , + ); + expect(container.querySelector('svg')).toBeVisible(); + }); + }); + + describe('accessibility', () => { + it('sets aria-expanded="true" when the listbox is displayed', () => { + // since we make the rest of the UI inaccessible when open this doesn't + // technically matter. This is only here in case we keep the rest accessible + const { getByRole } = render(); + + expect(getByRole('combobox')).to.have.attribute('aria-expanded', 'false'); + }); + + it('sets aria-disabled="true" when component is disabled', () => { + const { getByRole } = render(); + + expect(container.querySelector('input')).to.have.property('disabled', true); + }); + + specify('aria-disabled is not present if component is not disabled', () => { + const { getByRole } = render(); + + expect(getByRole('combobox')).to.have.attribute('aria-haspopup', 'listbox'); + }); + + it('renders an element with listbox behavior', () => { + const { getByRole } = render(); + const listboxId = getByRole('listbox').id; + + expect(getByRole('combobox', { hidden: true })).to.have.attribute('aria-controls', listboxId); + }); + + specify('the listbox is focusable', () => { + const { getByRole } = render( + First + Second + , + ); + + const options = getAllByRole('option'); + expect(options[0]).to.have.text('First'); + expect(options[1]).to.have.text('Second'); + }); + + it('indicates the selected option', () => { + const { getAllByRole } = render( + , + ); + + expect(getAllByRole('option')[1]).to.have.attribute('aria-selected', 'true'); + }); + + describe('when the first child is a ListSubheader', () => { + it('first selectable option is focused to use the arrow', () => { + const { getAllByRole } = render( + , + ); + + const options = getAllByRole('option'); + expect(options[1]).to.have.attribute('tabindex', '0'); + + act(() => { + fireEvent.keyDown(options[1], { key: 'ArrowDown' }); + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[4], { key: 'Enter' }); + }); + + expect(options[4]).to.have.attribute('aria-selected', 'true'); + }); + + describe('when also the second child is a ListSubheader', () => { + it('first selectable option is focused to use the arrow', () => { + const { getAllByRole } = render( + , + ); + + const options = getAllByRole('option'); + expect(options[2]).to.have.attribute('tabindex', '0'); + + act(() => { + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[3], { key: 'ArrowDown' }); + fireEvent.keyDown(options[5], { key: 'Enter' }); + }); + + expect(options[5]).to.have.attribute('aria-selected', 'true'); + }); + }); + + describe('when the second child is null', () => { + it('first selectable option is focused to use the arrow', () => { + const { getAllByRole } = render( + , + ); + + const options = getAllByRole('option'); + expect(options[1]).to.have.attribute('tabindex', '0'); + + act(() => { + fireEvent.keyDown(options[1], { key: 'ArrowDown' }); + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[4], { key: 'Enter' }); + }); + + expect(options[4]).to.have.attribute('aria-selected', 'true'); + }); + }); + + ['', 0, false, undefined, NaN].forEach((value) => + describe(`when the second child is conditionally rendering with "${value}"`, () => { + it('first selectable option is focused to use the arrow', () => { + const { getAllByRole } = render( + , + ); + + const options = getAllByRole('option'); + expect(options[1]).to.have.attribute('tabindex', '0'); + + act(() => { + fireEvent.keyDown(options[1], { key: 'ArrowDown' }); + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[4], { key: 'Enter' }); + }); + + expect(options[4]).to.have.attribute('aria-selected', 'true'); + }); + }), + ); + }); + + describe('when the first child is a ListSubheader wrapped in a custom component', () => { + describe('with the `muiSkipListHighlight` static field', () => { + function WrappedListSubheader(props) { + return ; + } + + WrappedListSubheader.muiSkipListHighlight = true; + + it('highlights the first selectable option below the header', () => { + const { getByText } = render( + , + ); + + const expectedHighlightedOption = getByText('Option 1'); + expect(expectedHighlightedOption).to.have.attribute('tabindex', '0'); + }); + }); + + describe('with the `muiSkipListHighlight` prop', () => { + function WrappedListSubheader(props) { + const { muiSkipListHighlight, ...other } = props; + return ; + } + + it('highlights the first selectable option below the header', () => { + const { getByText } = render( + , + ); + + const expectedHighlightedOption = getByText('Option 1'); + expect(expectedHighlightedOption).to.have.attribute('tabindex', '0'); + }); + }); + }); + + describe('when the first child is a MenuItem disabled', () => { + it('highlights the first selectable option below the header', () => { + const { getAllByRole } = render( + , + ); + + const options = getAllByRole('option'); + expect(options[2]).to.have.attribute('tabindex', '0'); + + act(() => { + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[3], { key: 'ArrowDown' }); + fireEvent.keyDown(options[5], { key: 'Enter' }); + }); + + expect(options[5]).to.have.attribute('aria-selected', 'true'); + }); + }); + + it('it will fallback to its content for the accessible name when it has no name', () => { + const { getByRole } = render(); + + expect(getByRole('combobox')).to.have.attribute( + 'aria-labelledby', + getByRole('combobox').getAttribute('id'), + ); + }); + + it('is labelled by itself when it has an id which is preferred over name', () => { + const { getAllByRole } = render( + + Chose first option: + + , + ); + + const triggers = getAllByRole('combobox'); + + expect(triggers[0]).to.have.attribute( + 'aria-labelledby', + `select-1-label ${triggers[0].getAttribute('id')}`, + ); + expect(triggers[1]).to.have.attribute( + 'aria-labelledby', + `select-2-label ${triggers[1].getAttribute('id')}`, + ); + }); + + it('can be labelled by an additional element if its id is provided in `labelId`', () => { + const { getByRole } = render( + + Choose one: + ); + + expect(getByRole('listbox')).not.to.have.attribute('aria-labelledby'); + }); + + specify('the list of options can be labelled by providing `labelId`', () => { + const { getByRole } = render( + + Choose one: + + Helper text content + , + ); + + const target = getByRole('combobox'); + expect(target).to.have.attribute('aria-describedby', 'select-helper-text'); + expect(target).toHaveAccessibleDescription('Helper text content'); + }); + }); + + describe('prop: readOnly', () => { + it('should not trigger any event with readOnly', () => { + render( + , + ); + const trigger = screen.getByRole('combobox'); + act(() => { + trigger.focus(); + }); + + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + expect(screen.queryByRole('listbox')).to.equal(null); + + fireEvent.keyUp(trigger, { key: 'ArrowDown' }); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + }); + + describe('prop: MenuProps', () => { + it('should apply additional props to the Menu component', () => { + const onEntered = spy(); + const { getByRole } = render( + , + ); + + fireEvent.mouseDown(getByRole('combobox')); + clock.tick(99); + + expect(onEntered.callCount).to.equal(0); + + clock.tick(1); + + expect(onEntered.callCount).to.equal(1); + }); + + it('should be able to override PaperProps minWidth', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('paper').style).to.have.property('minWidth', '12px'); + }); + + // https://github.com/mui/material-ui/issues/38700 + it('should merge `slotProps.paper` with the default Paper props', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const { getByTestId, getByRole } = render( + , + ); + + const paper = getByTestId('paper'); + const selectButton = getByRole('combobox', { hidden: true }); + + expect(paper.style).to.have.property('minWidth', `${selectButton.clientWidth}px`); + }); + + // https://github.com/mui/material-ui/issues/38949 + it('should forward `slotProps` to menu', function test() { + const { getByTestId } = render( + , + ); + + const backdrop = getByTestId('backdrop'); + + expect(backdrop.style).to.have.property('backgroundColor', 'red'); + }); + }); + + describe('prop: SelectDisplayProps', () => { + it('should apply additional props to trigger element', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).to.have.attribute('data-test', 'SelectDisplay'); + }); + }); + + describe('prop: displayEmpty', () => { + it('should display the selected item even if its value is empty', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).to.have.text('Ten'); + }); + }); + + describe('prop: renderValue', () => { + it('should use the prop to render the value', () => { + const renderValue = (x) => `0b${x.toString(2)}`; + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).to.have.text('0b100'); + }); + }); + + describe('prop: open (controlled)', () => { + it('should not focus on close controlled select', () => { + function ControlledWrapper() { + const [open, setOpen] = React.useState(false); + + return ( +
+ + +
+ ); + } + const { container, getByRole } = render(); + const openSelect = container.querySelector('#open-select'); + act(() => { + openSelect.focus(); + }); + fireEvent.click(openSelect); + + const option = getByRole('option'); + expect(option).toHaveFocus(); + fireEvent.click(option); + + expect(container.querySelectorAll(classes.focused).length).to.equal(0); + expect(openSelect).toHaveFocus(); + }); + + it('should allow to control closing by passing onClose props', () => { + function ControlledWrapper() { + const [open, setOpen] = React.useState(false); + + return ( + + ); + } + const { getByRole, queryByRole } = render(); + + fireEvent.mouseDown(getByRole('combobox')); + expect(getByRole('listbox')).not.to.equal(null); + + act(() => { + getByRole('option').click(); + }); + // react-transition-group uses one extra commit for exit to completely remove + // it from the DOM. but it's at least immediately inaccessible. + // It's desired that this fails one day. The additional tick required to remove + // this from the DOM is not a feature + expect(getByRole('listbox', { hidden: true })).toBeInaccessible(); + clock.tick(0); + + expect(queryByRole('listbox', { hidden: true })).to.equal(null); + }); + + it('should be open when initially true', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('listbox')).not.to.equal(null); + }); + + it('open only with the left mouse button click', () => { + // Test for https://github.com/mui/material-ui/issues/19250#issuecomment-578620934 + // Right/middle mouse click shouldn't open the Select + const { getByRole, queryByRole } = render( + , + ); + + const trigger = getByRole('combobox'); + + // If clicked by the right/middle mouse button, no options list should be opened + fireEvent.mouseDown(trigger, { button: 1 }); + expect(queryByRole('listbox')).to.equal(null); + + fireEvent.mouseDown(trigger, { button: 2 }); + expect(queryByRole('listbox')).to.equal(null); + }); + }); + + describe('prop: autoWidth', () => { + it('should take the trigger parent element width into account by default', () => { + const { container, getByRole, getByTestId } = render( + , + ); + const parentEl = container.querySelector('.MuiInputBase-root'); + const button = getByRole('combobox'); + stub(parentEl, 'clientWidth').get(() => 14); + + fireEvent.mouseDown(button); + expect(getByTestId('paper').style).to.have.property('minWidth', '14px'); + }); + + it('should not take the trigger parent element width into account when autoWidth is true', () => { + const { container, getByRole, getByTestId } = render( + , + ); + const parentEl = container.querySelector('.MuiInputBase-root'); + const button = getByRole('combobox'); + stub(parentEl, 'clientWidth').get(() => 14); + + fireEvent.mouseDown(button); + expect(getByTestId('paper').style).to.have.property('minWidth', ''); + }); + }); + + describe('prop: multiple', () => { + it('should serialize multiple select value', () => { + const { container, getAllByRole } = render( + , + ); + const options = getAllByRole('option'); + + expect(container.querySelector('input')).to.have.property('value', '10,30'); + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).not.to.have.attribute('aria-selected', 'true'); + expect(options[2]).to.have.attribute('aria-selected', 'true'); + }); + + it('should have aria-multiselectable=true when multiple is true', () => { + const { getByRole } = render( + , + ); + + fireEvent.mouseDown(getByRole('combobox')); + + expect(getByRole('listbox')).to.have.attribute('aria-multiselectable', 'true'); + }); + + it('should serialize multiple select display value', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('combobox')).to.have.text('Ten, Twenty, Thirty'); + }); + + it('should not throw an error if `value` is an empty array', () => { + expect(() => { + render(); + }).not.to.throw(); + }); + + it("selects value based on their stringified equality when they're not objects", () => { + const { getAllByRole } = render( + , + ); + const options = getAllByRole('option'); + + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).to.have.attribute('aria-selected', 'true'); + expect(options[2]).not.to.have.attribute('aria-selected', 'true'); + }); + + it("selects values based on strict equality if they're objects", () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + const { getAllByRole } = render( + , + ); + const options = getAllByRole('option'); + + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).not.to.have.attribute('aria-selected', 'true'); + expect(options[2]).to.have.attribute('aria-selected', 'true'); + }); + + describe('errors', () => { + it('should throw if non array', function test() { + // TODO is this fixed? + if (!/jsdom/.test(window.navigator.userAgent)) { + // can't catch render errors in the browser for unknown reason + // tried try-catch + error boundary + window onError preventDefault + this.skip(); + } + + const errorRef = React.createRef(); + expect(() => { + render( + + + , + ); + }).toErrorDev([ + 'MUI: The `value` prop must be an array', + // React 18 Strict Effects run mount effects twice + React.version.startsWith('18') && 'MUI: The `value` prop must be an array', + 'The above error occurred in the component', + ]); + const { + current: { errors }, + } = errorRef; + expect(errors).to.have.length(1); + expect(errors[0].toString()).to.include('MUI: The `value` prop must be an array'); + }); + }); + + describe('prop: onChange', () => { + it('should call onChange when clicking an item', () => { + function ControlledSelectInput(props) { + const { onChange } = props; + const [values, clickedValue] = React.useReducer((currentValues, valueClicked) => { + if (currentValues.indexOf(valueClicked) === -1) { + return currentValues.concat(valueClicked); + } + return currentValues.filter((value) => { + return value !== valueClicked; + }); + }, []); + + const handleChange = (event) => { + onChange(event); + clickedValue(event.target.value); + }; + + return ( + + ); + } + const onChange = stub().callsFake((event) => { + return { + name: event.target.name, + value: event.target.value, + }; + }); + const { getByRole, getAllByRole } = render(); + + fireEvent.mouseDown(getByRole('combobox')); + const options = getAllByRole('option'); + fireEvent.click(options[2]); + + expect(onChange.callCount).to.equal(1); + expect(onChange.firstCall.returnValue).to.deep.equal({ name: 'age', value: [30] }); + + act(() => { + options[0].click(); + }); + + expect(onChange.callCount).to.equal(2); + expect(onChange.secondCall.returnValue).to.deep.equal({ name: 'age', value: [30, 10] }); + }); + }); + + it('should apply multiple class to `select` slot', () => { + const { container } = render( + , + ); + + expect(container.querySelector(`.${classes.select}`)).to.have.class(classes.multiple); + }); + + it('should be able to override `multiple` rule name in `select` slot', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + const selectStyle = { + marginLeft: '10px', + marginTop: '10px', + }; + + const multipleStyle = { + marginTop: '14px', + }; + + const theme = createTheme({ + components: { + MuiSelect: { + styleOverrides: { + select: selectStyle, + multiple: multipleStyle, + }, + }, + }, + }); + + const { container } = render( + + + , + ); + + const combinedStyle = { ...selectStyle, ...multipleStyle }; + + expect(container.getElementsByClassName(classes.select)[0]).to.toHaveComputedStyle( + combinedStyle, + ); + }); + }); + + describe('prop: autoFocus', () => { + it('should focus select after Select did mount', () => { + const { getByRole } = render(); + + expect(ref.current.node).to.have.tagName('input'); + + setProps({ + value: '', + }); + expect(ref.current.node).to.have.tagName('input'); + }); + + describe('prop: inputRef', () => { + it('should be able to return the input node via a ref object', () => { + const ref = React.createRef(); + render( is still used. + it('should be able focus the trigger imperatively', () => { + const ref = React.createRef(); + const { getByRole } = render(); + + expect(getByRole('combobox')).not.to.have.attribute('id'); + }); + + it('should have select-`name` id when name is provided', () => { + const { getByRole } = render(', () => { + const { container } = render( +
, + ); + + expect(getByRole('combobox', { name: 'A select' })).to.have.property('tagName', 'SELECT'); + }); + }); + + it('prevents the default when releasing Space on the children', () => { + const keyUpSpy = spy(); + render( + , + ); + + fireEvent.keyUp(screen.getAllByRole('option')[0], { key: ' ' }); + + expect(keyUpSpy.callCount).to.equal(1); + expect(keyUpSpy.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); + + it('should pass onClick prop to MenuItem', () => { + const onClick = spy(); + const { getAllByRole } = render( + , + ); + + const options = getAllByRole('option'); + fireEvent.click(options[0]); + + expect(onClick.callCount).to.equal(1); + }); + + // https://github.com/testing-library/react-testing-library/issues/322 + // https://twitter.com/devongovett/status/1248306411508916224 + it('should handle the browser autofill event and simple testing-library API', () => { + const onChangeHandler = spy(); + const { container, getByRole } = render( + , + ); + fireEvent.change(container.querySelector('input[name="country"]'), { + target: { + value: 'france', + }, + }); + + expect(onChangeHandler.calledOnce).to.equal(true); + expect(getByRole('combobox')).to.have.text('France'); + }); + + it('should support native form validation', function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + // see https://github.com/jsdom/jsdom/issues/123 + this.skip(); + } + + const handleSubmit = spy((event) => { + // avoid karma reload. + event.preventDefault(); + }); + function Form(props) { + return ( +
+ +