-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Changes from all commits
eef24a2
7b22327
0c0ff12
2760042
00f8854
22e5f4d
bb17a6a
ae5eb6a
497e33f
98b6471
8241d6d
0365ccb
5659821
ca5b3b2
550db09
d75b49a
9d39fbc
91bc89a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typesafe helper function to explicitly handle the case when |
||
|
||
/** | ||
* `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, | ||
|
@@ -62,7 +120,7 @@ function ComboboxControl( { | |
selected: __( 'Item selected.' ), | ||
}, | ||
__experimentalRenderItem, | ||
} ) { | ||
}: ComboboxControlProps ) { | ||
const [ value, setValue ] = useControlledValue( { | ||
value: valueProp, | ||
onChange: onChangeProp, | ||
|
@@ -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 ); | ||
|
@@ -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 ); | ||
|
@@ -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; | ||
|
@@ -118,7 +181,9 @@ function ComboboxControl( { | |
setIsExpanded( true ); | ||
}; | ||
|
||
const onKeyDown = ( event ) => { | ||
const onKeyDown: React.KeyboardEventHandler< HTMLDivElement > = ( | ||
event | ||
) => { | ||
let preventDefault = false; | ||
|
||
if ( | ||
|
@@ -177,7 +242,7 @@ function ComboboxControl( { | |
setIsExpanded( false ); | ||
}; | ||
|
||
const onInputChange = ( event ) => { | ||
const onInputChange: TokenInputProps[ 'onChange' ] = ( event ) => { | ||
const text = event.value; | ||
setInputValue( text ); | ||
onFilterValueChange( text ); | ||
|
@@ -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. | ||
|
@@ -235,15 +303,14 @@ function ComboboxControl( { | |
className, | ||
'components-combobox-control' | ||
) } | ||
tabIndex="-1" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed this |
||
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 | ||
|
@@ -258,8 +325,9 @@ function ComboboxControl( { | |
onFocus={ onFocus } | ||
onBlur={ onBlur } | ||
isExpanded={ isExpanded } | ||
selectedSuggestionIndex={ matchingSuggestions.indexOf( | ||
selectedSuggestion | ||
selectedSuggestionIndex={ getIndexOfMatchingSuggestion( | ||
selectedSuggestion, | ||
matchingSuggestions | ||
) } | ||
onChange={ onInputChange } | ||
/> | ||
|
@@ -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 } | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 #47473There was a problem hiding this comment.
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
🤔