Skip to content

Commit

Permalink
Components: Implement a combobox control.
Browse files Browse the repository at this point in the history
  • Loading branch information
epiqueras committed Jan 15, 2020
1 parent c53b94d commit cc3d3eb
Show file tree
Hide file tree
Showing 7 changed files with 489 additions and 0 deletions.
6 changes: 6 additions & 0 deletions docs/manifest-devhub.json
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,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
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.
151 changes: 151 additions & 0 deletions packages/components/src/combobox-control/index.js
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>
);
}
51 changes: 51 additions & 0 deletions packages/components/src/combobox-control/stories/index.js
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 />;
Loading

0 comments on commit cc3d3eb

Please sign in to comment.