Skip to content

Commit

Permalink
Components: Add a WAI-ARIA compliant Combobox. (#19657)
Browse files Browse the repository at this point in the history
* Components: Implement a combobox control.

* Components: Update Downshift.

* Components: Update Downshift.
  • Loading branch information
epiqueras authored Jun 26, 2020
1 parent 45ef572 commit 76ef596
Show file tree
Hide file tree
Showing 9 changed files with 442 additions and 32 deletions.
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,12 @@
"markdown_source": "../packages/components/src/color-picker/README.md",
"parent": "components"
},
{
"title": "ComboboxControl",
"slug": "combobox-control",
"markdown_source": "../packages/components/src/combobox-control/README.md",
"parent": "components"
},
{
"title": "CustomSelectControl",
"slug": "custom-select-control",
Expand Down
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@wordpress/warning": "file:../warning",
"classnames": "^2.2.5",
"dom-scroll-into-view": "^1.2.1",
"downshift": "^4.0.5",
"downshift": "^5.4.0",
"gradient-parser": "^0.1.5",
"lodash": "^4.17.15",
"memize": "^1.1.0",
Expand Down
146 changes: 146 additions & 0 deletions packages/components/src/combobox-control/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# ComboboxControl

`ComboboxControl` is an enhanced version of a [`CustomSelectControl`](/packages/components/src/custom-select-control/readme.md), with the addition of being able to search for options using a search input.

## Table of contents

1. [Design guidelines](#design-guidelines)
2. [Development guidelines](#development-guidelines)
3. [Related components](#related-components)

## Design guidelines

These are the same as [the ones for `CustomSelectControl`s](/packages/components/src/select-control/readme.md#design-guidelines), but this component is better suited for when there are too many items to scroll through or load at once so you need to filter them based on user input.

## Development guidelines

### Usage

```jsx
/**
* WordPress dependencies
*/
import { ComboboxControl } from "@wordpress/components";
import { useState } from "@wordpress/compose";

const options = [
{
key: "small",
name: "Small",
style: { fontSize: "50%" }
},
{
key: "normal",
name: "Normal",
style: { fontSize: "100%" }
},
{
key: "large",
name: "Large",
style: { fontSize: "200%" }
},
{
key: "huge",
name: "Huge",
style: { fontSize: "300%" }
}
];

function MyComboboxControl() {
const [, setFontSize] = useState();
const [filteredOptions, setFilteredOptions] = useState(options);
return (
<ComboboxControl
label="Font Size"
options={filteredOptions}
onInputValueChange={({ inputValue }) =>
setFilteredOptions(
options.filter(option =>
option.name.toLowerCase().startsWith(inputValue.toLowerCase())
)
)
}
onChange={({ selectedItem }) => setFontSize(selectedItem)}
/>
);
}

function MyControlledComboboxControl() {
const [fontSize, setFontSize] = useState(options[0]);
const [filteredOptions, setFilteredOptions] = useState(options);
return (
<ComboboxControl
label="Font Size"
options={filteredOptions}
onInputValueChange={({ inputValue }) =>
setFilteredOptions(
options.filter(option =>
option.name.toLowerCase().startsWith(inputValue.toLowerCase())
)
)
}
onChange={({ selectedItem }) => setFontSize(selectedItem)}
value={options.find(option => option.key === fontSize.key)}
/>
);
}
```

### Props

#### className

A custom class name to append to the outer `<div>`.

- Type: `String`
- Required: No

#### hideLabelFromVision

Used to visually hide the label. It will always be visible to screen readers.

- Type: `Boolean`
- Required: No

#### label

The label for the control.

- Type: `String`
- Required: Yes

#### options

The options that can be chosen from.

- Type: `Array<{ key: String, name: String, style: ?{}, ...rest }>`
- Required: Yes

#### onInputValueChange

Function called with the control's search input value changes. The `inputValue` property contains the next input value.

- Type: `Function`
- Required: No

#### onChange

Function called with the control's internal state changes. The `selectedItem` property contains the next selected item.

- Type: `Function`
- Required: No

#### value

Can be used to externally control the value of the control, like in the `MyControlledComboboxControl` example above.

- Type: `Object`
- Required: No

## Related components

- Like this component, but without a search input, the `CustomSelectControl` component.

- To select one option from a set, when you want to show all the available options at once, use the `Radio` component.
- To select one or more items from a set, use the `CheckboxControl` component.
- To toggle a single setting on or off, use the `ToggleControl` component.
126 changes: 126 additions & 0 deletions packages/components/src/combobox-control/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* External dependencies
*/
import { useCombobox } from 'downshift';
import classnames from 'classnames';

/**
* Internal dependencies
*/
import { Button, Dashicon } from '../';

const itemToString = ( item ) => item && item.name;
export default function ComboboxControl( {
className,
hideLabelFromVision,
label,
options: items,
onInputValueChange: onInputValueChange,
onChange: onSelectedItemChange,
value: _selectedItem,
} ) {
const {
getLabelProps,
getToggleButtonProps,
getComboboxProps,
getInputProps,
getMenuProps,
getItemProps,
isOpen,
highlightedIndex,
selectedItem,
} = useCombobox( {
initialSelectedItem: items[ 0 ],
items,
itemToString,
onInputValueChange,
onSelectedItemChange,
selectedItem: _selectedItem,
} );
const menuProps = getMenuProps( {
className: 'components-combobox-control__menu',
} );
// We need this here, because the null active descendant is not
// fully ARIA compliant.
if (
menuProps[ 'aria-activedescendant' ] &&
menuProps[ 'aria-activedescendant' ].slice(
0,
'downshift-null'.length
) === 'downshift-null'
) {
delete menuProps[ 'aria-activedescendant' ];
}
return (
<div
className={ classnames( 'components-combobox-control', className ) }
>
{ /* eslint-disable-next-line jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */ }
<label
{ ...getLabelProps( {
className: classnames(
'components-combobox-control__label',
{
'screen-reader-text': hideLabelFromVision,
}
),
} ) }
>
{ label }
</label>
<div
{ ...getComboboxProps( {
className: 'components-combobox-control__button',
} ) }
>
<input
{ ...getInputProps( {
className: 'components-combobox-control__button-input',
} ) }
/>
<Button
{ ...getToggleButtonProps( {
// This is needed because some speech recognition software don't support `aria-labelledby`.
'aria-label': label,
'aria-labelledby': undefined,
className: 'components-combobox-control__button-button',
} ) }
>
<Dashicon
icon="arrow-down-alt2"
className="components-combobox-control__button-icon"
/>
</Button>
</div>
<ul { ...menuProps }>
{ isOpen &&
items.map( ( item, index ) => (
// eslint-disable-next-line react/jsx-key
<li
{ ...getItemProps( {
item,
index,
key: item.key,
className: classnames(
'components-combobox-control__item',
{
'is-highlighted':
index === highlightedIndex,
}
),
style: item.style,
} ) }
>
{ item === selectedItem && (
<Dashicon
icon="saved"
className="components-combobox-control__item-icon"
/>
) }
{ item.name }
</li>
) ) }
</ul>
</div>
);
}
Loading

0 comments on commit 76ef596

Please sign in to comment.