Skip to content

Commit

Permalink
ComboboxControl: Convert to TypeScript (#47581)
Browse files Browse the repository at this point in the history
* ComboboxControl: Rename file

* Remove tabIndex in BaseControl

It doesn't do anything

* ComboboxControl: Add types

* Mark `value` prop as optional

This definitely works with the initial value undefined, as seen in the readme code snippet and in unit tests.

* Convert tests

* Fixup

* Convert stories

* Remove from tsconfig

* Add changelog

* Add main JSDoc

* Make code snippets more concise

* Add code comment for empty string `value`

* Show code snippets

* Fix bug in Storybook

* Add explicit default value

* Fixup
  • Loading branch information
mirka committed Feb 6, 2023
1 parent e948f63 commit dc32380
Show file tree
Hide file tree
Showing 9 changed files with 296 additions and 382 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Expand Up @@ -4,6 +4,7 @@

### Internal

- `ComboboxControl`: Convert to TypeScript ([#47581](https://github.com/WordPress/gutenberg/pull/47581)).
- `Panel`, `PanelHeader`, `PanelRow`: Convert to TypeScript ([#47259](https://github.com/WordPress/gutenberg/pull/47259)).
- `BoxControl`: Convert to TypeScript ([#47622](https://github.com/WordPress/gutenberg/pull/47622)).
- `AnglePickerControl`: Convert to TypeScript ([#45820](https://github.com/WordPress/gutenberg/pull/45820)).
Expand Down
23 changes: 8 additions & 15 deletions packages/components/src/combobox-control/README.md
Expand Up @@ -17,9 +17,6 @@ These are the same as [the ones for `SelectControl`s](/packages/components/src/s
### Usage

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

Expand All @@ -36,10 +33,6 @@ const options = [
value: 'large',
label: 'Large',
},
{
value: 'huge',
label: 'Huge',
},
];

function MyComboboxControl() {
Expand Down Expand Up @@ -92,35 +85,35 @@ If this property is added, a help text will be generated using help property as

The options that can be chosen from.

- Type: `Array<{ value: String, label: String }>`
- Type: `Array<{ value: string, label: string }>`
- Required: Yes

#### onFilterValueChange

Function called with the control's search input value changes. The argument contains the next input value.
Function called when the control's search input value changes. The argument contains the next input value.

- Type: `Function`
- Type: `( value: string ) => void`
- Required: No

#### onChange

Function called with the selected value changes.

- Type: `Function`
- Type: `( value: string | null | undefined ) => void`
- Required: No

#### value

The current value of the input.
The current value of the control.

- Type: `mixed`
- Required: Yes
- Type: `string | null`
- Required: No

#### __experimentalRenderItem

Custom renderer invoked for each option in the suggestion list. The render prop receives as its argument an object containing, under the `item` key, the single option's data (directly from the array of data passed to the `options` prop).

- Type: `Function` - `( args: { item: object } ) => ReactNode`
- Type: `( args: { item: object } ) => ReactNode`
- Required: No

## Related components
Expand Down
Expand Up @@ -30,25 +30,83 @@ import { FlexBlock, FlexItem } from '../flex';
import withFocusOutside from '../higher-order/with-focus-outside';
import { useControlledValue } from '../utils/hooks';
import { normalizeTextString } from '../utils/strings';
import type { ComboboxControlOption, ComboboxControlProps } from './types';
import type { TokenInputProps } from '../form-token-field/types';

const noop = () => {};

const DetectOutside = withFocusOutside(
class extends Component {
// @ts-expect-error - TODO: Should be resolved when `withFocusOutside` is refactored to TypeScript
handleFocusOutside( event ) {
// @ts-expect-error - TODO: Should be resolved when `withFocusOutside` is refactored to TypeScript
this.props.onFocusOutside( event );
}

render() {
// @ts-expect-error - TODO: Should be resolved when `withFocusOutside` is refactored to TypeScript
return this.props.children;
}
}
);

const getIndexOfMatchingSuggestion = (
selectedSuggestion: ComboboxControlOption | null,
matchingSuggestions: ComboboxControlOption[]
) =>
selectedSuggestion === null
? -1
: matchingSuggestions.indexOf( selectedSuggestion );

/**
* `ComboboxControl` is an enhanced version of a [`SelectControl`](../select-control/README.md) with the addition of
* being able to search for options using a search input.
*
* ```jsx
* import { ComboboxControl } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const options = [
* {
* value: 'small',
* label: 'Small',
* },
* {
* value: 'normal',
* label: 'Normal',
* },
* {
* value: 'large',
* label: 'Large',
* },
* ];
*
* function MyComboboxControl() {
* const [ fontSize, setFontSize ] = useState();
* const [ filteredOptions, setFilteredOptions ] = useState( options );
* return (
* <ComboboxControl
* label="Font Size"
* value={ fontSize }
* onChange={ setFontSize }
* options={ filteredOptions }
* onFilterValueChange={ ( inputValue ) =>
* setFilteredOptions(
* options.filter( ( option ) =>
* option.label
* .toLowerCase()
* .startsWith( inputValue.toLowerCase() )
* )
* )
* }
* />
* );
* }
* ```
*/
function ComboboxControl( {
/** Start opting into the new margin-free styles that will become the default in a future version. */
__nextHasNoMarginBottom = false,
__next36pxDefaultSize,
__next36pxDefaultSize = false,
value: valueProp,
label,
options,
Expand All @@ -62,7 +120,7 @@ function ComboboxControl( {
selected: __( 'Item selected.' ),
},
__experimentalRenderItem,
} ) {
}: ComboboxControlProps ) {
const [ value, setValue ] = useControlledValue( {
value: valueProp,
onChange: onChangeProp,
Expand All @@ -80,11 +138,11 @@ function ComboboxControl( {
const [ isExpanded, setIsExpanded ] = useState( false );
const [ inputHasFocus, setInputHasFocus ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' );
const inputContainer = useRef();
const inputContainer = useRef< HTMLInputElement >( null );

const matchingSuggestions = useMemo( () => {
const startsWithMatch = [];
const containsMatch = [];
const startsWithMatch: ComboboxControlOption[] = [];
const containsMatch: ComboboxControlOption[] = [];
const match = normalizeTextString( inputValue );
options.forEach( ( option ) => {
const index = normalizeTextString( option.label ).indexOf( match );
Expand All @@ -98,7 +156,9 @@ function ComboboxControl( {
return startsWithMatch.concat( containsMatch );
}, [ inputValue, options ] );

const onSuggestionSelected = ( newSelectedSuggestion ) => {
const onSuggestionSelected = (
newSelectedSuggestion: ComboboxControlOption
) => {
setValue( newSelectedSuggestion.value );
speak( messages.selected, 'assertive' );
setSelectedSuggestion( newSelectedSuggestion );
Expand All @@ -107,7 +167,10 @@ function ComboboxControl( {
};

const handleArrowNavigation = ( offset = 1 ) => {
const index = matchingSuggestions.indexOf( selectedSuggestion );
const index = getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
);
let nextIndex = index + offset;
if ( nextIndex < 0 ) {
nextIndex = matchingSuggestions.length - 1;
Expand All @@ -118,7 +181,9 @@ function ComboboxControl( {
setIsExpanded( true );
};

const onKeyDown = ( event ) => {
const onKeyDown: React.KeyboardEventHandler< HTMLDivElement > = (
event
) => {
let preventDefault = false;

if (
Expand Down Expand Up @@ -177,7 +242,7 @@ function ComboboxControl( {
setIsExpanded( false );
};

const onInputChange = ( event ) => {
const onInputChange: TokenInputProps[ 'onChange' ] = ( event ) => {
const text = event.value;
setInputValue( text );
onFilterValueChange( text );
Expand All @@ -188,14 +253,17 @@ function ComboboxControl( {

const handleOnReset = () => {
setValue( null );
inputContainer.current.focus();
inputContainer.current?.focus();
};

// Update current selections when the filter input changes.
useEffect( () => {
const hasMatchingSuggestions = matchingSuggestions.length > 0;
const hasSelectedMatchingSuggestions =
matchingSuggestions.indexOf( selectedSuggestion ) > 0;
getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
) > 0;

if ( hasMatchingSuggestions && ! hasSelectedMatchingSuggestions ) {
// If the current selection isn't present in the list of suggestions, then automatically select the first item from the list of suggestions.
Expand Down Expand Up @@ -235,15 +303,14 @@ function ComboboxControl( {
className,
'components-combobox-control'
) }
tabIndex="-1"
label={ label }
id={ `components-form-token-input-${ instanceId }` }
hideLabelFromVision={ hideLabelFromVision }
help={ help }
>
<div
className="components-combobox-control__suggestions-container"
tabIndex="-1"
tabIndex={ -1 }
onKeyDown={ onKeyDown }
>
<InputWrapperFlex
Expand All @@ -258,8 +325,9 @@ function ComboboxControl( {
onFocus={ onFocus }
onBlur={ onBlur }
isExpanded={ isExpanded }
selectedSuggestionIndex={ matchingSuggestions.indexOf(
selectedSuggestion
selectedSuggestionIndex={ getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
) }
onChange={ onInputChange }
/>
Expand All @@ -279,13 +347,17 @@ function ComboboxControl( {
{ isExpanded && (
<SuggestionsList
instanceId={ instanceId }
match={ { label: inputValue } }
// The empty string for `value` here is not actually used, but is
// just a quick way to satisfy the TypeScript requirements of SuggestionsList.
// See: https://github.com/WordPress/gutenberg/pull/47581/files#r1091089330
match={ { label: inputValue, value: '' } }
displayTransform={ ( suggestion ) =>
suggestion.label
}
suggestions={ matchingSuggestions }
selectedIndex={ matchingSuggestions.indexOf(
selectedSuggestion
selectedIndex={ getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
) }
onHover={ setSelectedSuggestion }
onSelect={ onSuggestionSelected }
Expand Down

1 comment on commit dc32380

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky tests detected in dc32380.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4107417090
📝 Reported issues:

Please sign in to comment.