From 0341e39e99cffa35d61e8494b2b091a96ef8bafa Mon Sep 17 00:00:00 2001 From: Olivier Tassinari Date: Fri, 30 Oct 2020 21:49:37 +0100 Subject: [PATCH] material design state --- docs/pages/api-docs/autocomplete.md | 6 +- .../src/Autocomplete/Autocomplete.d.ts | 13 +- .../src/Autocomplete/Autocomplete.js | 116 +++++++----------- .../src/Autocomplete/Autocomplete.test.js | 78 +++++------- .../src/PaginationItem/PaginationItem.js | 9 +- .../src/useAutocomplete/useAutocomplete.js | 33 +++-- 6 files changed, 98 insertions(+), 157 deletions(-) diff --git a/docs/pages/api-docs/autocomplete.md b/docs/pages/api-docs/autocomplete.md index ae2b680e60784e..fb6ade645950ab 100644 --- a/docs/pages/api-docs/autocomplete.md +++ b/docs/pages/api-docs/autocomplete.md @@ -80,7 +80,6 @@ The `MuiAutocomplete` name can be used for providing [default props](/customizat | PaperComponent | elementType | Paper | The component used to render the body of the popup. | | PopperComponent | elementType | Popper | The component used to position the popup. | | popupIcon | node | <ArrowDropDownIcon /> | The icon to display in place of the default popup icon. | -| readOnly | bool | false | If `true`, the input is and search functionality disabled, but s still consistently styled. | | renderGroup | func | | Render the group.

**Signature:**
`function(option: any) => ReactNode`
*option:* The group to render. | | renderInput* | func | | Render the input.

**Signature:**
`function(params: object) => ReactNode`
| | renderOption | func | | Render the option, use `getOptionLabel` by default.

**Signature:**
`function(props: object, option: T, state: object) => ReactNode`
*props:* The props to apply on the li element.
*option:* The option to render.
*state:* The state of the component. | @@ -99,9 +98,7 @@ Any other props supplied will be provided to the root element (native element). |:-----|:-------------|:------------| | root | .MuiAutocomplete-root | Styles applied to the root element. | fullWidth | .MuiAutocomplete-fullWidth | Styles applied to the root element if `fullWidth={true}`. -| focused | .Mui-focused | Pseudo-class applied to the root element or option component `focused` class if keyboard or mouse focused. -| disabled | .Mui-disabled | Pseudo-class applied to the option component `disabled` class if option is disabled. -| selected | .Mui-selected | Pseudo-class applied to the option component `selected` class if option is selected. +| focused | .Mui-focused | Pseudo-class applied to the root element if focused. | tag | .MuiAutocomplete-tag | Styles applied to the tag elements, e.g. the chips. | tagSizeSmall | .MuiAutocomplete-tagSizeSmall | Styles applied to the tag elements, e.g. the chips if `size="small"`. | hasPopupIcon | .MuiAutocomplete-hasPopupIcon | Styles applied when the popup icon is rendered. @@ -109,7 +106,6 @@ Any other props supplied will be provided to the root element (native element). | inputRoot | .MuiAutocomplete-inputRoot | Styles applied to the Input element. | input | .MuiAutocomplete-input | Styles applied to the input element. | inputFocused | .MuiAutocomplete-inputFocused | Styles applied to the input element if tag focused. -| inputDisabled | .MuiAutocomplete-inputDisabled | Styles applied to the input element readOnly={true} | endAdornment | .MuiAutocomplete-endAdornment | Styles applied to the endAdornment element. | clearIndicator | .MuiAutocomplete-clearIndicator | Styles applied to the clear indicator. | popupIndicator | .MuiAutocomplete-popupIndicator | Styles applied to the popup indicator. diff --git a/packages/material-ui/src/Autocomplete/Autocomplete.d.ts b/packages/material-ui/src/Autocomplete/Autocomplete.d.ts index b29d33bb6b19a5..a69f3eabd2f268 100644 --- a/packages/material-ui/src/Autocomplete/Autocomplete.d.ts +++ b/packages/material-ui/src/Autocomplete/Autocomplete.d.ts @@ -67,12 +67,8 @@ export interface AutocompleteProps< root?: string; /** Styles applied to the root element if `fullWidth={true}`. */ fullWidth?: string; - /** Pseudo-class applied to the root element or option component `focused` class if keyboard or mouse focused. */ + /** Pseudo-class applied to the root element if focused. */ focused?: string; - /** Pseudo-class applied to the option component `disabled` class if option is disabled. */ - disabled?: string; - /** Pseudo-class applied to the option component `selected` class if option is selected. */ - selected?: string; /** Styles applied to the tag elements, e.g. the chips. */ tag?: string; /** Styles applied to the tag elements, e.g. the chips if `size="small"`. */ @@ -87,8 +83,6 @@ export interface AutocompleteProps< input?: string; /** Styles applied to the input element if tag focused. */ inputFocused?: string; - /** Styles applied to the input element readOnly={true} */ - inputDisabled?: string; /** Styles applied to the endAdornment element. */ endAdornment?: string; /** Styles applied to the clear indicator. */ @@ -145,11 +139,6 @@ export interface AutocompleteProps< * @default false */ disablePortal?: boolean; - /** - * If `true`, the input is and search functionality disabled, but s still consistently styled. - * @default false - */ - readOnly?: boolean; /** * Force the visibility display of the popup icon. * @default 'auto' diff --git a/packages/material-ui/src/Autocomplete/Autocomplete.js b/packages/material-ui/src/Autocomplete/Autocomplete.js index 96cbd20b6dcdab..de3ec2bf0b1456 100644 --- a/packages/material-ui/src/Autocomplete/Autocomplete.js +++ b/packages/material-ui/src/Autocomplete/Autocomplete.js @@ -31,12 +31,8 @@ export const styles = (theme) => ({ fullWidth: { width: '100%', }, - /* Pseudo-class applied to the root element or option component `focused` class if keyboard or mouse focused. */ + /* Pseudo-class applied to the root element if focused. */ focused: {}, - /* Pseudo-class applied to the option component `disabled` class if option is disabled. */ - disabled: {}, - /* Pseudo-class applied to the option component `selected` class if option is selected. */ - selected: {}, /* Styles applied to the tag elements, e.g. the chips. */ tag: { margin: 3, @@ -127,9 +123,6 @@ export const styles = (theme) => ({ padding: '2.5px 4px', }, }, - '&$inputDisabled': { - cursor: 'pointer', - }, }, /* Styles applied to the input element. */ input: { @@ -141,12 +134,6 @@ export const styles = (theme) => ({ inputFocused: { opacity: 1, }, - /* Styles applied to the input element readOnly={true} */ - inputDisabled: { - cursor: 'pointer !important', - color: 'transparent', // Hide the blinking cursor - textShadow: `0 0 0 ${theme.palette.text.primary}`, - }, /* Styles applied to the endAdornment element. */ endAdornment: { // We use a position absolute to support wrapping tags. @@ -219,21 +206,38 @@ export const styles = (theme) => ({ [theme.breakpoints.up('sm')]: { minHeight: 'auto', }, - '&$focused': { + '&[data-focus="true"]': { backgroundColor: theme.palette.action.hover, + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + '&[aria-disabled="true"]': { + opacity: theme.palette.action.disabledOpacity, + pointerEvents: 'none', }, - '&$selected': { - backgroundColor: theme.palette.action.selected, - '&$focused': { + '&.Mui-focusVisible': { + backgroundColor: theme.palette.action.focus, + }, + '&[aria-selected="true"]': { + backgroundColor: alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity), + '&[data-focus="true"]': { backgroundColor: alpha( - theme.palette.action.selected, + theme.palette.primary.main, theme.palette.action.selectedOpacity + theme.palette.action.hoverOpacity, ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: theme.palette.action.selected, + }, + }, + '&.Mui-focusVisible': { + backgroundColor: alpha( + theme.palette.primary.main, + theme.palette.action.selectedOpacity + theme.palette.action.focusOpacity, + ), }, - }, - '&$disabled': { - opacity: theme.palette.action.disabledOpacity, - pointerEvents: 'none', }, }, /* Styles applied to the group's label elements. */ @@ -274,7 +278,6 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { debug = false, defaultValue = props.multiple ? [] : null, disableClearable = false, - readOnly = false, disableCloseOnSelect = false, disabled = false, disabledItemsFocusable = false, @@ -345,7 +348,6 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { setAnchorEl, inputValue, groupedOptions, - highlightedOptionIndex, } = useAutocomplete({ ...props, componentName: 'Autocomplete' }); let startAdornment; @@ -396,45 +398,16 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { const renderGroup = renderGroupProp || defaultRenderGroup; const defaultRenderOption = (props2, option) =>
  • {getOptionLabel(option)}
  • ; - const renderOption = renderOptionProp || defaultRenderOption; - const renderListOption = React.useCallback( - (option, index) => { - const optionProps = getOptionProps({ option, index }); - return renderOption( - { - ...optionProps, - className: clsx(classes.option, { - [classes.focused]: highlightedOptionIndex === optionProps.index, - [classes.selected]: optionProps['aria-selected'], - [classes.disabled]: optionProps['aria-disabled'], - }), - }, - option, - { - selected: optionProps['aria-selected'], - inputValue, - }, - ); - }, - [getOptionProps, highlightedOptionIndex, inputValue, classes, renderOption], - ); + const renderListOption = (option, index) => { + const optionProps = getOptionProps({ option, index }); - const allGroupedOptions = React.useMemo(() => { - return groupedOptions.map((option, index) => { - if (groupBy) { - return renderGroup({ - key: option.key, - group: option.group, - children: option.options.map((option2, index2) => - renderListOption(option2, option.index + index2), - ), - }); - } - return renderListOption(option, index); + return renderOption({ ...optionProps, className: classes.option }, option, { + selected: optionProps['aria-selected'], + inputValue, }); - }, [groupedOptions, groupBy, renderGroup, renderListOption]); + }; const hasClearIcon = !disableClearable && !disabled && dirty; const hasPopupIcon = (!freeSolo || forcePopupIcon === true) && forcePopupIcon !== false; @@ -463,9 +436,7 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { InputLabelProps: getInputLabelProps(), InputProps: { ref: setAnchorEl, - className: clsx(classes.inputRoot, { - [classes.inputDisabled]: readOnly, - }), + className: classes.inputRoot, startAdornment, endAdornment: (
    @@ -499,7 +470,6 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { inputProps: { className: clsx(classes.input, { [classes.inputFocused]: focusedTag === -1, - [classes.inputDisabled]: readOnly, }), disabled, ...getInputProps(), @@ -531,7 +501,18 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { {...getListboxProps()} {...ListboxProps} > - {allGroupedOptions} + {groupedOptions.map((option, index) => { + if (groupBy) { + return renderGroup({ + key: option.key, + group: option.group, + children: option.options.map((option2, index2) => + renderListOption(option2, option.index + index2), + ), + }); + } + return renderListOption(option, index); + })} ) : null} @@ -863,11 +844,6 @@ Autocomplete.propTypes = { * @default */ popupIcon: PropTypes.node, - /** - * If `true`, the input is and search functionality disabled, but s still consistently styled. - * @default false - */ - readOnly: PropTypes.bool, /** * Render the group. * diff --git a/packages/material-ui/src/Autocomplete/Autocomplete.test.js b/packages/material-ui/src/Autocomplete/Autocomplete.test.js index 74f307679ea8ba..92a2ce6ebf9e45 100644 --- a/packages/material-ui/src/Autocomplete/Autocomplete.test.js +++ b/packages/material-ui/src/Autocomplete/Autocomplete.test.js @@ -17,9 +17,9 @@ import Autocomplete from './Autocomplete'; function checkHighlightIs(listbox, expected) { if (expected) { - expect(listbox.querySelector('li.Mui-focused')).to.have.text(expected); + expect(listbox.querySelector('li[data-focus]')).to.have.text(expected); } else { - expect(listbox.querySelector('li.Mui-focused')).to.equal(null); + expect(listbox.querySelector('li[data-focus]')).to.equal(null); } } @@ -328,12 +328,12 @@ describe('', () => { it('should add new value when autoSelect & multiple on blur', () => { const handleChange = spy(); - const options = ['one', 'two', 'three']; + const options = ['one', 'two']; render( ', () => { />, ); const textbox = screen.getByRole('textbox'); - fireEvent.change(textbox, { target: { value: 't' } }); - fireEvent.keyDown(textbox, { key: 'ArrowDown' }); act(() => { + fireEvent.change(textbox, { target: { value: 't' } }); + fireEvent.keyDown(textbox, { key: 'ArrowDown' }); textbox.blur(); }); expect(handleChange.callCount).to.equal(1); - expect(handleChange.args[0][1]).to.deep.equal(['one', 'two']); + expect(handleChange.args[0][1]).to.deep.equal(options); }); it('should add new value when autoSelect & multiple & freeSolo on blur', () => { @@ -533,7 +533,6 @@ describe('', () => { it('should trigger a form expectedly', () => { const handleSubmit = spy(); - const { setProps } = render( ', () => { renderInput={(props2) => } />, ); - let textbox = screen.getByRole('textbox'); fireEvent.keyDown(textbox, { key: 'Enter' }); expect(handleSubmit.callCount).to.equal(1); + fireEvent.change(textbox, { target: { value: 'o' } }); + fireEvent.keyDown(textbox, { key: 'ArrowDown' }); + fireEvent.keyDown(textbox, { key: 'Enter' }); + expect(handleSubmit.callCount).to.equal(1); + fireEvent.keyDown(textbox, { key: 'Enter' }); expect(handleSubmit.callCount).to.equal(2); - expect(() => { - setProps({ key: 'test-2', multiple: true, freeSolo: true }); - textbox = screen.getByRole('textbox'); + setProps({ key: 'test-2', multiple: true, freeSolo: true }); + textbox = screen.getByRole('textbox'); - fireEvent.change(textbox, { target: { value: 'o' } }); - fireEvent.keyDown(textbox, { key: 'Enter' }); - expect(handleSubmit.callCount).to.equal(2); + fireEvent.change(textbox, { target: { value: 'o' } }); + fireEvent.keyDown(textbox, { key: 'Enter' }); + expect(handleSubmit.callCount).to.equal(2); - fireEvent.keyDown(textbox, { key: 'Enter' }); - expect(handleSubmit.callCount).to.equal(3); - }).not.toErrorDev(); + fireEvent.keyDown(textbox, { key: 'Enter' }); + expect(handleSubmit.callCount).to.equal(3); - expect(() => { - setProps({ key: 'test-3', freeSolo: true }); - textbox = screen.getByRole('textbox'); - fireEvent.change(textbox, { target: { value: 'o' } }); - fireEvent.keyDown(textbox, { key: 'Enter' }); - expect(handleSubmit.callCount).to.equal(4); - }).not.toErrorDev(); + setProps({ key: 'test-3', freeSolo: true }); + textbox = screen.getByRole('textbox'); + + fireEvent.change(textbox, { target: { value: 'o' } }); + fireEvent.keyDown(textbox, { key: 'Enter' }); + expect(handleSubmit.callCount).to.equal(4); }); describe('prop: getOptionDisabled', () => { @@ -1151,26 +1151,6 @@ describe('', () => { }); }); - describe('prop: readOnly', () => { - it('should render inputDisabled class', () => { - const handleChange = spy(); - const options = ['one', 'two', 'three']; - render( - } - />, - ); - const textbox = screen.getByRole('textbox'); - fireEvent.click(textbox); - expect(handleChange.callCount).to.equal(0); - expect(textbox).toHaveFocus(); - expect(textbox).to.have.class(classes.inputDisabled); - }); - }); - describe('warnings', () => { it('warn if getOptionLabel do not return a string', () => { const handleChange = spy(); @@ -1272,8 +1252,6 @@ describe('', () => { />, ); }).toWarnDev([ - `Material-UI: The options provided combined with the \`groupBy\` method of Autocomplete returns duplicated headers.`, - 'You can solve the issue by sorting the options with the output of `groupBy`.', // strict mode renders twice 'returns duplicated headers', 'returns duplicated headers', @@ -1424,15 +1402,17 @@ describe('', () => { const textbox = screen.getByRole('textbox'); fireEvent.change(document.activeElement, { target: { value: 'O' } }); + expect(document.activeElement.value).to.equal('O'); fireEvent.keyDown(textbox, { key: 'ArrowDown' }); - fireEvent.change(document.activeElement, { target: { value: 'one' } }); + expect(document.activeElement.value).to.equal('one'); - expect(document.activeElement.selectionStart).to.equal(3); + expect(document.activeElement.selectionStart).to.equal(1); expect(document.activeElement.selectionEnd).to.equal(3); fireEvent.keyDown(textbox, { key: 'Enter' }); + expect(document.activeElement.value).to.equal('one'); expect(document.activeElement.selectionStart).to.equal(3); expect(document.activeElement.selectionEnd).to.equal(3); diff --git a/packages/material-ui/src/PaginationItem/PaginationItem.js b/packages/material-ui/src/PaginationItem/PaginationItem.js index d7a5f593d61d9e..182fbe620e8de7 100644 --- a/packages/material-ui/src/PaginationItem/PaginationItem.js +++ b/packages/material-ui/src/PaginationItem/PaginationItem.js @@ -1,7 +1,8 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { alpha, useTheme, withStyles, useThemeVariants } from '../styles'; +import { useTheme, withStyles, useThemeVariants } from '../styles'; +import { alpha } from '../styles/colorManipulator'; import ButtonBase from '../ButtonBase'; import { capitalize } from '../utils'; import FirstPageIcon from '../internal/svg-icons/FirstPage'; @@ -34,6 +35,9 @@ export const styles = (theme) => ({ backgroundColor: 'transparent', }, }, + '&$disabled': { + opacity: theme.palette.action.disabledOpacity, + }, '&$focusVisible': { backgroundColor: theme.palette.action.focus, }, @@ -61,9 +65,6 @@ export const styles = (theme) => ({ backgroundColor: theme.palette.action.selected, }, }, - '&$disabled': { - opacity: theme.palette.action.disabledOpacity, - }, }, /* Styles applied applied to the root element if `size="small"`. */ sizeSmall: { diff --git a/packages/material-ui/src/useAutocomplete/useAutocomplete.js b/packages/material-ui/src/useAutocomplete/useAutocomplete.js index a588251a705c02..7e7598f853c67f 100644 --- a/packages/material-ui/src/useAutocomplete/useAutocomplete.js +++ b/packages/material-ui/src/useAutocomplete/useAutocomplete.js @@ -74,7 +74,6 @@ export default function useAutocomplete(props) { defaultValue = props.multiple ? [] : null, disableClearable = false, disableCloseOnSelect = false, - readOnly = false, disabledItemsFocusable = false, disableListWrap = false, filterOptions = defaultFilterOptions, @@ -129,7 +128,7 @@ export default function useAutocomplete(props) { const [focusedTag, setFocusedTag] = React.useState(-1); const defaultHighlighted = autoHighlight ? 0 : -1; - const [highlightedOptionIndex, setHighlightedOptionIndex] = React.useState(defaultHighlighted); + const highlightedIndexRef = React.useRef(defaultHighlighted); const [value, setValueState] = useControlled({ controlled: valueProp, @@ -280,7 +279,7 @@ export default function useAutocomplete(props) { } const setHighlightedIndex = useEventCallback(({ event, index, reason = 'auto' }) => { - setHighlightedOptionIndex(index); + highlightedIndexRef.current = index; // does the index exist? if (index === -1) { @@ -298,9 +297,9 @@ export default function useAutocomplete(props) { } const prev = listboxRef.current.querySelector('[data-focus]'); - if (prev) { prev.removeAttribute('data-focus'); + prev.classList.remove('Mui-focusVisible'); } const listboxNode = listboxRef.current.parentElement.querySelector('[role="listbox"]'); @@ -322,6 +321,9 @@ export default function useAutocomplete(props) { } option.setAttribute('data-focus', 'true'); + if (reason === 'keyboard') { + option.classList.add('Mui-focusVisible'); + } // Scroll active descendant into view. // Logic copied from https://www.w3.org/TR/wai-aria-practices/examples/listbox/js/listbox.js @@ -365,14 +367,14 @@ export default function useAutocomplete(props) { return maxIndex; } - const newIndex = highlightedOptionIndex + diff; + const newIndex = highlightedIndexRef.current + diff; if (newIndex < 0) { if (newIndex === -1 && includeInputInList) { return -1; } - if ((disableListWrap && highlightedOptionIndex !== -1) || Math.abs(diff) > 1) { + if ((disableListWrap && highlightedIndexRef.current !== -1) || Math.abs(diff) > 1) { return 0; } @@ -435,7 +437,7 @@ export default function useAutocomplete(props) { // Synchronize the value with the highlighted index if (valueItem != null) { - const currentOption = filteredOptions[highlightedOptionIndex]; + const currentOption = filteredOptions[highlightedIndexRef.current]; // Keep the current highlighted index if possible if ( @@ -458,13 +460,13 @@ export default function useAutocomplete(props) { } // Prevent the highlighted index to leak outside the boundaries. - if (highlightedOptionIndex >= filteredOptions.length - 1) { + if (highlightedIndexRef.current >= filteredOptions.length - 1) { setHighlightedIndex({ index: filteredOptions.length - 1 }); return; } // Restore the focus to the previous index. - setHighlightedIndex({ index: highlightedOptionIndex }); + setHighlightedIndex({ index: highlightedIndexRef.current }); // Ignore filteredOptions (and options, getOptionSelected, getOptionLabel) not to break the scroll position // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -719,8 +721,8 @@ export default function useAutocomplete(props) { handleFocusTag(event, 'next'); break; case 'Enter': - if (highlightedOptionIndex !== -1 && popupOpen) { - const option = filteredOptions[highlightedOptionIndex]; + if (highlightedIndexRef.current !== -1 && popupOpen) { + const option = filteredOptions[highlightedIndexRef.current]; const disabled = getOptionDisabled ? getOptionDisabled(option) : false; // We don't want to validate the form. @@ -807,8 +809,8 @@ export default function useAutocomplete(props) { return; } - if (autoSelect && highlightedOptionIndex !== -1 && popupOpen) { - selectNewValue(event, filteredOptions[highlightedOptionIndex], 'blur'); + if (autoSelect && highlightedIndexRef.current !== -1 && popupOpen) { + selectNewValue(event, filteredOptions[highlightedIndexRef.current], 'blur'); } else if (autoSelect && freeSolo && inputValue !== '') { selectNewValue(event, inputValue, 'blur', 'freeSolo'); } else if (clearOnBlur) { @@ -820,7 +822,6 @@ export default function useAutocomplete(props) { const handleInputChange = (event) => { const newValue = event.target.value; - if (readOnly) return; if (inputValue !== newValue) { setInputValueState(newValue); @@ -1005,8 +1006,7 @@ export default function useAutocomplete(props) { const disabled = getOptionDisabled ? getOptionDisabled(option) : false; return { - index, - key: `${id}-option-${index}`, + key: index, tabIndex: -1, role: 'option', id: `${id}-option-${index}`, @@ -1027,7 +1027,6 @@ export default function useAutocomplete(props) { anchorEl, setAnchorEl, focusedTag, - highlightedOptionIndex, groupedOptions, }; }