Repository: woocommerce/woocommerce
Pull request #56000: Add virtual scrolling to SelectControl and country dropdowns
Author: login: ilyasfoo
Labels: name: plugin: woocommerce
description: Issues related to the WooCommerce Core plugin.
name: focus: monorepo infrastructure
description: Issues and PRs related to monorepo tooling.
name: package: @woocommerce/components
description: issues related to @woocommerce/components
name: focus: e2e tests
description: Issues related to e2e tests
name: needs: analysis
description: Indicates if the PR requires a PR testing scrub session.
Created: 2025-03-03T01:45:17Z | Merged: 2025-03-04T04:41:51Z

### Changes proposed in this Pull Request:

Addresses p1740728084833759-slack-C01SFMVEYAK:

> When i skip onboarding and it goes to the country selector, I go to interact with the dropdown, type a country or open it and in those initial couple of seconds it performs doggishly slow and is painful to interact with. (Chromium browser)

This PR:

- Adds `react-window` to `@woocommerce/packages`
- Adds virtual scrolling support for `SelectControl` component
- Implements virtual scrolling for core profiler business step, skip profiler, and StoreLocation component

### Screenshots or screen recordings:

| Before | After |
| ------ | ----- |
|   <img src="https://github.com/user-attachments/assets/0e5360d3-fb0b-415a-9c35-7a54bbf6df16" width="300">|   <img src="https://github.com/user-attachments/assets/3a00fd64-eb40-4f5c-8461-ef547549aab6" width="300">   |


<!-- Uploading d.pr/i/j8rnyX to GitHub… -->

<!-- Begin testing instructions -->

### How to test the changes in this Pull Request:

<!-- Include detailed instructions on how these changes can be tested. Review and follow the guide for how to write high-quality testing instructions. -->

