diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 5459fa35d806a..0148b78c5915c 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -12,6 +12,7 @@
- Refactor `FocalPointPicker` to function component ([#39168](https://github.com/WordPress/gutenberg/pull/39168)).
- `Guide`: use `code` instead of `keyCode` for keyboard events ([#43604](https://github.com/WordPress/gutenberg/pull/43604/)).
- `Navigation`: use `code` instead of `keyCode` for keyboard events ([#43644](https://github.com/WordPress/gutenberg/pull/43644/)).
+- `ComboboxControl`: Add unit tests ([#42403](https://github.com/WordPress/gutenberg/pull/42403)).
## 20.0.0 (2022-08-24)
diff --git a/packages/components/src/combobox-control/test/index.js b/packages/components/src/combobox-control/test/index.js
new file mode 100644
index 0000000000000..72033f679f960
--- /dev/null
+++ b/packages/components/src/combobox-control/test/index.js
@@ -0,0 +1,311 @@
+/**
+ * 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 '../';
+
+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 ) => screen.getByText( labelText );
+const getInput = ( name ) => screen.getByRole( 'combobox', { name } );
+const getOption = ( name ) => screen.getByRole( 'option', { name } );
+const getAllOptions = () => screen.getAllByRole( 'option' );
+const getOptionSearchString = ( option ) => option.label.substring( 0, 11 );
+const setupUser = () =>
+ userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+const ControlledComboboxControl = ( {
+ value: valueProp,
+ onChange,
+ ...props
+} ) => {
+ const [ value, setValue ] = useState( valueProp );
+ const handleOnChange = ( newValue ) => {
+ setValue( newValue );
+ onChange?.( newValue );
+ };
+ return (
+ <>
+
+ >
+ );
+};
+
+describe.each( [
+ [ 'uncontrolled', ComboboxControl ],
+ [ 'controlled', ControlledComboboxControl ],
+] )( 'ComboboxControl %s', ( ...modeAndComponent ) => {
+ const [ , Component ] = modeAndComponent;
+
+ it( 'should render with visible label', () => {
+ render(
+
+ );
+ const label = getLabel( defaultLabelText );
+ expect( label ).toBeInTheDocument();
+ expect( label ).toBeVisible();
+ } );
+
+ it( 'should render with hidden label', () => {
+ render(
+
+ );
+ const label = getLabel( defaultLabelText );
+
+ expect( label ).toBeInTheDocument();
+ expect( label ).toHaveAttribute(
+ 'data-wp-component',
+ 'VisuallyHidden'
+ );
+ } );
+
+ it( 'should render with the correct options', async () => {
+ const user = setupUser();
+ render(
+
+ );
+ 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 = setupUser();
+ const targetOption = timezones[ 2 ];
+ const onChangeSpy = jest.fn();
+ render(
+
+ );
+ 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 = setupUser();
+ const targetIndex = 4;
+ const targetOption = timezones[ targetIndex ];
+ const onChangeSpy = jest.fn();
+ render(
+
+ );
+ 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 = setupUser();
+ const targetOption = timezones[ 13 ];
+ const onChangeSpy = jest.fn();
+ render(
+
+ );
+ 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 = setupUser();
+ const targetOption = timezones[ 9 ];
+ const onChangeSpy = jest.fn();
+ render(
+
+ );
+
+ // 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 = setupUser();
+ const unmatchedString = 'Mordor';
+ const targetOption = timezones[ 6 ];
+ const onChangeSpy = jest.fn();
+ render(
+
+ );
+ 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' ) ).toBeNull();
+
+ // 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 );
+ } );
+} );