-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Components: Implement a combobox control.
- Loading branch information
Showing
7 changed files
with
489 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import { useCombobox } from 'downshift'; | ||
import classnames from 'classnames'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { Button, Dashicon } from '../'; | ||
|
||
const itemToString = ( item ) => item && item.name; | ||
// This is needed so that in Windows, where | ||
// the menu does not necessarily open on | ||
// key up/down, you can still switch between | ||
// options with the menu closed. | ||
const stateReducer = ( | ||
{ selectedItem }, | ||
{ type, changes, props: { items } } | ||
) => { | ||
switch ( type ) { | ||
case useCombobox.stateChangeTypes.ToggleButtonKeyDownArrowDown: | ||
// If we already have a selected item, try to select the next one, | ||
// without circular navigation. Otherwise, select the first item. | ||
return { | ||
selectedItem: | ||
items[ | ||
selectedItem ? | ||
Math.min( items.indexOf( selectedItem ) + 1, items.length - 1 ) : | ||
0 | ||
], | ||
}; | ||
case useCombobox.stateChangeTypes.ToggleButtonKeyDownArrowUp: | ||
// If we already have a selected item, try to select the previous one, | ||
// without circular navigation. Otherwise, select the last item. | ||
return { | ||
selectedItem: | ||
items[ | ||
selectedItem ? | ||
Math.max( items.indexOf( selectedItem ) - 1, 0 ) : | ||
items.length - 1 | ||
], | ||
}; | ||
default: | ||
return changes; | ||
} | ||
}; | ||
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, | ||
stateReducer, | ||
} ); | ||
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { useState } from '@wordpress/element'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import ComboboxControl from '../'; | ||
|
||
export default { title: 'ComboboxControl', component: ComboboxControl }; | ||
|
||
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 ComboboxControlWithState() { | ||
const [ filteredOptions, setFilteredOptions ] = useState( options ); | ||
return ( | ||
<ComboboxControl | ||
label="Font Size" | ||
options={ filteredOptions } | ||
onInputValueChange={ ( { inputValue } ) => | ||
setFilteredOptions( | ||
options.filter( ( option ) => | ||
option.name.toLowerCase().startsWith( inputValue.toLowerCase() ) | ||
) | ||
) | ||
} | ||
/> | ||
); | ||
} | ||
export const _default = () => <ComboboxControlWithState />; |
Oops, something went wrong.