From 7f09ea46f380eed609ca9d348022f77a67e787bb Mon Sep 17 00:00:00 2001 From: Olivier Tassinari Date: Wed, 6 Nov 2019 09:06:15 +0100 Subject: [PATCH] [Autocomplete] Improve accessibility (#18204) --- docs/pages/api/autocomplete.md | 8 +- .../components/autocomplete/GitHubLabel.js | 20 +-- .../components/autocomplete/GitHubLabel.tsx | 9 +- .../components/autocomplete/Playground.js | 7 + .../components/autocomplete/Playground.tsx | 7 + .../autocomplete/UseAutocomplete.js | 15 +- .../autocomplete/UseAutocomplete.tsx | 15 +- .../components/autocomplete/autocomplete.md | 7 + .../src/Autocomplete/Autocomplete.d.ts | 11 +- .../src/Autocomplete/Autocomplete.js | 155 ++++++++++-------- .../src/useAutocomplete/useAutocomplete.d.ts | 5 +- .../src/useAutocomplete/useAutocomplete.js | 5 +- 12 files changed, 147 insertions(+), 117 deletions(-) diff --git a/docs/pages/api/autocomplete.md b/docs/pages/api/autocomplete.md index 3b98615f021a0d..88ab04054a77b6 100644 --- a/docs/pages/api/autocomplete.md +++ b/docs/pages/api/autocomplete.md @@ -36,6 +36,7 @@ You can learn more about the difference by [reading this guide](/guides/minimizi | disabled | bool | false | If `true`, the input will be disabled. | | disableListWrap | bool | false | If `true`, the list box in the popup will not wrap focus. | | disableOpenOnFocus | bool | false | If `true`, the popup won't open on input focus. | +| disablePortal | bool | false | Disable the portal behavior. The children stay within it's parent DOM hierarchy. | | filterOptions | func | | A filter function that determines the options that are eligible.

**Signature:**
`function(options: undefined, state: object) => undefined`
*options:* The options to render.
*state:* The state of the component. | | filterSelectedOptions | bool | false | If `true`, hide the selected options from the list box. | | freeSolo | bool | false | If `true`, the Autocomplete is free solo, meaning that the user input is not bound to provided options. | @@ -55,12 +56,12 @@ You can learn more about the difference by [reading this guide](/guides/minimizi | open | bool | | Control the popup` open state. | | options | array | [] | Array of options. | | PaperComponent | elementType | Paper | The component used to render the body of the popup. | -| PopupComponent | elementType | Popper | The component used to render the popup. | +| PopperComponent | elementType | Popper | The component used to position the popup. | | 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`
*params:* null | | renderOption | func | | Render the option, use `getOptionLabel` by default.

**Signature:**
`function(option: any, state: object) => ReactNode`
*option:* The option to render.
*state:* The state of the component. | | renderTags | func | | Render the selected value.

**Signature:**
`function(value: any) => ReactNode`
*value:* The `value` provided to the component. | -| value | any | | The input value. | +| value | any | | The value of the autocomplete. | The `ref` is forwarded to the root element. @@ -83,7 +84,8 @@ Any other props supplied will be provided to the root element (native element). | clearIndicatorDirty | .MuiAutocomplete-clearIndicatorDirty | Styles applied to the clear indictator if the input is dirty. | popupIndicator | .MuiAutocomplete-popupIndicator | Styles applied to the popup indictator. | popupIndicatorOpen | .MuiAutocomplete-popupIndicatorOpen | Styles applied to the popup indictator if the popup is open. -| popup | .MuiAutocomplete-popup | Styles applied to the popup element. +| popper | .MuiAutocomplete-popper | Styles applied to the popper element. +| popperDisablePortal | .MuiAutocomplete-popperDisablePortal | Styles applied to the popper element if `disablePortal={true}`. | paper | .MuiAutocomplete-paper | Styles applied to the `Paper` component. | listbox | .MuiAutocomplete-listbox | Styles applied to the `listbox` component. | loading | .MuiAutocomplete-loading | Styles applied to the loading wrapper. diff --git a/docs/src/pages/components/autocomplete/GitHubLabel.js b/docs/src/pages/components/autocomplete/GitHubLabel.js index 0f94ddaeee1c8c..3c1544201fe50b 100644 --- a/docs/src/pages/components/autocomplete/GitHubLabel.js +++ b/docs/src/pages/components/autocomplete/GitHubLabel.js @@ -1,6 +1,5 @@ /* eslint-disable no-use-before-define */ import React from 'react'; -import PropTypes from 'prop-types'; import { useTheme, fade, makeStyles } from '@material-ui/core/styles'; import Popper from '@material-ui/core/Popper'; import SettingsIcon from '@material-ui/icons/Settings'; @@ -10,23 +9,6 @@ import Autocomplete from '@material-ui/lab/Autocomplete'; import ButtonBase from '@material-ui/core/ButtonBase'; import InputBase from '@material-ui/core/InputBase'; -function Popup(props) { - const { popperRef, anchorEl, open, ...other } = props; - return
; -} - -Popup.propTypes = { - anchorEl: PropTypes.object, - open: PropTypes.bool.isRequired, - popperRef: PropTypes.oneOfType([ - PropTypes.oneOf([null]), - PropTypes.func, - PropTypes.shape({ - current: PropTypes.any.isRequired, - }), - ]).isRequired, -}; - const useStyles = makeStyles(theme => ({ root: { width: 221, @@ -201,7 +183,7 @@ export default function GitHubLabel() { setPendingValue(newValue); }} disableCloseOnSelect - PopupComponent={Popup} + disablePortal renderTags={() => null} noOptionsText="No labels" renderOption={(option, { selected }) => ( diff --git a/docs/src/pages/components/autocomplete/GitHubLabel.tsx b/docs/src/pages/components/autocomplete/GitHubLabel.tsx index 1d584814bed6b6..240c4d70c7a6c7 100644 --- a/docs/src/pages/components/autocomplete/GitHubLabel.tsx +++ b/docs/src/pages/components/autocomplete/GitHubLabel.tsx @@ -5,15 +5,10 @@ import Popper from '@material-ui/core/Popper'; import SettingsIcon from '@material-ui/icons/Settings'; import CloseIcon from '@material-ui/icons/Close'; import DoneIcon from '@material-ui/icons/Done'; -import Autocomplete, { PopupProps } from '@material-ui/lab/Autocomplete'; +import Autocomplete from '@material-ui/lab/Autocomplete'; import ButtonBase from '@material-ui/core/ButtonBase'; import InputBase from '@material-ui/core/InputBase'; -function Popup(props: PopupProps) { - const { popperRef, anchorEl, open, ...other } = props; - return
; -} - const useStyles = makeStyles((theme: Theme) => createStyles({ root: { @@ -190,7 +185,7 @@ export default function GitHubLabel() { setPendingValue(newValue); }} disableCloseOnSelect - PopupComponent={Popup} + disablePortal renderTags={() => null} noOptionsText="No labels" renderOption={(option: LabelType, { selected }) => ( diff --git a/docs/src/pages/components/autocomplete/Playground.js b/docs/src/pages/components/autocomplete/Playground.js index 405ef7802afc9a..76d671b3428e98 100644 --- a/docs/src/pages/components/autocomplete/Playground.js +++ b/docs/src/pages/components/autocomplete/Playground.js @@ -105,6 +105,13 @@ export default function Playground() { disabled renderInput={params => } /> + ( + + )} + />
); } diff --git a/docs/src/pages/components/autocomplete/Playground.tsx b/docs/src/pages/components/autocomplete/Playground.tsx index 785e960b84417c..f3f147dfc264c2 100644 --- a/docs/src/pages/components/autocomplete/Playground.tsx +++ b/docs/src/pages/components/autocomplete/Playground.tsx @@ -103,6 +103,13 @@ export default function Playground() { disabled renderInput={params => } /> + ( + + )} + />
); } diff --git a/docs/src/pages/components/autocomplete/UseAutocomplete.js b/docs/src/pages/components/autocomplete/UseAutocomplete.js index 4c6e7aa92994ec..f58a7c51ee85b3 100644 --- a/docs/src/pages/components/autocomplete/UseAutocomplete.js +++ b/docs/src/pages/components/autocomplete/UseAutocomplete.js @@ -4,6 +4,9 @@ import useAutocomplete from '@material-ui/lab/useAutocomplete'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles(theme => ({ + root: { + position: 'relative', + }, input: { width: 200, }, @@ -32,7 +35,7 @@ const useStyles = makeStyles(theme => ({ export default function UseAutocomplete() { const classes = useStyles(); const { - getRootProps, + getComboboxProps, getInputProps, getInputLabelProps, getListboxProps, @@ -44,10 +47,12 @@ export default function UseAutocomplete() { }); return ( -
- -
- +
+
+ +
+ +
{groupedOptions.length > 0 ? (
    {groupedOptions.map((option, index) => ( diff --git a/docs/src/pages/components/autocomplete/UseAutocomplete.tsx b/docs/src/pages/components/autocomplete/UseAutocomplete.tsx index b076dfc8f1ed59..13fc3ec5045c17 100644 --- a/docs/src/pages/components/autocomplete/UseAutocomplete.tsx +++ b/docs/src/pages/components/autocomplete/UseAutocomplete.tsx @@ -5,6 +5,9 @@ import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; const useStyles = makeStyles((theme: Theme) => createStyles({ + root: { + position: 'relative', + }, input: { width: 200, }, @@ -34,7 +37,7 @@ const useStyles = makeStyles((theme: Theme) => export default function UseAutocomplete() { const classes = useStyles(); const { - getRootProps, + getComboboxProps, getInputProps, getInputLabelProps, getListboxProps, @@ -46,10 +49,12 @@ export default function UseAutocomplete() { }); return ( -
    - -
    - +
    +
    + +
    + +
    {groupedOptions.length > 0 ? (
      {groupedOptions.map((option, index) => ( diff --git a/docs/src/pages/components/autocomplete/autocomplete.md b/docs/src/pages/components/autocomplete/autocomplete.md index 46214f226b27d5..86ff7e297d70b1 100644 --- a/docs/src/pages/components/autocomplete/autocomplete.md +++ b/docs/src/pages/components/autocomplete/autocomplete.md @@ -152,6 +152,13 @@ Search within 10,000 randomly generated options. The list is virtualized thanks {{"demo": "pages/components/autocomplete/Virtualize.js"}} +## Limitations + +### iOS VoiceOver + +VoiceOver on iOS Safari doesn't support the `aria-owns` attribute very well. +You can work around the issue with the `disablePortal` prop. + ## Accessibility (WAI-ARIA: https://www.w3.org/TR/wai-aria-practices/#combobox) diff --git a/packages/material-ui-lab/src/Autocomplete/Autocomplete.d.ts b/packages/material-ui-lab/src/Autocomplete/Autocomplete.d.ts index 003b5976640f9f..b59faeeb5636ee 100644 --- a/packages/material-ui-lab/src/Autocomplete/Autocomplete.d.ts +++ b/packages/material-ui-lab/src/Autocomplete/Autocomplete.d.ts @@ -2,7 +2,7 @@ import * as React from 'react'; import { StandardProps } from '@material-ui/core'; import { UseAutocompleteProps, CreateFilterOptions } from '../useAutocomplete'; -export interface PopupProps extends React.HTMLAttributes { +export interface PopperProps extends React.HTMLAttributes { anchorEl?: HTMLElement; open: boolean; popperRef: React.Ref; @@ -48,6 +48,11 @@ export interface AutocompleteProps * If `true`, the input will be disabled. */ disabled?: boolean; + /** + * Disable the portal behavior. + * The children stay within it's parent DOM hierarchy. + */ + disablePortal?: boolean; /** * The component used to render the listbox. */ @@ -69,9 +74,9 @@ export interface AutocompleteProps */ PaperComponent?: React.ComponentType>; /** - * The component used to render the popup. + * The component used to position the popup. */ - PopupComponent?: React.ComponentType; + PopperComponent?: React.ComponentType; /** * Render the group. * diff --git a/packages/material-ui-lab/src/Autocomplete/Autocomplete.js b/packages/material-ui-lab/src/Autocomplete/Autocomplete.js index dde74d6abda20f..79ecd16093040b 100644 --- a/packages/material-ui-lab/src/Autocomplete/Autocomplete.js +++ b/packages/material-ui-lab/src/Autocomplete/Autocomplete.js @@ -73,10 +73,14 @@ export const styles = theme => ({ popupIndicatorOpen: { transform: 'rotate(180deg)', }, - /* Styles applied to the popup element. */ - popup: { + /* Styles applied to the popper element. */ + popper: { zIndex: theme.zIndex.modal, }, + /* Styles applied to the popper element if `disablePortal={true}`. */ + popperDisablePortal: { + position: 'absolute', + }, /* Styles applied to the `Paper` component. */ paper: { ...theme.typography.body1, @@ -145,6 +149,12 @@ export const styles = theme => ({ }, }); +function DisablePortal(props) { + // eslint-disable-next-line react/prop-types + const { popperRef, anchorEl, open, ...other } = props; + return
      ; +} + const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { /* eslint-disable no-unused-vars */ const { @@ -161,6 +171,7 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { disabled = false, disableListWrap = false, disableOpenOnFocus = false, + disablePortal = false, filterOptions, filterSelectedOptions = false, freeSolo = false, @@ -180,7 +191,7 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { open, options = [], PaperComponent = Paper, - PopupComponent = Popper, + PopperComponent: PopperComponentProp = Popper, renderGroup: renderGroupProp, renderInput, renderOption: renderOptionProp, @@ -196,15 +207,15 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { popperRef.current.update(); } }); + const PopperComponent = disablePortal ? DisablePortal : PopperComponentProp; const { - getRootProps, + getComboboxProps, getInputProps, getInputLabelProps, getPopupIndicatorProps, getClearProps, getTagProps, - getPopupProps, getListboxProps, getOptionProps, value, @@ -267,73 +278,76 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { }; return ( -
      - {renderInput({ - ref: setAnchorEl, - disabled, - InputLabelProps: getInputLabelProps(), - InputProps: { - className: classes.inputRoot, - startAdornment, - endAdornment: ( - - {disableClearable || disabled ? null : ( - - - - )} - - {freeSolo ? null : ( - - - - )} - - ), - }, - inputProps: { - className: clsx(classes.input, { - [classes.inputFocused]: focusedTag === -1, - }), + +
      + {renderInput({ + ref: setAnchorEl, disabled, - ...getInputProps(), - }, - })} + InputLabelProps: getInputLabelProps(), + InputProps: { + className: classes.inputRoot, + startAdornment, + endAdornment: ( + + {disableClearable || disabled ? null : ( + + + + )} + {freeSolo ? null : ( + + + + )} + + ), + }, + inputProps: { + className: clsx(classes.input, { + [classes.inputFocused]: focusedTag === -1, + }), + disabled, + ...getInputProps(), + }, + })} +
      {popupOpen && anchorEl ? ( - {loading ?
      {loadingText}
      : null} @@ -356,9 +370,9 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) { ) : null}
      -
      + ) : null} -
      + ); }); @@ -426,6 +440,11 @@ Autocomplete.propTypes = { * If `true`, the popup won't open on input focus. */ disableOpenOnFocus: PropTypes.bool, + /** + * Disable the portal behavior. + * The children stay within it's parent DOM hierarchy. + */ + disablePortal: PropTypes.bool, /** * A filter function that determines the options that are eligible. * @@ -522,9 +541,9 @@ Autocomplete.propTypes = { */ PaperComponent: PropTypes.elementType, /** - * The component used to render the popup. + * The component used to position the popup. */ - PopupComponent: PropTypes.elementType, + PopperComponent: PropTypes.elementType, /** * Render the group. * @@ -555,7 +574,7 @@ Autocomplete.propTypes = { */ renderTags: PropTypes.func, /** - * The input value. + * The value of the autocomplete. */ value: PropTypes.any, }; diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts index 4160b0152da14a..85b7910efed304 100644 --- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts +++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.d.ts @@ -145,7 +145,7 @@ export interface UseAutocompleteProps { */ options?: any[]; /** - * The input value. + * The value of the autocomplete. */ value?: any; } @@ -153,13 +153,12 @@ export interface UseAutocompleteProps { export default function useAutocomplete( props: UseAutocompleteProps, ): { - getRootProps: () => {}; + getComboboxProps: () => {}; getInputProps: () => {}; getInputLabelProps: () => {}; getClearProps: () => {}; getPopupIndicatorProps: () => {}; getTagProps: () => {}; - getPopupProps: () => {}; getListboxProps: () => {}; getOptionProps: ({ option, index }: { option: any; index: number }) => {}; id: string; diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js index 72d7c84c367f72..e60cf0804ac886 100644 --- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js +++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js @@ -658,7 +658,7 @@ export default function useAutocomplete(props) { } return { - getRootProps: () => ({ + getComboboxProps: () => ({ 'aria-owns': popupOpen ? `${id}-popup` : null, role: 'combobox', 'aria-expanded': popupOpen, @@ -702,9 +702,6 @@ export default function useAutocomplete(props) { getTagProps: () => ({ onDelete: handleTagDelete, }), - getPopupProps: () => ({ - role: 'presentation', - }), getListboxProps: () => ({ role: 'listbox', id: `${id}-popup`,