Using the [WooCommerce Testing Instructions Guide](https://github.com/woocommerce/woocommerce/wiki/Writing-high-quality-testing-instructions), include your detailed testing instructions:

#### Testing core profiler business step

1. In a fresh site, go to core profiler `/wp-admin/admin.php?page=wc-admin&path=%2Fsetup-wizard`
2. Click on `Set up my store` and `Continue`
3. Click on `Where is your store located` dropdown and observe the time it takes to show options is significantly faster
4. Try scrolling, selecting, searching, and other interactions and ensure it's working similarly to previous version (the extremely brief blank options while scrolling is expected due to virtual rendering) 

#### Testing core profiler skip profiler

1. Go to skip guided setup, `/wp-admin/admin.php?page=wc-admin&path=%2Fsetup-wizard&step=skip-guided-setup`
2. Click on `Select country/region` dropdown and observe similar experience as previous test

#### Testing StoreLocation component

1. Go to `WooCommerce > Home`
2. Click on `Collect sales tax`
3. Select Woo tax partner if asked
4. Click on `Country / Region` and observe similar experience as previous test

#### Testing storybook

1. Go to `tools/storybook`
2. Run `npm run watch:build:storybook`
3. Open storybook and navigate to `Components > SelectControl`
4. Test `Virtual scrolling with many options` and observe similar experience as previous test
5. Optionally, test other dropdowns and ensure they're working as expected

<!-- End testing instructions -->

### Testing that has already taken place:

<!-- Detail any testing that has already been conducted. -->
<!-- Include environment details such as hosting type, plugins, theme, store size, store age, and relevant settings. -->
<!-- Mention any analysis performed, such as assessing potential impacts on environment attributes and other plugins, performance profiling, or LLM/AI-based analysis. -->
<!-- Within the testing details you provide, please ensure that no sensitive information (such as API keys, passwords, user data, etc.) is included in this public pull request. -->

### Changelog entry

<!-- You can optionally choose to enter a changelog entry by checking the box below and supplying data. -->
<!-- It will trigger the 'Add changelog to PR' CI job to create and push the entry into the branch. -->

<!-- Due to org permissions, the job might fail for PRs crated from a fork under GitHub organizations. Possible solutions: -->
<!-- * Create entry manually with `pnpm --filter='@woocommerce/plugin-woocommerce' changelog add` and push it into the branch (replace `@woocommerce/plugin-woocommerce` with package name from nearest `package.json` file) -->
<!-- * Create entry from supplied PR data and push it automatically `pnpm utils changefile pr-number-here -o github-org-name-here` -->

-   [ ] Automatically create a changelog entry from the details below.

<!-- If no changelog entry is required for this PR, you can specify that below and provide a comment explaining why. This cannot be used if you selected the option to automatically create a changelog entry above. -->

-   [ ] This Pull Request does not require a changelog entry. (Comment required below)

<details>

<summary>Changelog Entry Details</summary>

#### Significance

<!-- Choose only one -->

-   [ ] Patch
-   [ ] Minor
-   [ ] Major

#### Type

<!-- Choose only one -->

-   [ ] Fix - Fixes an existing bug
-   [ ] Add - Adds functionality
-   [ ] Update - Update existing functionality
-   [ ] Dev - Development related task
-   [ ] Tweak - A minor adjustment to the codebase
-   [ ] Performance - Address performance issues
-   [ ] Enhancement - Improvement to existing functionality

#### Message <!-- Add a changelog message here -->

</details>

<details>

<summary>Changelog Entry Comment</summary>

#### Comment <!-- If your Pull Request doesn't require a changelog entry, a comment explaining why is required instead -->

</details>


Diff:
diff --git a/packages/js/components/changelog/add-onboarding-business-step-virtual-select-control b/packages/js/components/changelog/add-onboarding-business-step-virtual-select-control
new file mode 100644
index 0000000000000..32f7eaab94edc
--- /dev/null
+++ b/packages/js/components/changelog/add-onboarding-business-step-virtual-select-control
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add react-window for SelectControl, set country dropdowns in core profiler and store location to use virtual scrolling
diff --git a/packages/js/components/package.json b/packages/js/components/package.json
index 291aff551e8b2..35b3269c59ef2 100644
--- a/packages/js/components/package.json
+++ b/packages/js/components/package.json
@@ -63,6 +63,7 @@
 		"@automattic/calypso-color-schemes": "^2.1.1",
 		"@automattic/interpolate-components": "^1.2.1",
 		"@automattic/tour-kit": "^1.1.3",
+		"@types/react-window": "^1.8.8",
 		"@types/wordpress__block-editor": "7.0.0",
 		"@types/wordpress__block-library": "2.6.1",
 		"@types/wordpress__blocks": "11.0.7",
@@ -115,7 +116,8 @@
 		"moment-timezone": "^0.5.43",
 		"prop-types": "^15.8.1",
 		"react-dates": "^21.8.0",
-		"react-transition-group": "^4.4.5"
+		"react-transition-group": "^4.4.5",
+		"react-window": "^1.8.11"
 	},
 	"peerDependencies": {
 		"@types/react": "18.3.x",
diff --git a/packages/js/components/src/select-control/README.md b/packages/js/components/src/select-control/README.md
index f7fe0c96499a0..64f249a6ba07c 100644
--- a/packages/js/components/src/select-control/README.md
+++ b/packages/js/components/src/select-control/README.md
@@ -49,6 +49,9 @@ const options = [
 | `showClearButton`        | boolean      | `false`    | Render a 'Clear' button next to the input box to remove its contents                                                                                            |
 | `hideBeforeSearch`       | boolean      | `false`    | Only show list options after typing a search query                                                                                                              |
 | `staticList`             | boolean      | `false`    | Render results list positioned statically instead of absolutely                                                                                                 |
+| `virtualScroll`          | boolean      | `false`    | Enable virtual scrolling for large lists                                                                                                                        |
+| `virtualItemHeight`      | number       | `35`       | Height in pixels for each virtual item                                                                                                                          |
+| `virtualListHeight`      | number       | `300`      | Maximum height in pixels for the virtualized list                                                                                                               |
 
 ### onChange value
 
@@ -56,3 +59,20 @@ The onChange value defaults to an array of the selected option(s), but will also
 If the `selected` prop has the value set as a `string`, the `onChange` method will also be called with a string value - the `key` of the selected option (if multiple is `false`).
 
 Only string or array are the supported types here.
+
+## Virtualized Lists for Large Datasets
+
+When dealing with a large number of options (thousands), you can enable virtualization for better performance. This feature uses the `react-window` library to render only the items that are visible in the viewport, significantly improving performance.
+
+```jsx
+<SelectControl
+	label="Select from large dataset"
+	onChange={ handleSelect }
+	options={ largeDataset } // Array with thousands of options
+	placeholder="Start typing to search..."
+	isSearchable={ true }
+	virtualScroll={ true } // Enable virtualization
+	virtualItemHeight={ 35 } // Height of each option in pixels
+	virtualListHeight={ 300 } // Maximum height of the dropdown
+/>
+```
diff --git a/packages/js/components/src/select-control/index.tsx b/packages/js/components/src/select-control/index.tsx
index 572b738a52114..af65e19bf7682 100644
--- a/packages/js/components/src/select-control/index.tsx
+++ b/packages/js/components/src/select-control/index.tsx
@@ -156,6 +156,18 @@ type Props = {
 	 * On Blur callback.
 	 */
 	onBlur?: () => void;
+	/**
+	 * Enable virtual scrolling for large lists of options.
+	 */
+	virtualScroll?: boolean;
+	/**
+	 * Height in pixels for each virtual item. Required when virtualScroll is true.
+	 */
+	virtualItemHeight?: number;
+	/**
+	 * Maximum height in pixels for the virtualized list. Default is 300.
+	 */
+	virtualListHeight?: number;
 };
 
 type State = {
@@ -196,6 +208,9 @@ export class SelectControl extends Component< Props, State > {
 		hideBeforeSearch: false,
 		staticList: false,
 		autoComplete: 'off',
+		virtualScroll: false,
+		virtualItemHeight: 35,
+		virtualListHeight: 300,
 	};
 
 	node: HTMLDivElement | null = null;
@@ -229,6 +244,13 @@ export class SelectControl extends Component< Props, State > {
 		this.setNewValue = this.setNewValue.bind( this );
 	}
 
+	componentDidUpdate( prevProps: Props ) {
+		const { selected } = this.props;
+		if ( selected !== prevProps.selected ) {
+			this.reset( selected );
+		}
+	}
+
 	bindNode( node: HTMLDivElement ) {
 		this.node = node;
 	}
@@ -518,6 +540,9 @@ export class SelectControl extends Component< Props, State > {
 			instanceId,
 			isSearchable,
 			options,
+			virtualScroll,
+			virtualItemHeight,
+			virtualListHeight,
 		} = this.props;
 		const { isExpanded, isFocused, selectedIndex } = this.state;
 
@@ -604,6 +629,9 @@ export class SelectControl extends Component< Props, State > {
 						decrementSelectedIndex={ this.decrementSelectedIndex }
 						incrementSelectedIndex={ this.incrementSelectedIndex }
 						setExpanded={ this.setExpanded }
+						virtualScroll={ virtualScroll }
+						virtualItemHeight={ virtualItemHeight }
+						virtualListHeight={ virtualListHeight }
 					/>
 				) }
 			</div>
diff --git a/packages/js/components/src/select-control/list.tsx b/packages/js/components/src/select-control/list.tsx
index 7e1d1a389187c..a3d5f57388f04 100644
--- a/packages/js/components/src/select-control/list.tsx
+++ b/packages/js/components/src/select-control/list.tsx
@@ -7,6 +7,7 @@ import classnames from 'classnames';
 import { createElement, Component, createRef } from '@wordpress/element';
 import { isEqual, isNumber } from 'lodash';
 import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, TAB } from '@wordpress/keycodes';
+import { FixedSizeList } from 'react-window';
 
 /**
  * Internal dependencies
@@ -58,6 +59,58 @@ type Props = {
 	 * Function to execute when the list should be expanded or collapsed.
 	 */
 	setExpanded: ( expanded: boolean ) => void;
+	/**
+	 * Enable virtual scrolling for large lists of options.
+	 */
+	virtualScroll?: boolean;
+	/**
+	 * Height in pixels for each virtual item.
+	 */
+	virtualItemHeight?: number;
+	/**
+	 * Maximum height in pixels for the virtualized list.
+	 */
+	virtualListHeight?: number;
+};
+
+const VirtualOption = ( {
+	index,
+	style,
+	data,
+}: {
+	index: number;
+	style: React.CSSProperties;
+	data: {
+		options: Option[];
+		selectedIndex: number | null | undefined;
+		instanceId: number;
+		onSelect: ( option: Option ) => void;
+		getOptionRef: ( index: number ) => RefObject< HTMLButtonElement >;
+	};
+} ) => {
+	const { options, selectedIndex, instanceId, onSelect, getOptionRef } = data;
+	const option = options[ index ];
+
+	return (
+		<Button
+			ref={ getOptionRef( index ) }
+			key={ option.key }
+			id={ `woocommerce-select-control__option-${ instanceId }-${ option.key }` }
+			role="option"
+			aria-selected={ index === selectedIndex }
+			aria-setsize={ options.length }
+			aria-posinset={ index + 1 }
+			disabled={ option.isDisabled }
+			className={ classnames( 'woocommerce-select-control__option', {
+				'is-selected': index === selectedIndex,
+			} ) }
+			onClick={ () => onSelect( option ) }
+			tabIndex={ -1 }
+			style={ style }
+		>
+			{ option.label }
+		</Button>
+	);
 };
 
 /**
@@ -66,6 +119,7 @@ type Props = {
 class List extends Component< Props > {
 	optionRefs: { [ key: number ]: RefObject< HTMLButtonElement > };
 	listbox: RefObject< HTMLDivElement >;
+	listRef: RefObject< FixedSizeList >;
 
 	constructor( props: Props ) {
 		super( props );
@@ -74,10 +128,11 @@ class List extends Component< Props > {
 		this.select = this.select.bind( this );
 		this.optionRefs = {};
 		this.listbox = createRef();
+		this.listRef = createRef();
 	}
 
 	componentDidUpdate( prevProps: Props ) {
-		const { options, selectedIndex } = this.props;
+		const { options, selectedIndex, virtualScroll } = this.props;
 
 		// Remove old option refs to avoid memory leaks.
 		if ( ! isEqual( options, prevProps.options ) ) {
@@ -88,7 +143,11 @@ class List extends Component< Props > {
 			selectedIndex !== prevProps.selectedIndex &&
 			isNumber( selectedIndex )
 		) {
-			this.scrollToOption( selectedIndex );
+			if ( virtualScroll && this.listRef.current ) {
+				this.listRef.current.scrollToItem( selectedIndex, 'smart' );
+			} else {
+				this.scrollToOption( selectedIndex );
+			}
 		}
 	}
 
@@ -222,7 +281,11 @@ class List extends Component< Props > {
 	componentDidMount() {
 		const { selectedIndex } = this.props;
 		if ( isNumber( selectedIndex ) && selectedIndex > -1 ) {
-			this.scrollToOption( selectedIndex );
+			if ( this.props.virtualScroll && this.listRef.current ) {
+				this.listRef.current.scrollToItem( selectedIndex, 'smart' );
+			} else {
+				this.scrollToOption( selectedIndex );
+			}
 		}
 		this.toggleKeyEvents( true );
 	}
@@ -232,15 +295,56 @@ class List extends Component< Props > {
 	}
 
 	render() {
-		const { instanceId, listboxId, options, selectedIndex, staticList } =
-			this.props;
+		const {
+			instanceId,
+			listboxId,
+			options,
+			selectedIndex,
+			staticList,
+			virtualScroll,
+			virtualItemHeight = 35,
+			virtualListHeight = 300,
+		} = this.props;
+
 		const listboxClasses = classnames(
 			'woocommerce-select-control__listbox',
 			{
 				'is-static': staticList,
+				'is-virtual': virtualScroll,
 			}
 		);
 
+		if ( virtualScroll ) {
+			return (
+				<div
+					id={ listboxId }
+					role="listbox"
+					className={ listboxClasses }
+					tabIndex={ -1 }
+				>
+					<FixedSizeList
+						ref={ this.listRef }
+						height={ Math.min(
+							virtualListHeight,
+							options.length * virtualItemHeight
+						) }
+						width="100%"
+						itemCount={ options.length }
+						itemSize={ virtualItemHeight }
+						itemData={ {
+							options,
+							selectedIndex,
+							instanceId,
+							onSelect: this.select,
+							getOptionRef: this.getOptionRef.bind( this ),
+						} }
+					>
+						{ VirtualOption }
+					</FixedSizeList>
+				</div>
+			);
+		}
+
 		return (
 			<div
 				ref={ this.listbox }
diff --git a/packages/js/components/src/select-control/stories/select-control.story.tsx b/packages/js/components/src/select-control/stories/select-control.story.tsx
index 060ca4677f551..73ebf86026ea7 100644
--- a/packages/js/components/src/select-control/stories/select-control.story.tsx
+++ b/packages/js/components/src/select-control/stories/select-control.story.tsx
@@ -51,6 +51,16 @@ const options = [
 	},
 ];
 
+// Create a larger list of options for virtual scrolling example
+const manyOptions = Array.from( { length: 2000 }, ( _, index ) => {
+	const key = `option-${ index + 1 }`;
+	return {
+		key,
+		label: `Option ${ index + 1 }`,
+		value: { id: key },
+	};
+} );
+
 const SelectControlExample = () => {
 	const [ state, setState ] = useState( {
 		simpleSelected: [],
@@ -60,6 +70,7 @@ const SelectControlExample = () => {
 		multipleSelected: [],
 		inlineSelected: [],
 		allOptionsIncludingSelected: options[ options.length - 1 ].key,
+		virtualScrollSelected: [],
 	} );
 
 	const {
@@ -70,6 +81,7 @@ const SelectControlExample = () => {
 		multipleSelected,
 		inlineSelected,
 		allOptionsIncludingSelected,
+		virtualScrollSelected,
 	} = state;
 
 	return (
@@ -160,6 +172,21 @@ const SelectControlExample = () => {
 				selected={ multipleSelected }
 				showClearButton
 			/>
+			<br />
+			<SelectControl
+				label="Virtual scrolling with many options"
+				isSearchable
+				onChange={ ( selected ) =>
+					setState( { ...state, virtualScrollSelected: selected } )
+				}
+				options={ manyOptions }
+				placeholder="Start typing to filter options..."
+				selected={ virtualScrollSelected }
+				showAllOnFocus
+				virtualScroll={ true }
+				virtualItemHeight={ 56 }
+				virtualListHeight={ 56 * 6 }
+			/>
 		</div>
 	);
 };
diff --git a/packages/js/components/src/select-control/style.scss b/packages/js/components/src/select-control/style.scss
index 2505a538fad57..f982a38555c33 100644
--- a/packages/js/components/src/select-control/style.scss
+++ b/packages/js/components/src/select-control/style.scss
@@ -131,6 +131,10 @@
 		&.is-static {
 			position: static;
 		}
+
+		&.is-virtual {
+			overflow: hidden;
+		}
 	}
 
 	.woocommerce-select-control__option {
diff --git a/packages/js/components/src/select-control/test/index.tsx b/packages/js/components/src/select-control/test/index.tsx
index 006cf467588b6..1c52fb199a4a0 100644
--- a/packages/js/components/src/select-control/test/index.tsx
+++ b/packages/js/components/src/select-control/test/index.tsx
@@ -225,6 +225,85 @@ describe( 'SelectControl', () => {
 		expect( queryByRole( 'option', { selected: true } ) ).toBeNull();
 	} );
 
+	describe( 'virtual scrolling', () => {
+		const manyOptions: Option[] = Array.from(
+			{ length: 100 },
+			( _, i ) => ( {
+				key: `${ i }`,
+				label: `Option ${ i }`,
+				value: { id: `${ i }` },
+			} )
+		);
+
+		it( 'renders with virtualScroll enabled', async () => {
+			const { getByRole, queryByRole } = render(
+				<SelectControl options={ manyOptions } virtualScroll />
+			);
+
+			userEvent.click( getByRole( 'combobox' ) );
+			await waitFor( () => {
+				expect(
+					getByRole( 'option', { name: 'Option 0' } )
+				).toBeInTheDocument();
+			} );
+
+			// Only a subset of options should be in the DOM due to virtualization
+			expect( queryByRole( 'option', { name: 'Option 99' } ) ).toBeNull();
+		} );
+
+		it( 'allows searching with virtualScroll enabled', async () => {
+			const { getByRole, queryByRole } = render(
+				<SelectControl
+					options={ manyOptions }
+					virtualScroll
+					isSearchable
+				/>
+			);
+
+			userEvent.click( getByRole( 'combobox' ) );
+			userEvent.type( getByRole( 'combobox' ), '99' );
+			await waitFor( () => {
+				expect(
+					getByRole( 'option', { name: 'Option 99' } )
+				).toBeInTheDocument();
+			} );
+
+			// Only Option 99 should be visible
+			expect( queryByRole( 'option', { name: 'Option 0' } ) ).toBeNull();
+		} );
+
+		it( 'allows selecting options using keyboard with virtualScroll enabled', async () => {
+			const onChangeMock = jest.fn();
+			const { getByRole } = render(
+				<SelectControl
+					options={ manyOptions }
+					virtualScroll
+					onChange={ onChangeMock }
+				/>
+			);
+
+			getByRole( 'combobox' ).focus();
+			userEvent.click( getByRole( 'combobox' ) );
+			await waitFor( () => {
+				expect(
+					getByRole( 'option', { name: 'Option 0' } )
+				).toBeInTheDocument();
+			} );
+
+			// Navigate and select Option 2
+			userEvent.type(
+				getByRole( 'combobox' ),
+				'{arrowdown}{arrowdown}{arrowdown}'
+			);
+			userEvent.type( getByRole( 'combobox' ), '{enter}' );
+
+			expect( onChangeMock ).toHaveBeenCalledWith(
+				[ { key: '2', label: 'Option 2', value: { id: '2' } } ],
+				''
+			);
+		} );
+	} );
+
 	describe( 'selected value', () => {
 		it( 'should return an array if array', async () => {
 			const onChangeMock = jest.fn();
diff --git a/plugins/woocommerce/changelog/add-onboarding-business-step-virtual-select-control b/plugins/woocommerce/changelog/add-onboarding-business-step-virtual-select-control
new file mode 100644
index 0000000000000..32f7eaab94edc
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-onboarding-business-step-virtual-select-control
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add react-window for SelectControl, set country dropdowns in core profiler and store location to use virtual scrolling
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessInfo.tsx b/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessInfo.tsx
index 58108851d1a9a..45f19797a490e 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessInfo.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessInfo.tsx
@@ -329,6 +329,9 @@ export const BusinessInfo = ( {
 						selected={ storeCountry ? [ storeCountry ] : [] }
 						showAllOnFocus
 						isSearchable
+						virtualScroll={ true }
+						virtualItemHeight={ 40 }
+						virtualListHeight={ 40 * 9 }
 					/>
 					{ countries.length === 0 && (
 						<Notice
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx b/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx
index 06f089800400c..f7ae65acb1dd0 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx
@@ -74,6 +74,9 @@ export const BusinessLocation = ( {
 					selected={ storeCountry ? [ storeCountry ] : [] }
 					showAllOnFocus
 					isSearchable
+					virtualScroll={ true }
+					virtualItemHeight={ 40 }
+					virtualListHeight={ 40 * 9 }
 				/>
 				<div className="woocommerce-profiler-button-container woocommerce-profiler-go-to-mystore__button-container">
 					<Button
diff --git a/plugins/woocommerce/client/admin/client/dashboard/components/settings/general/store-address.tsx b/plugins/woocommerce/client/admin/client/dashboard/components/settings/general/store-address.tsx
index b7c06bafd0e2e..06ddbdee573b3 100644
--- a/plugins/woocommerce/client/admin/client/dashboard/components/settings/general/store-address.tsx
+++ b/plugins/woocommerce/client/admin/client/dashboard/components/settings/general/store-address.tsx
@@ -390,6 +390,9 @@ export function StoreAddress( {
 					onCountryStateChange( selected as string );
 				} }
 				controlClassName={ getInputProps( 'countryState' ).className }
+				virtualScroll={ true }
+				virtualItemHeight={ 56 }
+				virtualListHeight={ 56 * 6 }
 			>
 				{ countryStateAutofill }
 			</SelectControl>
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/onboarding/onboarding-wizard.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/onboarding/onboarding-wizard.spec.js
index 13ada364055e1..94cb769cd43c7 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/onboarding/onboarding-wizard.spec.js
+++ b/plugins/woocommerce/tests/e2e-pw/tests/onboarding/onboarding-wizard.spec.js
@@ -91,12 +91,8 @@ test.describe(
 					.getByRole( 'option', { name: 'Clothing and accessories' } )
 					.click();
 				// select a WooPayments compatible location
-				await page
-					.locator(
-						'form.woocommerce-profiler-business-information-form > div > div > div > div > input'
-					)
-					.last()
-					.click();
+				await page.getByRole( 'combobox' ).last().click();
+				await page.getByRole( 'combobox' ).last().fill( 'Australia' );
 				await page
 					.getByRole( 'option', {
 						name: 'Australia — Northern Territory',
@@ -261,12 +257,8 @@ test.describe(
 					.getByRole( 'option', { name: 'Food and drink' } )
 					.click();
 				// select a WooPayments incompatible location
-				await page
-					.locator(
-						'form.woocommerce-profiler-business-information-form > div > div > div > div > input'
-					)
-					.last()
-					.click();
+				await page.getByRole( 'combobox' ).last().click();
+				await page.getByRole( 'combobox' ).last().fill( 'Afghanistan' );
 				await page
 					.getByRole( 'option', { name: 'Afghanistan' } )
 					.click();
@@ -496,6 +488,9 @@ test.describe(
 				} )
 			).toBeVisible();
 			await page.getByLabel( 'Select country/region' ).click();
+			await page
+				.getByLabel( 'Select country/region' )
+				.fill( 'California' );
 			await page
 				.getByRole( 'option', {
 					name: 'United States (US) — California',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 359492dbb6fd7..bf5c42669de46 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -633,6 +633,9 @@ importers:
       '@types/react-dom':
         specifier: 18.3.x
         version: 18.3.0
+      '@types/react-window':
+        specifier: ^1.8.8
+        version: 1.8.8
       '@types/wordpress__block-editor':
         specifier: 7.0.0
         version: 7.0.0(@emotion/is-prop-valid@1.2.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -801,6 +804,9 @@ importers:
       react-transition-group:
         specifier: ^4.4.5
         version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      react-window:
+        specifier: ^1.8.11
+        version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
     devDependencies:
       '@babel/core':
         specifier: ^7.23.5
@@ -10961,6 +10967,9 @@ packages:
   '@types/react-transition-group@4.4.10':
     resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==}
 
+  '@types/react-window@1.8.8':
+    resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
+
   '@types/react@18.3.16':
     resolution: {integrity: sha512-oh8AMIC4Y2ciKufU8hnKgs+ufgbA/dhPTACaZPM86AbwX9QwnFtSoPWEeRUj8fge+v6kFt78BXcDhAU1SrrAsw==}
 
@@ -23412,6 +23421,13 @@ packages:
       react: '>=16.0.0'
       react-dom: '>=16.0.0'
 
+  react-window@1.8.11:
+    resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
+    engines: {node: '>8.0.0'}
+    peerDependencies:
+      react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
   react-with-direction@1.4.0:
     resolution: {integrity: sha512-ybHNPiAmaJpoWwugwqry9Hd1Irl2hnNXlo/2SXQBwbLn/jGMauMS2y9jw+ydyX5V9ICryCqObNSthNt5R94xpg==}
     peerDependencies:
@@ -34271,9 +34287,7 @@ snapshots:
       jest-runner: 24.9.0
       jest-runtime: 24.9.0
     transitivePeerDependencies:
-      - bufferutil
       - supports-color
-      - utf-8-validate
 
   '@jest/test-sequencer@25.5.4':
     dependencies:
@@ -34285,7 +34299,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@jest/test-sequencer@26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))':
+  '@jest/test-sequencer@26.6.3':
     dependencies:
       '@jest/test-result': 26.6.2
       graceful-fs: 4.2.11
@@ -34293,11 +34307,7 @@ snapshots:
       jest-runner: 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
       jest-runtime: 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
     transitivePeerDependencies:
-      - bufferutil
-      - canvas
       - supports-color
-      - ts-node
-      - utf-8-validate
 
   '@jest/test-sequencer@27.5.1':
     dependencies:
@@ -39248,18 +39258,18 @@ snapshots:
       regenerator-runtime: 0.13.11
       resolve-from: 5.0.0
 
-  '@stylelint/postcss-css-in-js@0.37.3(postcss-syntax@0.36.2)(postcss@7.0.39)':
+  '@stylelint/postcss-css-in-js@0.37.3(postcss-syntax@0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39))(postcss@7.0.39)':
     dependencies:
       '@babel/core': 7.26.0
       postcss: 7.0.39
-      postcss-syntax: 0.36.2(postcss@8.4.32)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39)
     transitivePeerDependencies:
       - supports-color
 
-  '@stylelint/postcss-markdown@0.36.2(postcss-syntax@0.36.2)(postcss@7.0.39)':
+  '@stylelint/postcss-markdown@0.36.2(postcss-syntax@0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39))(postcss@7.0.39)':
     dependencies:
       postcss: 7.0.39
-      postcss-syntax: 0.36.2(postcss@8.4.32)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39)
       remark: 13.0.0
       unist-util-find-all-after: 3.0.2
     transitivePeerDependencies:
@@ -40185,6 +40195,10 @@ snapshots:
     dependencies:
       '@types/react': 18.3.16
 
+  '@types/react-window@1.8.8':
+    dependencies:
+      '@types/react': 18.3.16
+
   '@types/react@18.3.16':
     dependencies:
       '@types/prop-types': 15.7.11
@@ -45896,7 +45910,7 @@ snapshots:
       expect-puppeteer: 4.4.0
       filenamify: 4.3.0
       jest: 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
-      jest-circus: 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
+      jest-circus: 26.6.3
       jest-dev-server: 5.0.3(debug@4.3.4)
       jest-environment-node: 26.6.2
       markdownlint: 0.23.1
@@ -45975,7 +45989,7 @@ snapshots:
       expect-puppeteer: 4.4.0
       filenamify: 4.3.0
       jest: 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
-      jest-circus: 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
+      jest-circus: 26.6.3
       jest-dev-server: 5.0.3(debug@4.3.4)
       jest-environment-node: 26.6.2
       markdownlint: 0.23.1
@@ -54987,7 +55001,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  jest-circus@26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2)):
+  jest-circus@26.6.3:
     dependencies:
       '@babel/traverse': 7.25.9
       '@jest/environment': 26.6.2
@@ -55011,11 +55025,7 @@ snapshots:
       stack-utils: 2.0.6
       throat: 5.0.0
     transitivePeerDependencies:
-      - bufferutil
-      - canvas
       - supports-color
-      - ts-node
-      - utf-8-validate
 
   jest-circus@27.5.1:
     dependencies:
@@ -55273,7 +55283,7 @@ snapshots:
   jest-config@26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2)):
     dependencies:
       '@babel/core': 7.26.0
-      '@jest/test-sequencer': 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
+      '@jest/test-sequencer': 26.6.3
       '@jest/types': 26.6.2
       babel-jest: 26.6.3(@babel/core@7.26.0)
       chalk: 4.1.2
@@ -55283,7 +55293,7 @@ snapshots:
       jest-environment-jsdom: 26.6.2
       jest-environment-node: 26.6.2
       jest-get-type: 26.3.0
-      jest-jasmine2: 26.6.3
+      jest-jasmine2: 26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2))
       jest-regex-util: 26.0.0
       jest-resolve: 26.6.2
       jest-util: 26.6.2
@@ -55886,7 +55896,9 @@ snapshots:
       pretty-format: 24.9.0
       throat: 4.1.0
     transitivePeerDependencies:
+      - bufferutil
       - supports-color
+      - utf-8-validate
 
   jest-jasmine2@25.5.4:
     dependencies:
@@ -55908,9 +55920,12 @@ snapshots:
       pretty-format: 25.5.0
       throat: 5.0.0
     transitivePeerDependencies:
+      - bufferutil
+      - canvas
       - supports-color
+      - utf-8-validate
 
-  jest-jasmine2@26.6.3:
+  jest-jasmine2@26.6.3(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@22.9.1)(typescript@5.7.2)):
     dependencies:
       '@babel/traverse': 7.25.9
       '@jest/environment': 26.6.2
@@ -55931,7 +55946,11 @@ snapshots:
       pretty-format: 26.6.2
       throat: 5.0.0
     transitivePeerDependencies:
+      - bufferutil
+      - canvas
       - supports-color
+      - ts-node
+      - utf-8-validate
 
   jest-jasmine2@27.5.1:
     dependencies:
@@ -60085,11 +60104,11 @@ snapshots:
     dependencies:
       postcss: 7.0.39
 
-  postcss-html@0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39):
+  postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39):
     dependencies:
       htmlparser2: 3.10.1
       postcss: 7.0.39
-      postcss-syntax: 0.36.2(postcss@8.4.32)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39)
 
   postcss-less@3.1.4:
     dependencies:
@@ -60622,9 +60641,13 @@ snapshots:
       postcss-value-parser: 4.2.0
       svgo: 3.3.2
 
-  postcss-syntax@0.36.2(postcss@8.4.32):
+  postcss-syntax@0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39):
     dependencies:
-      postcss: 8.4.32
+      postcss: 7.0.39
+    optionalDependencies:
+      postcss-html: 0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39)
+      postcss-less: 3.1.4
+      postcss-scss: 2.1.1
 
   postcss-unique-selectors@4.0.1:
     dependencies:
@@ -61741,6 +61764,13 @@ snapshots:
       react: 18.3.1
       react-dom: 18.3.1(react@18.3.1)
 
+  react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+    dependencies:
+      '@babel/runtime': 7.26.0
+      memoize-one: 5.2.1
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+
   react-with-direction@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
     dependencies:
       airbnb-prop-types: 2.16.0(react@18.3.1)
@@ -63637,8 +63667,8 @@ snapshots:
 
   stylelint@13.13.1:
     dependencies:
-      '@stylelint/postcss-css-in-js': 0.37.3(postcss-syntax@0.36.2)(postcss@7.0.39)
-      '@stylelint/postcss-markdown': 0.36.2(postcss-syntax@0.36.2)(postcss@7.0.39)
+      '@stylelint/postcss-css-in-js': 0.37.3(postcss-syntax@0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39))(postcss@7.0.39)
+      '@stylelint/postcss-markdown': 0.36.2(postcss-syntax@0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39))(postcss@7.0.39)
       autoprefixer: 9.8.6
       balanced-match: 2.0.0
       chalk: 4.1.2
@@ -63664,7 +63694,7 @@ snapshots:
       micromatch: 4.0.7
       normalize-selector: 0.2.0
       postcss: 7.0.39
-      postcss-html: 0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39)
+      postcss-html: 0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39)
       postcss-less: 3.1.4
       postcss-media-query-parser: 0.2.3
       postcss-resolve-nested-selector: 0.1.1
@@ -63672,7 +63702,7 @@ snapshots:
       postcss-sass: 0.4.4
       postcss-scss: 2.1.1
       postcss-selector-parser: 6.0.13
-      postcss-syntax: 0.36.2(postcss@8.4.32)
+      postcss-syntax: 0.36.2(postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.4.32))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-scss@2.1.1)(postcss@7.0.39)
       postcss-value-parser: 4.2.0
       resolve-from: 5.0.0
       slash: 3.0.0


Reviews:
user: login: chihsuan
body: 

Nice work!  @ilyasfoo  I found an issue with keyboard navigation. The selected option is not focused in the dropdown when using keyboard to navigate. 

See videos below.


Before:

https://github.com/user-attachments/assets/c3f9484b-4f61-40c8-86b0-e2ca5045cd34

After:

https://github.com/user-attachments/assets/a971311b-a1b4-4bbc-8a2b-063ed19145bc


Summarizing comment about my review goes here.

#### Review checklist

| Parameter  | Result |
| ------------- | ------------- |
| Approach  | ✅ All good |
| Implementation  | ✅ All good |
| Automated tests  | If it's possible, we can add some unit tests for the virtual scrolling |
| Naming  | ✅ All good |
| Typing  | ✅ |
| Code comments  | Left a comment in the PR |
| Changelog  | ✅ |
| Documentation  | ✅ |
| PR description  | ✅ All good |
| Testing instructions  | ✅ All good |
| Manual testing  | An issue found with keyboard navigation |

#### Scenarios tested

On a fresh JN site.


https://github.com/user-attachments/assets/5e807d13-a5db-4db0-b341-b99d912accf8

| Scenario  | Result | Screenshot |
| ------------- | ------------- | ------------- |
| Testing core profiler business step  | ✅ Functionality worked correctly and confirm it's faster with virtual scrolling |  |
| Testing core profiler skip profiler  |  ✅ Functionality worked correctly and confirm it's faster with virtual scrolling |  |
| Testing StoreLocation component  | ✅ Functionality worked correctly and confirm it's faster with virtual scrolling  |
| Storybook  | ✅|  |


state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T07:37:43Z
user: login: ilyasfoo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T11:19:38Z
user: login: moon0326
body: LGTM and tested well 👍 🚀 

The list appears instantly now :)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T16:18:25Z
user: login: chihsuan
body: Thanks @ilyasfoo, I verified that the keyboard navigation is functioning correctly. 👍 

I also checked the VoiceOver accessibility feature and noticed that because we only render a portion of the list, VoiceOver does not announce the total number of options but only the currently visible ones. I'm not sure if this is something we can address, but I wanted to bring it to your attention.

<img width="1147" alt="Screenshot 2025-03-04 at 10 28 27" src="https://github.com/user-attachments/assets/3fa9127f-c0f6-4d7c-a8f0-9fce7c1c4c56" />

state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-04T02:35:23Z

Review comments:
diff_hunk: @@ -229,6 +244,13 @@ export class SelectControl extends Component< Props, State > {
 		this.setNewValue = this.setNewValue.bind( this );
 	}
 
+	componentDidUpdate( prevProps: Props ) {
+		const { selected } = this.props;
+		if ( selected !== prevProps.selected ) {
+			this.reset( selected );
+		}
+	}
path: packages/js/components/src/select-control/index.tsx
user: login: chihsuan
body: Is it intended to do this for all cases? Or should we only reset the state when `virtualScroll` is `true`?
created_at: 2025-03-03T07:12:45Z
author_association: MEMBER
diff_hunk: @@ -229,6 +244,13 @@ export class SelectControl extends Component< Props, State > {
 		this.setNewValue = this.setNewValue.bind( this );
 	}
 
+	componentDidUpdate( prevProps: Props ) {
+		const { selected } = this.props;
+		if ( selected !== prevProps.selected ) {
+			this.reset( selected );
+		}
+	}
path: packages/js/components/src/select-control/index.tsx
user: login: ilyasfoo
body: Yes this is intended for all cases. Sorry I forgot to mention, this is a fix for initial selection where the pre-selected option is not highlighted when after a fresh reload.
created_at: 2025-03-03T11:19:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-03T01:45:29Z
body: ## Testing Guidelines
Hi @chihsuan @moon0326 @woocommerce/flux,

Apart from reviewing the code changes, please make sure to **review the testing instructions** ([Guide](https://github.com/woocommerce/woocommerce/wiki/Writing-high-quality-testing-instructions)) and verify that relevant tests (E2E, Unit, Integration, etc.) have been added or updated as needed.

**Reminder:** PR reviewers are required to document testing performed. This includes:
- 🖼️ **Screenshots or screen recordings**.
- 📝 **List of functionality tested / steps followed**.
- 🌐 **Site details** (environment attributes such as hosting type, plugins, theme, store size, store age, and relevant settings).
- 🔍 **Any analysis performed**, such as assessing potential impacts on environment attributes and other plugins, conducting performance profiling, or using LLM/AI-based analysis.

⚠️ Within the testing details you provide, please ensure that no sensitive information (such as API keys, passwords, user data, etc.) is included in this public issue.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-03T01:58:09Z
body: 
## Test using WordPress Playground
The changes in this pull request can be previewed and tested using a [WordPress Playground](https://developer.wordpress.org/playground/) instance.
[WordPress Playground](https://developer.wordpress.org/playground/) is an experimental project that creates a full WordPress instance entirely within the browser.

[Test this pull request with WordPress Playground](https://playground.wordpress.net/#{"landingPage":"/wp-admin/admin.php?page=wc-admin","preferredVersions":{"php":"8.0","wp":"latest"},"phpExtensionBundles":["kitchen-sink"],"features":{"networking":true},"steps":[{"step":"installPlugin","pluginZipFile":{"resource":"url","url":"https://playground.wordpress.net/plugin-proxy.php?org=woocommerce&repo=woocommerce&workflow=Build%20Live%20Branch&artifact=plugins-13645808024&pr=56000"},"options":{"activate":true}},{"step":"installPlugin","pluginZipFile":{"resource":"url","url":"https://github-proxy.com/https://github.com/woocommerce/woocommerce/releases/download/wc-beta-tester-2.3.1/woocommerce-beta-tester.zip"},"options":{"activate":true}},{"step":"login","username":"admin","password":"password"}],"plugins":[]}).

Note that this URL is valid for 30 days from when this comment was last updated. You can update it by closing/reopening the PR or pushing a new commit.

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-03T01:59:31Z
body: **Size Change:** +4.21 kB (+0.08%) 

**Total Size:** 5.6 MB

| Filename | Size | Change |  |
| :--- | :---: | :---: | :---: |
| `./packages/js/components/build/select-control/list.js` | 2.06 kB | +377 B (+22.43%) | 🚨 |
| `./packages/js/components/build/select-control/test/index.js` | 2.46 kB | +297 B (+13.76%) | ⚠️ |
| `./plugins/woocommerce/client/admin/build/components/index.js` | 232 kB | +3.34 kB (+1.46%) |  |

<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: ilyasfoo
created_at: 2025-03-03T11:23:26Z
body: Thanks for the feedback, @chihsuan!

> The selected option is not focused in the dropdown when using keyboard to navigate.

Good catch, I've fixed this in 5d9035917e732ed47c352558be82fbdaca6491e9. Tbh I have don't know exactly why though, I suspect it could be the list height is somehow larger than the viewport.

> If it's possible, we can add some unit tests for the virtual scrolling

Good call, I've added unit tests in 4c7928cbb66d616ed355d68ea5e44ffd2d964b15

author_association: CONTRIBUTOR
user: login: ilyasfoo
created_at: 2025-03-04T03:49:18Z
body: Great catch, @chihsuan! Thanks for being so thorough. I added relevant aria attributes to support VoiceOver in d040156
author_association: CONTRIBUTOR