fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,117 @@
# ComboboxControl
`ComboboxControl` is an enhanced version of a [`SelectControl`](/packages/components/src/select-control/README.md), with the addition of being able to search for options using a search input.
## Design guidelines
These are the same as [the ones for `SelectControl`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
import { useState } from 'react';
import { ComboboxControl } from '@wordpress/components';
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.value === inputValue
)
)
}
/>
);
}
```
### Props
#### label
The label for the control.
- Type: `String`
- Required: Yes
#### hideLabelFromVision
If true, the label will only be visible to screen readers.
- Type: `Boolean`
- Required: No
#### help
If this property is added, a help text will be generated using help property as the content.
- Type: `String`
- Required: No
#### options
The options that can be chosen from.
- Type: `Array<{ value: string, label: string }>`
- Required: Yes
#### onFilterValueChange
Function called when the control's search input value changes. The argument contains the next input value.
- Type: `( value: string ) => void`
- Required: No
#### onChange
Function called with the selected value changes.
- Type: `( value: string | null | undefined ) => void`
- Required: No
#### value
The current value of the control.
- 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: `( args: { item: object } ) => ReactNode`
- 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.

View File

@@ -0,0 +1,385 @@
/**
* External dependencies
*/
import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import {
Component,
useState,
useMemo,
useRef,
useEffect,
} from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { speak } from '@wordpress/a11y';
import { closeSmall } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { InputWrapperFlex } from './styles';
import TokenInput from '../form-token-field/token-input';
import SuggestionsList from '../form-token-field/suggestions-list';
import BaseControl from '../base-control';
import Button from '../button';
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';
import { useDeprecated36pxDefaultSizeProp } from '../utils/use-deprecated-props';
const noop = () => {};
interface DetectOutsideComponentProps {
onFocusOutside: ( event: React.FocusEvent ) => void;
children?: React.ReactNode;
}
const DetectOutside = withFocusOutside(
class extends Component< DetectOutsideComponentProps > {
handleFocusOutside( event: React.FocusEvent ) {
this.props.onFocusOutside( event );
}
render() {
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( props: ComboboxControlProps ) {
const {
__nextHasNoMarginBottom = false,
__next40pxDefaultSize = false,
value: valueProp,
label,
options,
onChange: onChangeProp,
onFilterValueChange = noop,
hideLabelFromVision,
help,
allowReset = true,
className,
messages = {
selected: __( 'Item selected.' ),
},
__experimentalRenderItem,
} = useDeprecated36pxDefaultSizeProp< ComboboxControlProps >(
props,
'wp.components.ComboboxControl'
);
const [ value, setValue ] = useControlledValue( {
value: valueProp,
onChange: onChangeProp,
} );
const currentOption = options.find( ( option ) => option.value === value );
const currentLabel = currentOption?.label ?? '';
// Use a custom prefix when generating the `instanceId` to avoid having
// duplicate input IDs when rendering this component and `FormTokenField`
// in the same page (see https://github.com/WordPress/gutenberg/issues/42112).
const instanceId = useInstanceId( ComboboxControl, 'combobox-control' );
const [ selectedSuggestion, setSelectedSuggestion ] = useState(
currentOption || null
);
const [ isExpanded, setIsExpanded ] = useState( false );
const [ inputHasFocus, setInputHasFocus ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' );
const inputContainer = useRef< HTMLInputElement >( null );
const matchingSuggestions = useMemo( () => {
const startsWithMatch: ComboboxControlOption[] = [];
const containsMatch: ComboboxControlOption[] = [];
const match = normalizeTextString( inputValue );
options.forEach( ( option ) => {
const index = normalizeTextString( option.label ).indexOf( match );
if ( index === 0 ) {
startsWithMatch.push( option );
} else if ( index > 0 ) {
containsMatch.push( option );
}
} );
return startsWithMatch.concat( containsMatch );
}, [ inputValue, options ] );
const onSuggestionSelected = (
newSelectedSuggestion: ComboboxControlOption
) => {
setValue( newSelectedSuggestion.value );
speak( messages.selected, 'assertive' );
setSelectedSuggestion( newSelectedSuggestion );
setInputValue( '' );
setIsExpanded( false );
};
const handleArrowNavigation = ( offset = 1 ) => {
const index = getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
);
let nextIndex = index + offset;
if ( nextIndex < 0 ) {
nextIndex = matchingSuggestions.length - 1;
} else if ( nextIndex >= matchingSuggestions.length ) {
nextIndex = 0;
}
setSelectedSuggestion( matchingSuggestions[ nextIndex ] );
setIsExpanded( true );
};
const onKeyDown: React.KeyboardEventHandler< HTMLDivElement > = (
event
) => {
let preventDefault = false;
if (
event.defaultPrevented ||
// Ignore keydowns from IMEs
event.nativeEvent.isComposing ||
// Workaround for Mac Safari where the final Enter/Backspace of an IME composition
// is `isComposing=false`, even though it's technically still part of the composition.
// These can only be detected by keyCode.
event.keyCode === 229
) {
return;
}
switch ( event.code ) {
case 'Enter':
if ( selectedSuggestion ) {
onSuggestionSelected( selectedSuggestion );
preventDefault = true;
}
break;
case 'ArrowUp':
handleArrowNavigation( -1 );
preventDefault = true;
break;
case 'ArrowDown':
handleArrowNavigation( 1 );
preventDefault = true;
break;
case 'Escape':
setIsExpanded( false );
setSelectedSuggestion( null );
preventDefault = true;
break;
default:
break;
}
if ( preventDefault ) {
event.preventDefault();
}
};
const onBlur = () => {
setInputHasFocus( false );
};
const onFocus = () => {
setInputHasFocus( true );
setIsExpanded( true );
onFilterValueChange( '' );
setInputValue( '' );
};
const onFocusOutside = () => {
setIsExpanded( false );
};
const onInputChange: TokenInputProps[ 'onChange' ] = ( event ) => {
const text = event.value;
setInputValue( text );
onFilterValueChange( text );
if ( inputHasFocus ) {
setIsExpanded( true );
}
};
const handleOnReset = () => {
setValue( null );
inputContainer.current?.focus();
};
// Update current selections when the filter input changes.
useEffect( () => {
const hasMatchingSuggestions = matchingSuggestions.length > 0;
const hasSelectedMatchingSuggestions =
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.
setSelectedSuggestion( matchingSuggestions[ 0 ] );
}
}, [ matchingSuggestions, selectedSuggestion ] );
// Announcements.
useEffect( () => {
const hasMatchingSuggestions = matchingSuggestions.length > 0;
if ( isExpanded ) {
const message = hasMatchingSuggestions
? sprintf(
/* translators: %d: number of results. */
_n(
'%d result found, use up and down arrow keys to navigate.',
'%d results found, use up and down arrow keys to navigate.',
matchingSuggestions.length
),
matchingSuggestions.length
)
: __( 'No results.' );
speak( message, 'polite' );
}
}, [ matchingSuggestions, isExpanded ] );
// Disable reason: There is no appropriate role which describes the
// input container intended accessible usability.
// TODO: Refactor click detection to use blur to stop propagation.
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<DetectOutside onFocusOutside={ onFocusOutside }>
<BaseControl
__nextHasNoMarginBottom={ __nextHasNoMarginBottom }
className={ classnames(
className,
'components-combobox-control'
) }
label={ label }
id={ `components-form-token-input-${ instanceId }` }
hideLabelFromVision={ hideLabelFromVision }
help={ help }
>
<div
className="components-combobox-control__suggestions-container"
tabIndex={ -1 }
onKeyDown={ onKeyDown }
>
<InputWrapperFlex
__next40pxDefaultSize={ __next40pxDefaultSize }
>
<FlexBlock>
<TokenInput
className="components-combobox-control__input"
instanceId={ instanceId }
ref={ inputContainer }
value={ isExpanded ? inputValue : currentLabel }
onFocus={ onFocus }
onBlur={ onBlur }
isExpanded={ isExpanded }
selectedSuggestionIndex={ getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
) }
onChange={ onInputChange }
/>
</FlexBlock>
{ allowReset && (
<FlexItem>
<Button
className="components-combobox-control__reset"
icon={ closeSmall }
disabled={ ! value }
onClick={ handleOnReset }
label={ __( 'Reset' ) }
/>
</FlexItem>
) }
</InputWrapperFlex>
{ isExpanded && (
<SuggestionsList
instanceId={ instanceId }
// 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={ getIndexOfMatchingSuggestion(
selectedSuggestion,
matchingSuggestions
) }
onHover={ setSelectedSuggestion }
onSelect={ onSuggestionSelected }
scrollIntoView
__experimentalRenderItem={
__experimentalRenderItem
}
/>
) }
</div>
</BaseControl>
</DetectOutside>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}
export default ComboboxControl;

View File

@@ -0,0 +1,113 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import ComboboxControl from '..';
import type { ComboboxControlProps } from '../types';
const countries = [
{ name: 'Afghanistan', code: 'AF' },
{ name: 'Åland Islands', code: 'AX' },
{ name: 'Albania', code: 'AL' },
{ name: 'Algeria', code: 'DZ' },
{ name: 'American Samoa', code: 'AS' },
];
const meta: Meta< typeof ComboboxControl > = {
title: 'Components/ComboboxControl',
component: ComboboxControl,
argTypes: {
value: { control: { type: null } },
},
parameters: {
actions: { argTypesRegex: '^on.*' },
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const mapCountryOption = ( country: ( typeof countries )[ number ] ) => ( {
value: country.code,
label: country.name,
} );
const countryOptions = countries.map( mapCountryOption );
const Template: StoryFn< typeof ComboboxControl > = ( {
onChange,
...args
} ) => {
const [ value, setValue ] =
useState< ComboboxControlProps[ 'value' ] >( null );
return (
<>
<ComboboxControl
{ ...args }
value={ value }
onChange={ ( ...changeArgs ) => {
setValue( ...changeArgs );
onChange?.( ...changeArgs );
} }
/>
</>
);
};
export const Default = Template.bind( {} );
Default.args = {
allowReset: false,
label: 'Select a country',
options: countryOptions,
};
/**
* The rendered output of each suggestion can be customized by passing a
* render function to the `__experimentalRenderItem` prop. (This is still an experimental feature
* and is subject to change.)
*/
export const WithCustomRenderItem = Template.bind( {} );
WithCustomRenderItem.args = {
...Default.args,
label: 'Select an author',
options: [
{
value: 'parsley',
label: 'Parsley Montana',
age: 48,
country: 'Germany',
},
{
value: 'cabbage',
label: 'Cabbage New York',
age: 44,
country: 'France',
},
{
value: 'jake',
label: 'Jake Weary',
age: 41,
country: 'United Kingdom',
},
],
__experimentalRenderItem: ( { item } ) => {
const { label, age, country } = item;
return (
<div>
<div style={ { marginBottom: '0.2rem' } }>{ label }</div>
<small>
Age: { age }, Country: { country }
</small>
</div>
);
},
};

View File

@@ -0,0 +1,46 @@
.components-combobox-control {
width: 100%;
}
input.components-combobox-control__input[type="text"] {
width: 100%;
border: none;
box-shadow: none;
font-family: inherit;
font-size: 16px;
padding: 2px;
margin: 0;
line-height: inherit;
min-height: auto;
// Resolves Zooming on iOS devices
// https://github.com/WordPress/gutenberg/issues/27405
@include break-small() {
font-size: 13px;
}
&:focus {
outline: none;
box-shadow: none;
}
}
.components-combobox-control__suggestions-container {
@include input-control;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
width: 100%;
padding: 0;
&:focus-within {
@include input-style__focus();
}
}
.components-combobox-control__reset.components-button {
display: flex;
height: $grid-unit-20;
min-width: $grid-unit-20;
padding: 0;
}

View File

@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import { Flex } from '../flex';
import { space } from '../utils/space';
import type { ComboboxControlProps } from './types';
const deprecatedDefaultSize = ( {
__next40pxDefaultSize,
}: Pick< ComboboxControlProps, '__next40pxDefaultSize' > ) =>
! __next40pxDefaultSize &&
css`
height: 28px; // 30px - 2px vertical borders on parent container
padding-left: ${ space( 1 ) };
padding-right: ${ space( 1 ) };
`;
export const InputWrapperFlex = styled( Flex )`
height: 38px; // 40px - 2px vertical borders on parent container
padding-left: ${ space( 2 ) };
padding-right: ${ space( 2 ) };
${ deprecatedDefaultSize }
`;

View File

@@ -0,0 +1,309 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import ComboboxControl from '..';
import type { ComboboxControlOption, ComboboxControlProps } from '../types';
const timezones = [
{ label: 'Greenwich Mean Time', value: 'GMT' },
{ label: 'Universal Coordinated Time', value: 'UTC' },
{ label: 'European Central Time', value: 'ECT' },
{ label: '(Arabic) Egypt Standard Time', value: 'ART' },
{ label: 'Eastern African Time', value: 'EAT' },
{ label: 'Middle East Time', value: 'MET' },
{ label: 'Near East Time', value: 'NET' },
{ label: 'Pakistan Lahore Time', value: 'PLT' },
{ label: 'India Standard Time', value: 'IST' },
{ label: 'Bangladesh Standard Time', value: 'BST' },
{ label: 'Vietnam Standard Time', value: 'VST' },
{ label: 'China Taiwan Time', value: 'CTT' },
{ label: 'Japan Standard Time', value: 'JST' },
{ label: 'Australia Central Time', value: 'ACT' },
{ label: 'Australia Eastern Time', value: 'AET' },
{ label: 'Solomon Standard Time', value: 'SST' },
{ label: 'New Zealand Standard Time', value: 'NST' },
{ label: 'Midway Islands Time', value: 'MIT' },
{ label: 'Hawaii Standard Time', value: 'HST' },
{ label: 'Alaska Standard Time', value: 'AST' },
{ label: 'Pacific Standard Time', value: 'PST' },
{ label: 'Phoenix Standard Time', value: 'PNT' },
{ label: 'Mountain Standard Time', value: 'MST' },
{ label: 'Central Standard Time', value: 'CST' },
{ label: 'Eastern Standard Time', value: 'EST' },
{ label: 'Indiana Eastern Standard Time', value: 'IET' },
{ label: 'Puerto Rico and US Virgin Islands Time', value: 'PRT' },
{ label: 'Canada Newfoundland Time', value: 'CNT' },
{ label: 'Argentina Standard Time', value: 'AGT' },
{ label: 'Brazil Eastern Time', value: 'BET' },
{ label: 'Central African Time', value: 'CAT' },
];
const defaultLabelText = 'Select a timezone';
const getLabel = ( labelText: string ) => screen.getByText( labelText );
const getInput = ( name: string ) => screen.getByRole( 'combobox', { name } );
const getOption = ( name: string ) => screen.getByRole( 'option', { name } );
const getAllOptions = () => screen.getAllByRole( 'option' );
const getOptionSearchString = ( option: ComboboxControlOption ) =>
option.label.substring( 0, 11 );
const ControlledComboboxControl = ( {
value: valueProp,
onChange,
...props
}: ComboboxControlProps ) => {
const [ value, setValue ] = useState( valueProp );
const handleOnChange: ComboboxControlProps[ 'onChange' ] = ( newValue ) => {
setValue( newValue );
onChange?.( newValue );
};
return (
<>
<ComboboxControl
{ ...props }
value={ value }
onChange={ handleOnChange }
/>
</>
);
};
describe.each( [
[ 'uncontrolled', ComboboxControl ],
[ 'controlled', ControlledComboboxControl ],
] )( 'ComboboxControl %s', ( ...modeAndComponent ) => {
const [ , Component ] = modeAndComponent;
it( 'should render with visible label', () => {
render(
<Component options={ timezones } label={ defaultLabelText } />
);
const label = getLabel( defaultLabelText );
expect( label ).toBeInTheDocument();
expect( label ).toBeVisible();
} );
it( 'should render with hidden label', () => {
render(
<Component
options={ timezones }
label={ defaultLabelText }
hideLabelFromVision={ true }
/>
);
const label = getLabel( defaultLabelText );
expect( label ).toBeInTheDocument();
expect( label ).toHaveAttribute(
'data-wp-component',
'VisuallyHidden'
);
} );
it( 'should render with the correct options', async () => {
const user = await userEvent.setup();
render(
<Component options={ timezones } label={ defaultLabelText } />
);
const input = getInput( defaultLabelText );
// Clicking on the input shows the options
await user.click( input );
const renderedOptions = getAllOptions();
// Confirm the rendered options match the provided dataset.
expect( renderedOptions ).toHaveLength( timezones.length );
renderedOptions.forEach( ( option, optionIndex ) => {
expect( option ).toHaveTextContent(
timezones[ optionIndex ].label
);
} );
} );
it( 'should select the correct option via click events', async () => {
const user = await userEvent.setup();
const targetOption = timezones[ 2 ];
const onChangeSpy = jest.fn();
render(
<Component
options={ timezones }
label={ defaultLabelText }
onChange={ onChangeSpy }
/>
);
const input = getInput( defaultLabelText );
// Clicking on the input shows the options
await user.click( input );
// Select the target option
await user.click( getOption( targetOption.label ) );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenCalledWith( targetOption.value );
expect( input ).toHaveValue( targetOption.label );
} );
it( 'should select the correct option via keypress events', async () => {
const user = await userEvent.setup();
const targetIndex = 4;
const targetOption = timezones[ targetIndex ];
const onChangeSpy = jest.fn();
render(
<Component
options={ timezones }
label={ defaultLabelText }
onChange={ onChangeSpy }
/>
);
const input = getInput( defaultLabelText );
// Pressing tab selects the input and shows the options
await user.tab();
// Navigate the options using the down arrow
for ( let i = 0; i < targetIndex; i++ ) {
await user.keyboard( '{ArrowDown}' );
}
// Pressing Enter/Return selects the currently focused option
await user.keyboard( '{Enter}' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenCalledWith( targetOption.value );
expect( input ).toHaveValue( targetOption.label );
} );
it( 'should select the correct option from a search', async () => {
const user = await userEvent.setup();
const targetOption = timezones[ 13 ];
const onChangeSpy = jest.fn();
render(
<Component
options={ timezones }
label={ defaultLabelText }
onChange={ onChangeSpy }
/>
);
const input = getInput( defaultLabelText );
// Pressing tab selects the input and shows the options
await user.tab();
// Type enough characters to ensure a predictable search result
await user.keyboard( getOptionSearchString( targetOption ) );
// Pressing Enter/Return selects the currently focused option
await user.keyboard( '{Enter}' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenCalledWith( targetOption.value );
expect( input ).toHaveValue( targetOption.label );
} );
it( 'should render aria-live announcement upon selection', async () => {
const user = await userEvent.setup();
const targetOption = timezones[ 9 ];
const onChangeSpy = jest.fn();
render(
<Component
options={ timezones }
label={ defaultLabelText }
onChange={ onChangeSpy }
/>
);
// Pressing tab selects the input and shows the options
await user.tab();
// Type enough characters to ensure a predictable search result
await user.keyboard( getOptionSearchString( targetOption ) );
// Pressing Enter/Return selects the currently focused option
await user.keyboard( '{Enter}' );
expect(
screen.getByText( 'Item selected.', {
selector: '[aria-live]',
} )
).toBeInTheDocument();
} );
it( 'should process multiple entries in a single session', async () => {
const user = await userEvent.setup();
const unmatchedString = 'Mordor';
const targetOption = timezones[ 6 ];
const onChangeSpy = jest.fn();
render(
<Component
options={ timezones }
label={ defaultLabelText }
onChange={ onChangeSpy }
/>
);
const input = getInput( defaultLabelText );
// Pressing tab selects the input and shows the options
await user.tab();
const initialRenderedOptions = getAllOptions();
// Rendered options match the provided dataset.
expect( initialRenderedOptions ).toHaveLength( timezones.length );
initialRenderedOptions.forEach( ( option, optionIndex ) => {
expect( option ).toHaveTextContent(
timezones[ optionIndex ].label
);
} );
// No options are rendered if no match is found
await user.keyboard( unmatchedString );
expect( screen.queryByRole( 'option' ) ).not.toBeInTheDocument();
// Clearing the input renders all options again
await user.clear( input );
const postClearRenderedOptions = getAllOptions();
expect( postClearRenderedOptions ).toHaveLength( timezones.length );
postClearRenderedOptions.forEach( ( option, optionIndex ) => {
expect( option ).toHaveTextContent(
timezones[ optionIndex ].label
);
} );
// Run a second search with a valid string.
const searchString = getOptionSearchString( targetOption );
await user.keyboard( searchString );
const validSearchRenderedOptions = getAllOptions();
// Find option that match the search string.
const matches = timezones.filter( ( option ) =>
option.label.includes( searchString )
);
// Confirm the rendered options match the provided dataset based on the current string.
expect( validSearchRenderedOptions ).toHaveLength( matches.length );
validSearchRenderedOptions.forEach( ( option, optionIndex ) => {
expect( option ).toHaveTextContent( matches[ optionIndex ].label );
} );
// Confirm that the corrent option is selected
await user.keyboard( '{Enter}' );
expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
expect( onChangeSpy ).toHaveBeenCalledWith( targetOption.value );
expect( input ).toHaveValue( targetOption.label );
} );
} );

View File

@@ -0,0 +1,76 @@
/**
* Internal dependencies
*/
import type { BaseControlProps } from '../base-control/types';
export type ComboboxControlOption = {
label: string;
value: string;
[ key: string ]: any;
};
export type ComboboxControlProps = Pick<
BaseControlProps,
| '__nextHasNoMarginBottom'
| 'className'
| 'label'
| 'hideLabelFromVision'
| 'help'
> & {
/**
* 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).
*/
__experimentalRenderItem?: ( args: {
item: ComboboxControlOption;
} ) => React.ReactNode;
/**
* Deprecated. Use `__next40pxDefaultSize` instead.
*
* @default false
* @deprecated
*/
__next36pxDefaultSize?: boolean;
/**
* Start opting into the larger default height that will become the default size in a future version.
*
* @default false
*/
__next40pxDefaultSize?: boolean;
/**
* Show a reset button to clear the input.
*
* @default true
*/
allowReset?: boolean;
/**
* Customizable UI messages.
*/
messages?: {
/**
* The message to announce to screen readers when a suggestion is selected.
*
* @default `__( 'Item selected.' )`
*/
selected: string;
};
/**
* Function called with the selected value changes.
*/
onChange?: ( value: ComboboxControlProps[ 'value' ] ) => void;
/**
* Function called when the control's search input value changes. The argument contains the next input value.
*
* @default noop
*/
onFilterValueChange?: ( value: string ) => void;
/**
* The options that can be chosen from.
*/
options: ComboboxControlOption[];
/**
* The current value of the control.
*/
value?: string | null;
};