Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ComboboxControl: Convert to TypeScript #47581

Merged
merged 18 commits into from
Feb 6, 2023
Merged
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Copy link
Member Author

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting to see how null is being used here, in the context of #47473

Copy link
Member Author

Choose a reason for hiding this comment

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

Exactly, it might even be the first one I've seen with an explicit null 🤔


#### __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
Original file line number Diff line number Diff line change
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 );
Comment on lines +53 to +59
Copy link
Member Author

Choose a reason for hiding this comment

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

Typesafe helper function to explicitly handle the case when selectedSuggestion is null. Should not change runtime behavior, as Array.indexOf() will return -1 when there is no match.


/**
* `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"
Copy link
Member Author

Choose a reason for hiding this comment

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

Removed this tabIndex because it isn't a valid prop on BaseControl and doesn't do anything.

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: '' } }
ciampo marked this conversation as resolved.
Show resolved Hide resolved
displayTransform={ ( suggestion ) =>
suggestion.label
}
suggestions={ matchingSuggestions }
selectedIndex={ matchingSuggestions.indexOf(
selectedSuggestion
selectedIndex={ getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
) }
onHover={ setSelectedSuggestion }
onSelect={ onSuggestionSelected }
Expand Down
Loading