diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index f69acd0053daf1..248802890e46d6 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -57,7 +57,7 @@
- `NavigableMenu`: Refactor away from `_.includes()` ([#43518](https://github.com/WordPress/gutenberg/pull/43518/)).
- `Tooltip`: Refactor away from `_.includes()` ([#43518](https://github.com/WordPress/gutenberg/pull/43518/)).
- `TreeGrid`: Refactor away from `_.includes()` ([#43518](https://github.com/WordPress/gutenberg/pull/43518/)).
-
+- `FormTokenField`: use `KeyboardEvent.code`, refactor tests to modern RTL and `user-event` ([#43442](https://github.com/WordPress/gutenberg/pull/43442/)).
### Experimental
diff --git a/packages/components/src/form-token-field/index.tsx b/packages/components/src/form-token-field/index.tsx
index 06982a6a86df6c..aa7211ca16e1ea 100644
--- a/packages/components/src/form-token-field/index.tsx
+++ b/packages/components/src/form-token-field/index.tsx
@@ -12,17 +12,6 @@ import { useEffect, useRef, useState } from '@wordpress/element';
import { __, _n, sprintf } from '@wordpress/i18n';
import { useDebounce, useInstanceId, usePrevious } from '@wordpress/compose';
import { speak } from '@wordpress/a11y';
-import {
- BACKSPACE,
- ENTER,
- UP,
- DOWN,
- LEFT,
- RIGHT,
- SPACE,
- DELETE,
- ESCAPE,
-} from '@wordpress/keycodes';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
@@ -127,6 +116,11 @@ export function FormTokenField( props: FormTokenFieldProps ) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ incompleteTokenValue ] );
+ useEffect( () => {
+ updateSuggestions();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ __experimentalAutoSelectFirstMatch ] );
+
if ( disabled && isActive ) {
setIsActive( false );
setIncompleteTokenValue( '' );
@@ -179,35 +173,34 @@ export function FormTokenField( props: FormTokenFieldProps ) {
if ( event.defaultPrevented ) {
return;
}
- // TODO: replace to event.code;
- switch ( event.keyCode ) {
- case BACKSPACE:
+ switch ( event.code ) {
+ case 'Backspace':
preventDefault = handleDeleteKey( deleteTokenBeforeInput );
break;
- case ENTER:
+ case 'Enter':
preventDefault = addCurrentToken();
break;
- case LEFT:
+ case 'ArrowLeft':
preventDefault = handleLeftArrowKey();
break;
- case UP:
+ case 'ArrowUp':
preventDefault = handleUpArrowKey();
break;
- case RIGHT:
+ case 'ArrowRight':
preventDefault = handleRightArrowKey();
break;
- case DOWN:
+ case 'ArrowDown':
preventDefault = handleDownArrowKey();
break;
- case DELETE:
+ case 'Delete':
preventDefault = handleDeleteKey( deleteTokenAfterInput );
break;
- case SPACE:
+ case 'Space':
if ( tokenizeOnSpace ) {
preventDefault = addCurrentToken();
}
break;
- case ESCAPE:
+ case 'Escape':
preventDefault = handleEscapeKey( event );
break;
default:
@@ -533,8 +526,9 @@ export function FormTokenField( props: FormTokenFieldProps ) {
getMatchingSuggestions( incompleteTokenValue );
const hasMatchingSuggestions = matchingSuggestions.length > 0;
+ const shouldExpandIfFocuses = hasFocus() && __experimentalExpandOnFocus;
setIsExpanded(
- __experimentalExpandOnFocus ||
+ shouldExpandIfFocuses ||
( inputHasMinimumChars && hasMatchingSuggestions )
);
diff --git a/packages/components/src/form-token-field/test/index.js b/packages/components/src/form-token-field/test/index.js
deleted file mode 100644
index 417e8ef08946bc..00000000000000
--- a/packages/components/src/form-token-field/test/index.js
+++ /dev/null
@@ -1,442 +0,0 @@
-/**
- * External dependencies
- */
-import TestUtils, { act } from 'react-dom/test-utils';
-import ReactDOM from 'react-dom';
-
-/**
- * Internal dependencies
- */
-import fixtures from './lib/fixtures';
-import TokenFieldWrapper from './lib/token-field-wrapper';
-
-/**
- * Module variables
- */
-const keyCodes = {
- backspace: 8,
- tab: 9,
- enter: 13,
- leftArrow: 37,
- upArrow: 38,
- rightArrow: 39,
- downArrow: 40,
- delete: 46,
- comma: 188,
-};
-
-const charCodes = {
- comma: 44,
-};
-
-describe( 'FormTokenField', () => {
- let wrapper, wrapperElement, textInputElement, textInputComponent;
-
- function setText( text ) {
- TestUtils.Simulate.change( textInputElement(), {
- target: {
- value: text,
- },
- } );
- }
-
- function sendKeyDown( keyCode, shiftKey ) {
- TestUtils.Simulate.keyDown( wrapperElement(), {
- keyCode,
- shiftKey: !! shiftKey,
- } );
- }
-
- function sendKeyPress( charCode ) {
- TestUtils.Simulate.keyPress( wrapperElement(), { charCode } );
- }
-
- function getTokensHTML() {
- const textNodes = wrapperElement().querySelectorAll(
- '.components-form-token-field__token-text span[aria-hidden]'
- );
- return Array.from( textNodes ).map( ( node ) => node.innerHTML );
- }
-
- function getSuggestionsText( selector ) {
- const suggestionNodes = wrapperElement().querySelectorAll(
- selector || '.components-form-token-field__suggestion'
- );
-
- return Array.from( suggestionNodes ).map( getSuggestionNodeText );
- }
-
- function getSuggestionNodeText( node ) {
- if ( ! node.querySelector( 'span' ) ) {
- return node.outerHTML;
- }
-
- // This suggestion is part of a partial match; return up to three
- // sections of the suggestion (before match, match, and after
- // match).
- const div = document.createElement( 'div' );
- div.innerHTML = node.querySelector( 'span' ).outerHTML;
- return Array.from( div.firstChild.childNodes )
- .filter(
- ( childNode ) => childNode.nodeType !== childNode.COMMENT_NODE
- )
- .map( ( childNode ) => childNode.textContent );
- }
-
- function getSelectedSuggestion() {
- const selectedSuggestions = getSuggestionsText(
- '.components-form-token-field__suggestion.is-selected'
- );
-
- return selectedSuggestions[ 0 ] || null;
- }
-
- function setUp( props ) {
- wrapper = TestUtils.renderIntoDocument(
-
- );
- /* eslint-disable react/no-find-dom-node */
- wrapperElement = () => ReactDOM.findDOMNode( wrapper );
- textInputElement = () =>
- TestUtils.findRenderedDOMComponentWithClass(
- wrapper,
- 'components-form-token-field__input'
- );
-
- textInputComponent = () =>
- TestUtils.findRenderedDOMComponentWithTag( wrapper, 'input' );
- /* eslint-enable react/no-find-dom-node */
- TestUtils.Simulate.focus( textInputElement() );
- }
-
- describe( 'displaying tokens', () => {
- it( 'should render default tokens', () => {
- setUp();
- wrapper.setState( {
- isExpanded: true,
- } );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] );
- } );
-
- it( 'should display tokens with escaped special characters properly', () => {
- setUp();
- wrapper.setState( {
- tokens: fixtures.specialTokens.textEscaped,
- isExpanded: true,
- } );
- expect( getTokensHTML() ).toEqual(
- fixtures.specialTokens.htmlEscaped
- );
- } );
-
- it( 'should display tokens with special characters properly', () => {
- setUp();
- // This test is not as realistic as the previous one: if a WP site
- // contains tag names with special characters, the API will always
- // return the tag names already escaped. However, this is still
- // worth testing, so we can be sure that token values with
- // dangerous characters in them don't have these characters carried
- // through unescaped to the HTML.
- wrapper.setState( {
- tokens: fixtures.specialTokens.textUnescaped,
- isExpanded: true,
- } );
- expect( getTokensHTML() ).toEqual(
- fixtures.specialTokens.htmlUnescaped
- );
- } );
- } );
-
- describe( 'suggestions', () => {
- it( 'should not render suggestions unless we type at least two characters', () => {
- setUp();
- wrapper.setState( {
- isExpanded: true,
- } );
- expect( getSuggestionsText() ).toEqual( [] );
- act( () => {
- setText( 'th' );
- } );
- expect( getSuggestionsText() ).toEqual(
- fixtures.matchingSuggestions.th
- );
- } );
-
- it( 'should show suggestions when input is empty if expandOnFocus is set to true', () => {
- setUp( { __experimentalExpandOnFocus: true } );
- wrapper.setState( {
- isExpanded: true,
- } );
- expect( getSuggestionsText() ).not.toEqual( [] );
- } );
-
- it( 'should remove already added tags from suggestions', () => {
- setUp();
- wrapper.setState( {
- tokens: Object.freeze( [ 'of', 'and' ] ),
- } );
- expect( getSuggestionsText() ).not.toEqual( getTokensHTML() );
- } );
-
- it( 'suggestions that begin with match are boosted', () => {
- setUp();
- wrapper.setState( {
- isExpanded: true,
- } );
- act( () => {
- setText( 'so' );
- } );
- expect( getSuggestionsText() ).toEqual(
- fixtures.matchingSuggestions.so
- );
- } );
-
- it( 'should match against the unescaped values of suggestions with special characters', () => {
- setUp();
-
- act( () => {
- wrapper.setState( {
- tokenSuggestions: fixtures.specialSuggestions.textUnescaped,
- isExpanded: true,
- } );
- } );
-
- act( () => {
- setText( '& S' );
- } );
-
- expect( getSuggestionsText() ).toEqual(
- fixtures.specialSuggestions.matchAmpersandUnescaped
- );
- } );
-
- it( 'should match against the unescaped values of suggestions with special characters (including spaces)', () => {
- setUp();
- act( () => {
- wrapper.setState( {
- tokenSuggestions: fixtures.specialSuggestions.textUnescaped,
- isExpanded: true,
- } );
- } );
- act( () => {
- setText( 's &' );
- } );
- expect( getSuggestionsText() ).toEqual(
- fixtures.specialSuggestions.matchAmpersandSequence
- );
- } );
-
- it( 'should not match against the escaped values of suggestions with special characters', () => {
- setUp();
- act( () => {
- setText( 'amp' );
- } );
- act( () => {
- wrapper.setState( {
- tokenSuggestions: fixtures.specialSuggestions.textUnescaped,
- isExpanded: true,
- } );
- } );
- expect( getSuggestionsText() ).toEqual(
- fixtures.specialSuggestions.matchAmpersandEscaped
- );
- } );
-
- it( 'should match suggestions even with trailing spaces', () => {
- setUp();
- wrapper.setState( {
- isExpanded: true,
- } );
- act( () => {
- setText( ' at ' );
- } );
- expect( getSuggestionsText() ).toEqual(
- fixtures.matchingSuggestions.at
- );
- } );
-
- it( 'should manage the selected suggestion based on both keyboard and mouse events', () => {
- setUp();
- wrapper.setState( {
- isExpanded: true,
- } );
- act( () => {
- setText( 'th' );
- } );
- expect( getSuggestionsText() ).toEqual(
- fixtures.matchingSuggestions.th
- );
- expect( getSelectedSuggestion() ).toBe( null );
- sendKeyDown( keyCodes.downArrow ); // 'the'.
- expect( getSelectedSuggestion() ).toEqual( [ 'th', 'e' ] );
- sendKeyDown( keyCodes.downArrow ); // 'that'.
- expect( getSelectedSuggestion() ).toEqual( [ 'th', 'at' ] );
-
- const hoverSuggestion = wrapperElement().querySelectorAll(
- '.components-form-token-field__suggestion'
- )[ 3 ]; // 'with'.
- expect( getSuggestionNodeText( hoverSuggestion ) ).toEqual( [
- 'wi',
- 'th',
- ] );
-
- // Before sending a hover event, we need to wait for
- // SuggestionList#_scrollingIntoView to become false.
- act( () => {
- jest.advanceTimersByTime( 100 );
- } );
-
- TestUtils.Simulate.mouseEnter( hoverSuggestion );
- expect( getSelectedSuggestion() ).toEqual( [ 'wi', 'th' ] );
- sendKeyDown( keyCodes.upArrow );
- expect( getSelectedSuggestion() ).toEqual( [ 'th', 'is' ] );
- sendKeyDown( keyCodes.upArrow );
- expect( getSelectedSuggestion() ).toEqual( [ 'th', 'at' ] );
- TestUtils.Simulate.click( hoverSuggestion );
- expect( getSelectedSuggestion() ).toBe( null );
- expect( getTokensHTML() ).toEqual( [ 'foo', 'bar', 'with' ] );
- } );
-
- it( 'should re-render when suggestions prop has changed', () => {
- setUp();
- act( () => {
- wrapper.setState( {
- tokenSuggestions: [],
- isExpanded: true,
- } );
- } );
- expect( getSuggestionsText() ).toEqual( [] );
- act( () => {
- setText( 'so' );
- } );
- expect( getSuggestionsText() ).toEqual( [] );
- act( () => {
- wrapper.setState( {
- tokenSuggestions: fixtures.specialSuggestions.default,
- } );
- } );
- expect( getSuggestionsText() ).toEqual(
- fixtures.matchingSuggestions.so
- );
- act( () => {
- wrapper.setState( {
- tokenSuggestions: [],
- } );
- } );
- expect( getSuggestionsText() ).toEqual( [] );
- } );
- } );
-
- describe( 'adding tokens', () => {
- it( 'should not allow adding blank tokens with Tab', () => {
- setUp();
- sendKeyDown( keyCodes.tab );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] );
- } );
-
- it( 'should not allow adding whitespace tokens with Tab', () => {
- setUp();
- act( () => {
- setText( ' ' );
- } );
- sendKeyDown( keyCodes.tab );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] );
- } );
-
- it( 'should add a token when Enter pressed', () => {
- setUp();
- act( () => {
- setText( 'baz' );
- } );
- sendKeyDown( keyCodes.enter );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar', 'baz' ] );
- const textNode = textInputComponent();
- expect( textNode.value ).toBe( '' );
- } );
-
- it( 'should not allow adding blank tokens with Enter', () => {
- setUp();
- sendKeyDown( keyCodes.enter );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] );
- } );
-
- it( 'should not allow adding whitespace tokens with Enter', () => {
- setUp();
- setText( ' ' );
- sendKeyDown( keyCodes.enter );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] );
- } );
-
- it( 'should not allow adding whitespace tokens with comma', () => {
- setUp();
- setText( ' ' );
- sendKeyPress( charCodes.comma );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] );
- } );
-
- it( 'should add a token when comma pressed', () => {
- setUp();
- setText( 'baz' );
- sendKeyPress( charCodes.comma );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar', 'baz' ] );
- } );
-
- it( 'should trim token values when adding', () => {
- setUp();
- setText( ' baz ' );
- sendKeyDown( keyCodes.enter );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar', 'baz' ] );
- } );
-
- it( "should not add values that don't pass the validation", () => {
- setUp( {
- __experimentalValidateInput: ( newValue ) => newValue !== 'baz',
- } );
- setText( 'baz' );
- sendKeyDown( keyCodes.enter );
- expect( wrapper.state.tokens ).toEqual( [ 'foo', 'bar' ] );
- } );
-
- it( 'should automatically select the first matching suggestions when __experimentalAutoSelectFirstMatch is set to true', () => {
- setUp( { __experimentalAutoSelectFirstMatch: true } );
-
- wrapper.setState( {
- isExpanded: true,
- } );
-
- expect( getSuggestionsText() ).toEqual( [] );
-
- const searchText = 'so';
-
- act( () => {
- setText( searchText );
- } );
-
- const expectedFirstMatchTokens =
- fixtures.matchingSuggestions[ searchText ][ 0 ];
-
- expect( getSelectedSuggestion() ).toEqual(
- expectedFirstMatchTokens
- );
-
- sendKeyDown( keyCodes.enter );
-
- expect( wrapper.state.tokens ).toEqual( [
- 'foo',
- 'bar',
- expectedFirstMatchTokens.join( '' ),
- ] );
- } );
- } );
-
- describe( 'removing tokens', () => {
- it( 'should remove tokens when X icon clicked', () => {
- setUp();
- const forClickNode = wrapperElement().querySelector(
- '.components-form-token-field__remove-token'
- ).firstChild;
- TestUtils.Simulate.click( forClickNode );
- expect( wrapper.state.tokens ).toEqual( [ 'bar' ] );
- } );
- } );
-} );
diff --git a/packages/components/src/form-token-field/test/index.tsx b/packages/components/src/form-token-field/test/index.tsx
new file mode 100644
index 00000000000000..2dc08d02fa4f88
--- /dev/null
+++ b/packages/components/src/form-token-field/test/index.tsx
@@ -0,0 +1,2106 @@
+/**
+ * External dependencies
+ */
+import {
+ render,
+ screen,
+ within,
+ getDefaultNormalizer,
+ waitFor,
+} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ComponentProps } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import FormTokenField from '../';
+
+const FormTokenFieldWithState = ( {
+ onChange,
+ value,
+ initialValue = [],
+ ...props
+}: ComponentProps< typeof FormTokenField > & {
+ initialValue?: ComponentProps< typeof FormTokenField >[ 'value' ];
+} ) => {
+ const [ selectedValue, setSelectedValue ] =
+ useState< ComponentProps< typeof FormTokenField >[ 'value' ] >(
+ initialValue
+ );
+
+ return (
+ {
+ setSelectedValue( tokens );
+ onChange?.( tokens );
+ } }
+ />
+ );
+};
+
+const expectTokensToBeInTheDocument = ( tokensText: string[] ) => {
+ tokensText.forEach( ( tokenText, tokenIndex, tokensArray ) => {
+ // Each token has 2 tags rendered in the DOM:
+ // - one with the format "takenName (X of Y)", which is visibly hidden,
+ // and is used for assistive technology;
+ // - one with the format "tokenName", which is visible but hidden to
+ // assistive technology.
+ const assistiveTechnologyToken = screen.getByText(
+ `${ tokenText } (${ tokenIndex + 1 } of ${ tokensArray.length })`,
+ {
+ normalizer: getDefaultNormalizer( {
+ collapseWhitespace: false,
+ trim: false,
+ } ),
+ }
+ );
+ // The "exact" flag is necessary in order no to match the element
+ // used for assistive technology.
+ const visibleToken = screen.getByText( tokenText, {
+ exact: true,
+ normalizer: getDefaultNormalizer( {
+ collapseWhitespace: false,
+ trim: false,
+ } ),
+ } );
+
+ expect( assistiveTechnologyToken ).toBeInTheDocument();
+ expect( visibleToken ).toBeVisible();
+ expect( visibleToken ).toHaveAttribute( 'aria-hidden', 'true' );
+ } );
+};
+const expectTokensNotToBeInTheDocument = ( tokensText: string[] ) => {
+ tokensText.forEach( ( tokenText ) =>
+ expect( screen.queryByText( tokenText ) ).not.toBeInTheDocument()
+ );
+};
+
+const expectVisibleSuggestionsToBe = (
+ listElement: HTMLElement,
+ suggestionsText: string[]
+) => {
+ const allVisibleOptions = within( listElement ).queryAllByRole( 'option' );
+
+ expect( allVisibleOptions ).toHaveLength( suggestionsText.length );
+
+ allVisibleOptions.forEach( ( matchedOption, index ) => {
+ expect( matchedOption ).toHaveAccessibleName(
+ suggestionsText[ index ]
+ );
+ } );
+};
+
+function unescapeAndFormatSpaces( str: string ) {
+ const nbsp = String.fromCharCode( 160 );
+ const escaped = new DOMParser().parseFromString( str, 'text/html' );
+ return escaped.documentElement.textContent?.replace( / /g, nbsp ) ?? '';
+}
+
+describe( 'FormTokenField', () => {
+ describe( 'basic usage', () => {
+ it( "should add tokens with the input's value when pressing the enter key", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'apple' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'apple[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'apple' ] );
+ expectTokensToBeInTheDocument( [ 'apple' ] );
+
+ // Add 'pear' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'pear[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [
+ 'apple',
+ 'pear',
+ ] );
+ expectTokensToBeInTheDocument( [ 'apple', 'pear' ] );
+ } );
+
+ it( "should add a token with the input's value when pressing the comma key", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'orange' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'orange,' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'orange' ] );
+ expectTokensToBeInTheDocument( [ 'orange' ] );
+ } );
+
+ it( 'should add a token with the input value when pressing the space key and the `tokenizeOnSpace` prop is `true`', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'dragon fruit' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'dragon fruit[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'dragon fruit' ] );
+ expectTokensToBeInTheDocument( [ 'dragon fruit' ] );
+
+ rerender(
+
+ );
+
+ // Add 'dragon fruit' token by typing it and pressing enter to tokenize it,
+ // this time two separate tokens should be added
+ await user.type( input, 'dragon fruit[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
+ expect( onChangeSpy ).toHaveBeenNthCalledWith( 2, [
+ 'dragon fruit',
+ 'dragon',
+ ] );
+ expect( onChangeSpy ).toHaveBeenNthCalledWith( 3, [
+ 'dragon fruit',
+ 'dragon',
+ 'fruit',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'dragon fruit',
+ 'dragon',
+ 'fruit',
+ ] );
+ } );
+
+ it( "should not add a token with the input's value when pressing the tab key", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'orange' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'grapefruit' );
+ await user.tab();
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 0 );
+ expectTokensNotToBeInTheDocument( [ 'grapefruit' ] );
+ } );
+
+ it( 'should remove the last token when pressing the backspace key', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Press backspace to remove the last token ("mango")
+ await user.type( input, '[Backspace]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [ 'banana' ] );
+ expectTokensToBeInTheDocument( [ 'banana' ] );
+ expectTokensNotToBeInTheDocument( [ 'mango' ] );
+
+ // Press backspace to remove the last token ("banana")
+ await user.type( input, '[Backspace]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [] );
+ expectTokensNotToBeInTheDocument( [ 'banana', 'mango' ] );
+ } );
+
+ it( 'should remove a token when clicking the token\'s "remove" button', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ expectTokensToBeInTheDocument( [ 'lemon', 'bergamot' ] );
+
+ // There should be 2 "remove item" buttons, one per token
+ expect(
+ screen.getAllByRole( 'button', { name: 'Remove item' } )
+ ).toHaveLength( 2 );
+
+ // Click the "X" button for the "lemon" token (the first one)
+ await user.click(
+ screen.getAllByRole( 'button', { name: 'Remove item' } )[ 0 ]
+ );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [ 'bergamot' ] );
+ expectTokensToBeInTheDocument( [ 'bergamot' ] );
+ expectTokensNotToBeInTheDocument( [ 'lemon' ] );
+
+ // There should be 1 "remove item" button for the "bergamot" token
+ expect(
+ screen.getAllByRole( 'button', { name: 'Remove item' } )
+ ).toHaveLength( 1 );
+
+ // Click the "X" button for the "bergamot" token (the only one)
+ await user.click(
+ screen.getAllByRole( 'button', { name: 'Remove item' } )[ 0 ]
+ );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [] );
+ expectTokensNotToBeInTheDocument( [ 'lemon', 'bergamot' ] );
+ } );
+
+ it( 'should remove a token when by focusing on the token\'s "remove" button and pressing space bar', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+ await user.click( input );
+
+ // Currently the focus in on the input. Pressing shift+tab twice should
+ // move focus on the "remove item" button of the first token ("persimmon")
+ await user.tab( { shift: true } );
+ await user.tab( { shift: true } );
+
+ expect(
+ screen.getAllByRole( 'button', { name: 'Remove item' } )
+ ).toHaveLength( 2 );
+ expect(
+ screen.getAllByRole( 'button', { name: 'Remove item' } )[ 0 ]
+ ).toHaveFocus();
+
+ // Pressing the "space" key on the button should remove the "persimmon" item
+ await user.keyboard( '[Space]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [ 'plum' ] );
+ expectTokensToBeInTheDocument( [ 'plum' ] );
+ expectTokensNotToBeInTheDocument( [ 'persimmon' ] );
+ } );
+
+ it( 'should not add a new token if a token with the same value already exists', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'guava' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'guava[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'papaya', 'guava' ] );
+ expectTokensToBeInTheDocument( [ 'papaya', 'guava' ] );
+
+ // Try to add a 'papaya' token by typing it and pressing enter to tokenize it,
+ // but the token won't be added because it already exists.
+ await user.type( input, 'papaya[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expectTokensToBeInTheDocument( [ 'papaya', 'guava' ] );
+ } );
+
+ it( 'should not add a new token if the text input is blank', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Press enter on an empty input, no token gets added
+ await user.type( input, '[Enter]' );
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+ expectTokensToBeInTheDocument( [ 'melon' ] );
+ } );
+
+ it( 'should allow moving the cursor through the tokens when pressing the arrow keys, and should remove the token in front of the cursor when pressing the delete key', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ expectTokensToBeInTheDocument( [
+ 'kiwi',
+ 'peach',
+ 'nectarine',
+ 'coconut',
+ ] );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Press "delete" to delete the token in front of the cursor, but since
+ // there's no token in front of the cursor, nothing happens
+ await user.type( input, '[Delete]' );
+
+ // Pressing the right arrow doesn't move the cursor because there are no
+ // tokens in front of it, and therefore pressing "delete" yields the same
+ // result as before — no tokens are deleted.
+ await user.type( input, '[ArrowRight][Delete]' );
+
+ // Proof that so far, all keyboard interactions didn't delete any tokens.
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+ expectTokensToBeInTheDocument( [
+ 'kiwi',
+ 'peach',
+ 'nectarine',
+ 'coconut',
+ ] );
+
+ // Press the left arrow 4 times, moving cursor between the "kiwi" and
+ // "peach" tokens. Pressing the "delete" key will delete the "peach"
+ // token, since it's in front of the cursor.
+ await user.type(
+ input,
+ '[ArrowLeft][ArrowLeft][ArrowLeft][ArrowLeft][Delete]'
+ );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'peach',
+ 'nectarine',
+ 'coconut',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'peach',
+ 'nectarine',
+ 'coconut',
+ ] );
+ expectTokensNotToBeInTheDocument( [ 'kiwi' ] );
+
+ // Press backspace to delete the token before the cursor, but since
+ // there's no token before the cursor, nothing happens
+ await user.type( input, '[Backspace]' );
+
+ // Pressing the left arrow doesn't move the cursor because there are no
+ // tokens before it, and therefore pressing backspace yields the same
+ // result as before — no tokens are deleted.
+ await user.type( input, '[ArrowLeft][Backspace]' );
+
+ // Proof that pressing backspace hasn't caused any further token deletion.
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+
+ // Press the right arrow, moving cursor between the "kiwi" and
+ // "nectarine" tokens. Pressing the "delete" key will delete the "nectarine"
+ // token, since it's in front of the cursor.
+ await user.type( input, '[ArrowRight][Delete]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'peach',
+ 'coconut',
+ ] );
+ expectTokensToBeInTheDocument( [ 'peach', 'coconut' ] );
+ expectTokensNotToBeInTheDocument( [ 'kiwi', 'nectarine' ] );
+
+ // Add 'starfruit' token while the cursor is in between the "peach" and
+ // "coconut" tokens.
+ await user.type( input, 'starfruit[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'peach',
+ // Notice that starfruit is added in between "peach" and "coconut"
+ 'starfruit',
+ 'coconut',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'peach',
+ 'starfruit',
+ 'coconut',
+ ] );
+ } );
+
+ it( "should add additional classnames passed via the `className` prop to the input element's 2nd level wrapper", () => {
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // This is testing implementation details, but I'm not sure there's
+ // a better way.
+ expect( input.parentElement?.parentElement ).toHaveClass(
+ 'test-classname'
+ );
+ } );
+
+ it( 'should label the input correctly via the `label` prop', () => {
+ const { rerender } = render( );
+
+ expect(
+ screen.getByRole( 'combobox', { name: 'Add item' } )
+ ).toBeVisible();
+
+ rerender( );
+
+ expect(
+ screen.getByRole( 'combobox', { name: 'Test label' } )
+ ).toBeVisible();
+ } );
+
+ it( 'should fire the `onFocus` callback when the input is focused', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onFocusSpy = jest.fn();
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ await user.click( input );
+
+ expect( onFocusSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onFocusSpy ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ type: 'focus',
+ target: input,
+ } )
+ );
+
+ expect( input ).toHaveFocus();
+ } );
+
+ it( "should fire the `onInputChange` callback when the input's value changes", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onInputChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ await user.type( input, 'strawberry[Enter]' );
+
+ expect( onInputChangeSpy ).toHaveBeenCalledTimes(
+ 'strawberry'.length
+ );
+ expect( onInputChangeSpy ).toHaveBeenNthCalledWith(
+ 5,
+ 'strawberry'.slice( 0, 5 )
+ );
+ } );
+
+ it( 'should show extra instructions when the `__experimentalShowHowTo` prop is set to `true`', () => {
+ const instructionsTokenizeSpace =
+ 'Separate with commas, spaces, or the Enter key.';
+ const instructionsDefault =
+ 'Separate with commas or the Enter key.';
+
+ // The __experimentalShowHowTo prop is `true` by default
+ const { rerender } = render( );
+
+ expect( screen.getByText( instructionsDefault ) ).toBeVisible();
+
+ // The "show how to" text is used to aria-describedby the input
+ expect(
+ screen.getByRole( 'combobox' )
+ ).toHaveAccessibleDescription( instructionsDefault );
+
+ rerender( );
+
+ expect(
+ screen.getByText( instructionsTokenizeSpace )
+ ).toBeVisible();
+
+ // The "show how to" text is used to aria-describedby the input
+ expect(
+ screen.getByRole( 'combobox' )
+ ).toHaveAccessibleDescription( instructionsTokenizeSpace );
+
+ rerender(
+
+ );
+
+ expect(
+ screen.queryByText( instructionsDefault )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText( instructionsTokenizeSpace )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole( 'combobox' )
+ ).not.toHaveAccessibleDescription();
+ } );
+
+ it( "should use the value of the `placeholder` prop as the input's placeholder only when there are no tokens", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ expect(
+ screen.getByPlaceholderText( 'Test placeholder' )
+ ).toBeVisible();
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'blueberry' token. The placeholder text should not be shown anymore
+ await user.type( input, 'blueberry[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'blueberry' ] );
+ expectTokensToBeInTheDocument( [ 'blueberry' ] );
+
+ expect(
+ screen.queryByPlaceholderText( 'Test placeholder' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'should handle accents and special characters in tokens and input value', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'عربى' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'عربى[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'français',
+ 'español',
+ '日本',
+ 'עברית',
+ 'عربى',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'français',
+ 'español',
+ '日本',
+ 'עברית',
+ 'عربى',
+ ] );
+ } );
+ } );
+
+ describe( 'suggestions', () => {
+ it( 'should not render suggestions in its default state', () => {
+ render(
+
+ );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'should render suggestions when receiving focus if the `__experimentalExpandOnFocus` prop is set to `true`', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onFocusSpy = jest.fn();
+
+ const suggestions = [ 'Cobalt', 'Blue', 'Octane' ];
+
+ render(
+ <>
+
+ >
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+
+ // Click the input, focusing it.
+ await user.click( input );
+
+ const suggestionList = screen.getByRole( 'listbox' );
+
+ expect( onFocusSpy ).toHaveBeenCalledTimes( 1 );
+ expect( suggestionList ).toBeVisible();
+
+ expectVisibleSuggestionsToBe(
+ screen.getByRole( 'listbox' ),
+ suggestions
+ );
+
+ // Minimum length limitations don't affect the search text when the
+ // `__experimentalExpandOnFocus` is `true`
+ await user.keyboard( 'c' );
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Cobalt',
+ 'Octane',
+ ] );
+ } );
+
+ it( 'should not render suggestions if the text input is not matching any of the suggestions', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'White', 'Pearl', 'Alabaster' ];
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Type 'Snow' which doesn't match any of the suggestions
+ await user.type( input, 'Snow' );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'should render the matching suggestions only if the text input has the minimum length', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'Yellow', 'Canary', 'Gold', 'Blonde' ];
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Despite 'l' matches some suggestions, the search text needs to be
+ // at least 2 characters
+ await user.type( input, ' l ' );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+
+ // The trimmed search text is now 2 characters long (`lo`), which is
+ // enough to show matching suggestions ('Yellow' and 'Blonde')
+ await user.type( input, '[ArrowLeft][ArrowLeft][ArrowLeft]o' );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Yellow',
+ 'Blonde',
+ ] );
+ } );
+
+ it( 'should not render a matching suggestion if a token with the same value has already been added', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'Green', 'Emerald', 'Seaweed' ];
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Despite 'ee' matches both the "Green" and "Seaweed", "Green" won't be
+ // displayed because there's already a token with the same value
+ await user.type( input, 'ee' );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Seaweed',
+ ] );
+ } );
+
+ it( 'should allow the user to use the keyboard to navigate and select suggestions (which are marked with the `aria-selected` attribute)', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const suggestions = [
+ 'Pink',
+ 'Salmon',
+ 'Flamingo',
+ 'Carnation',
+ 'Neon',
+ ];
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Typing "on" will show the "Salmon", "Carnation" and "Neon" suggestions
+ await user.type( input, 'on' );
+
+ const suggestionList = screen.getByRole( 'listbox' );
+
+ expectVisibleSuggestionsToBe( suggestionList, [
+ 'Salmon',
+ 'Carnation',
+ 'Neon',
+ ] );
+
+ // Currently, none of the suggestions are selected
+ expect(
+ within( suggestionList ).queryAllByRole( 'option', {
+ selected: true,
+ } )
+ ).toHaveLength( 0 );
+
+ // Pressing the down arrow will select "Salmon"
+ await user.keyboard( '[ArrowDown]' );
+
+ expect(
+ within( suggestionList ).getByRole( 'option', {
+ selected: true,
+ } )
+ ).toHaveAccessibleName( 'Salmon' );
+
+ // Pressing the up arrow will select "Neon" (the selection wraps around
+ // the list)
+ await user.keyboard( '[ArrowUp]' );
+
+ expect(
+ within( suggestionList ).getByRole( 'option', {
+ selected: true,
+ } )
+ ).toHaveAccessibleName( 'Neon' );
+
+ // Pressing the down arrow twice will select "Carnation" (the selection
+ // wraps around the list)
+ await user.keyboard( '[ArrowDown][ArrowDown]' );
+
+ expect(
+ within( suggestionList ).getByRole( 'option', {
+ selected: true,
+ } )
+ ).toHaveAccessibleName( 'Carnation' );
+
+ // Pressing enter will add "Carnation" as a token and close the suggestion list
+ await user.keyboard( '[Enter]' );
+
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'Carnation' ] );
+ expectTokensToBeInTheDocument( [ 'Carnation' ] );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'should allow the user to use the mouse to navigate and select suggestions (which are marked with the `aria-selected` attribute)', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const suggestions = [ 'Tiger', 'Tangerine', 'Orange' ];
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Typing "er" will show the "Tiger" and "Tangerine" suggestions
+ await user.type( input, 'er' );
+
+ const suggestionList = screen.getByRole( 'listbox' );
+ expectVisibleSuggestionsToBe( suggestionList, [
+ 'Tiger',
+ 'Tangerine',
+ ] );
+
+ // Currently, none of the suggestions are selected
+ expect(
+ within( suggestionList ).queryAllByRole( 'option', {
+ selected: true,
+ } )
+ ).toHaveLength( 0 );
+
+ const tigerOption = within( suggestionList ).getByRole( 'option', {
+ name: 'Tiger',
+ } );
+ const tangerineOption = within( suggestionList ).getByRole(
+ 'option',
+ {
+ name: 'Tangerine',
+ }
+ );
+
+ // Hovering over each option will mark it as selected (via the
+ // `aria-selected` attribute)
+ await user.hover( tigerOption );
+
+ expect( tigerOption ).toHaveAttribute( 'aria-selected', 'true' );
+ expect( tangerineOption ).toHaveAttribute(
+ 'aria-selected',
+ 'false'
+ );
+
+ await user.hover( tangerineOption );
+
+ expect( tigerOption ).toHaveAttribute( 'aria-selected', 'false' );
+ expect( tangerineOption ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+
+ // Clicking an option will add it as a token and close the list
+ await user.click( tangerineOption );
+
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'Tangerine' ] );
+ expectTokensToBeInTheDocument( [ 'Tangerine' ] );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'should hide the suggestion list when the Escape key is pressed', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const suggestions = [ 'Black', 'Ash', 'Onyx', 'Ebony' ];
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Typing "ony" will show the "Onyx" and "Ebony" suggestions
+ await user.type( input, 'ony' );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Onyx',
+ 'Ebony',
+ ] );
+
+ expect( screen.getByRole( 'listbox' ) ).toBeVisible();
+
+ // Pressing the ESC key will close the suggestion list
+ await user.keyboard( '[Escape]' );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+ } );
+
+ it( 'matches the search text with the suggestions in a case-insensitive way', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'Cinnamon', 'Tawny', 'Mocha' ];
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Because text-matching is case-insensitive, "mo" matches both
+ // "Mocha" and "Cinnamon"
+ await user.type( input, 'mo' );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Mocha',
+ 'Cinnamon',
+ ] );
+ } );
+
+ it( 'should show, at most, a number of suggestions equals to the value of the `maxSuggestions` prop', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [
+ 'Ablaze',
+ 'Ability',
+ 'Abandon',
+ 'Abdomen',
+ 'Abdicate',
+ 'Abortive',
+ 'Abundance',
+ 'Abashedly',
+ 'Abominable',
+ 'Absolutely',
+ 'Absorption',
+ 'Abnormality',
+ ];
+
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Because text-matching is case-insensitive, "Ab" matches all suggestions
+ await user.type( input, 'Ab' );
+
+ // By default, `maxSuggestions` has a value of 100, which means that
+ // all matching suggestions will be shown.
+ expectVisibleSuggestionsToBe(
+ screen.getByRole( 'listbox' ),
+ suggestions
+ );
+
+ rerender(
+
+ );
+
+ // Only the first 3 suggestions are shown, as per the `maxSuggestions` prop
+ expectVisibleSuggestionsToBe(
+ screen.getByRole( 'listbox' ),
+ suggestions.slice( 0, 3 )
+ );
+ } );
+
+ it( 'should match the search text against the unescaped values of suggestions with special characters (including spaces)', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Should match against the escaped value
+ await user.type( input, '& S' );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Tags & Stuff',
+ 'Tags & Stuff 2',
+ ] );
+
+ // Should match against the escaped value
+ await user.clear( input );
+ await user.type( input, 's &' );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Tags & Stuff',
+ 'Tags & Stuff 2',
+ ] );
+
+ // Should not match against the escaped value
+ await user.clear( input );
+ await user.type( input, 'amp' );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'should re-render if suggestions change', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'Aluminum', 'Silver', 'Bronze' ];
+
+ const { rerender } = render( );
+
+ // Type "sil", but there are no suggestions.
+ await user.type( screen.getByRole( 'combobox' ), 'sil' );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+
+ // When suggestions change, the "sil" text is matched against the new
+ // suggestions, which results in the "Silver" suggestion being shown.
+ rerender( );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Silver',
+ ] );
+ } );
+
+ it( 'should automatically select the first matching suggestions when the `__experimentalAutoSelectFirstMatch` prop is set to `true`', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'Walnut', 'Hazelnut', 'Pecan' ];
+
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Type "nut", which will match "Walnut" and "Hazelnut".
+ await user.type( input, 'nut' );
+
+ const suggestionList = screen.getByRole( 'listbox' );
+
+ expectVisibleSuggestionsToBe( suggestionList, [
+ 'Walnut',
+ 'Hazelnut',
+ ] );
+
+ expect(
+ within( suggestionList ).queryByRole( 'option', {
+ selected: true,
+ } )
+ ).not.toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect(
+ within( suggestionList ).getByRole( 'option', {
+ selected: true,
+ } )
+ ).toHaveAccessibleName( 'Walnut' );
+ } );
+
+ it( 'should allow to render custom suggestion items via the `__experimentalRenderItem` prop', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'Wood', 'Stone', 'Metal' ];
+
+ render(
+ (
+ <>Suggestion: { item }>
+ ) }
+ />
+ );
+
+ // Type "woo". Matching suggestion will be "Wood"
+ await user.type( screen.getByRole( 'combobox' ), 'woo' );
+
+ // The `__experimentalRenderItem` only affects the rendered suggestion,
+ // but doesn't change the underlying data `value`, nor the value
+ // displayed in the added token.
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Suggestion: Wood',
+ ] );
+
+ await user.keyboard( '[ArrowDown][Enter]' );
+
+ expectTokensToBeInTheDocument( [ 'Wood' ] );
+ } );
+ } );
+
+ describe( 'tokens as objects', () => {
+ it( 'should accept tokens in their object format', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ expectTokensToBeInTheDocument( [ 'Italy', 'Switzerland' ] );
+
+ const input = screen.getByRole( 'combobox' );
+
+ await user.type( input, 'Italy[Enter]' );
+
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+
+ rerender(
+
+ );
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Sweden',
+ ] );
+ } );
+
+ it( 'should trigger mouse callbacks if the `onMouseEnter` and/or the `onMouseLeave` properties are set on a token data object', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onMouseEnterSpy = jest.fn();
+ const onMouseLeaveSpy = jest.fn();
+
+ render(
+
+ );
+
+ // Move mouse over the 'Germany' token, then over 'Austria', then over
+ // 'Liechtenstein'. The mouse-related callbacks should fire only for
+ // the 'Germany' token, since they are not defined for other tokens.
+ await user.hover( screen.getByText( 'Germany', { exact: true } ) );
+
+ expect( onMouseEnterSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onMouseLeaveSpy ).not.toHaveBeenCalled();
+
+ await user.hover( screen.getByText( 'Austria', { exact: true } ) );
+
+ expect( onMouseEnterSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onMouseLeaveSpy ).toHaveBeenCalledTimes( 1 );
+
+ await user.hover(
+ screen.getByText( 'Liechtenstein', { exact: true } )
+ );
+
+ expect( onMouseEnterSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onMouseLeaveSpy ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'should add an accessible `title` to a token when specified', () => {
+ render(
+
+ );
+
+ expect( screen.queryByTitle( 'France' ) ).not.toBeInTheDocument();
+ expect( screen.getByTitle( 'España' ) ).toBeVisible();
+ } );
+
+ it( 'should be still used to filter out duplicate suggestions', () => {
+ render(
+
+ );
+ } );
+ } );
+
+ describe( 'saveTransform', () => {
+ it( "by default, it should trim the input's value from extra white spaces before attempting to add it as a token", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Press enter on an empty input, no token gets added
+ await user.type( input, '[Enter]' );
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+ expectTokensToBeInTheDocument( [ 'potato' ] );
+
+ // Add the "carrot" token - white space gets trimmed
+ await user.type( input, ' carrot [Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'potato',
+ 'carrot',
+ ] );
+ expectTokensToBeInTheDocument( [ 'potato', 'carrot' ] );
+
+ // Press enter on an input containing a duplicate token but surrounded by
+ // white space, no token gets added
+ await user.type( input, ' potato [Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expectTokensToBeInTheDocument( [ 'potato', 'carrot' ] );
+
+ // Press enter on an input containing only spaces, no token gets added
+ await user.type( input, ' [Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expectTokensToBeInTheDocument( [ 'potato', 'carrot' ] );
+
+ rerender(
+ text }
+ />
+ );
+
+ // If a custom `saveTransform` function is passed, it will be the new
+ // function's duty to trim the whitespace if necessary.
+ await user.clear( input );
+ await user.type( input, ' parnsnip [Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'potato',
+ 'carrot',
+ ' parnsnip ',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'potato',
+ 'carrot',
+ ' parnsnip ',
+ ] );
+ } );
+
+ it( "should allow to modify the input's value when saving it as a token", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ expectTokensToBeInTheDocument( [
+ 'small trousers',
+ 'small shirt',
+ ] );
+
+ rerender(
+
+ tokenText.replace( /small/g, 'medium' )
+ }
+ />
+ );
+
+ // The `saveTransform` prop doesn't apply to existing tokens.
+ expectTokensToBeInTheDocument( [
+ 'small trousers',
+ 'small shirt',
+ ] );
+ expectTokensNotToBeInTheDocument( [
+ 'medium trousers',
+ 'medium shirt',
+ ] );
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'small jacket' token by typing it and pressing enter to tokenize it.
+ // The saveTransform function will change its value to "medium jacket"
+ // when tokenizing it, thus affecting both the onChange callback and
+ // the text rendered in the document.
+ await user.type( input, 'small jacket[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'small trousers',
+ 'small shirt',
+ 'medium jacket',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'small trousers',
+ 'small shirt',
+ 'medium jacket',
+ ] );
+ expectTokensNotToBeInTheDocument( [
+ 'medium trousers',
+ 'medium shirt',
+ 'small jacket',
+ ] );
+ } );
+
+ it( 'is applied to the search value when matching it against the list of suggestions', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const suggestions = [ 'Expensive food', 'Free food' ];
+
+ render(
+
+ text.replace( /cheap/gi, 'free' )
+ }
+ />
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // "cheap" matches the "Free food" option, since the `saveTransform`
+ // function transform "cheap" to "free"
+ await user.type( input, 'cheap' );
+
+ // But the value shown in the suggestion is still "Cheap food"
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'Free food',
+ ] );
+
+ // Selecting the suggestion will add the transformed value as a token,
+ // since the `saveTransform` function will be applied before tokenizing.
+ await user.keyboard( '[ArrowDown][Enter]' );
+
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [ 'Free food' ] );
+ } );
+ } );
+
+ describe( 'displayTransform', () => {
+ it( 'should allow to modify the text rendered in the browser for each token', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ expectTokensToBeInTheDocument( [ 'dark blue', 'dark green' ] );
+
+ rerender(
+
+ tokenText.replace( /dark/g, 'light' )
+ }
+ />
+ );
+
+ // The `displayTransform` prop applies also to the displayed text
+ // of existing tokens
+ expectTokensToBeInTheDocument( [ 'light blue', 'light green' ] );
+ expectTokensNotToBeInTheDocument( [ 'dark blue', 'dark green' ] );
+
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'dark red' token by typing it and pressing enter to tokenize it.
+ // The displayTransform function will change its displayed value to
+ // "light red", but the onChange callback will still receive "dark red" as
+ // part of the component's new value.
+ await user.type( input, 'dark red[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [
+ 'dark blue',
+ 'dark green',
+ 'dark red',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'light blue',
+ 'light green',
+ 'light red',
+ ] );
+ expectTokensNotToBeInTheDocument( [
+ 'dark blue',
+ 'dark green',
+ 'dark red',
+ ] );
+ } );
+
+ it( "is applied to each suggestions, but doesn't influence the matching against the search value", async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const suggestions = [ 'Hot coffee', 'Hot tea' ];
+
+ render(
+
+ text.replace( /hot/gi, 'cold' )
+ }
+ />
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // The `displayTransform` function is only applied to the value
+ // rendered in the DOM, while the data behind is not modified.
+ await user.type( input, 'hot' );
+
+ expectVisibleSuggestionsToBe( screen.getByRole( 'listbox' ), [
+ 'cold coffee',
+ 'cold tea',
+ ] );
+
+ await user.keyboard( '[ArrowDown][Enter]' );
+
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [ 'Hot coffee' ] );
+ } );
+
+ it( 'should allow to pass a function that renders tokens with escaped special characters correctly', async () => {
+ render(
+
+ );
+
+ // This is hacky, but it's a way we can check exactly the output HTML
+ [
+ 'a b',
+ 'i <3 tags',
+ '1&2&3&4',
+ ].forEach( ( tokenHtml ) => {
+ screen.getByText( ( _, node: Element | null ) => {
+ if ( node === null ) {
+ return false;
+ }
+
+ return node.innerHTML === tokenHtml;
+ } );
+ } );
+ } );
+
+ it( 'should allow to pass a function that renders tokens with special characters correctly', async () => {
+ // This test is not as realistic as the previous one: if a WP site
+ // contains tag names with special characters, the API will always
+ // return the tag names already escaped. However, this is still
+ // worth testing, so we can be sure that token values with
+ // dangerous characters in them don't have these characters carried
+ // through unescaped to the HTML.
+ render(
+
+ );
+
+ // This is hacky, but it's a way we can check exactly the output HTML
+ [
+ 'a b',
+ 'i <3 tags',
+ '1&2&3&4',
+ ].forEach( ( tokenHtml ) => {
+ screen.getByText( ( _, node: Element | null ) => {
+ if ( node === null ) {
+ return false;
+ }
+
+ return node.innerHTML === tokenHtml;
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'validation', () => {
+ it( 'should add a token only if it passes the validation set via `__experimentalValidateInput`', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+ const startsWithCapitalLetter = ( tokenText: string ) =>
+ /^[A-Z]/.test( tokenText );
+
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'cherry' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'cherry[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'cherry' ] );
+ expectTokensToBeInTheDocument( [ 'cherry' ] );
+
+ rerender(
+
+ );
+
+ // Add 'cranberry' token by typing it and pressing enter to tokenize it.
+ // The validation function won't allow the value from being tokenized.
+ // Note that the any token added before is still around, even if it
+ // wouldn't pass the newly added validation — this is because the
+ // validation happens when the input\'s value gets tokenized.
+ await user.type( input, 'cranberry[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expectTokensToBeInTheDocument( [ 'cherry' ] );
+ expectTokensNotToBeInTheDocument( [ 'cranberry' ] );
+
+ // Retry, this time with capital letter. The value should be added.
+ await user.clear( input );
+ await user.type( input, 'Cranberry[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
+ expectTokensToBeInTheDocument( [ 'cherry', 'Cranberry' ] );
+ } );
+ } );
+
+ describe( 'maxLength', () => {
+ it( 'should not allow adding new tokens beyond the value defined by the `maxLength` prop', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ expectTokensToBeInTheDocument( [ 'square', 'triangle', 'circle' ] );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Try to add the 'hexagon' token, but because the number of tokens already
+ // matches `maxLength`, the token won't be added.
+ await user.type( input, 'hexagon[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 0 );
+ expectTokensToBeInTheDocument( [ 'square', 'triangle', 'circle' ] );
+ expectTokensNotToBeInTheDocument( [ 'hexagon' ] );
+
+ // Delete the last token ("circle"), in order to make space for the
+ // hexagon token
+ await user.clear( input );
+ await user.keyboard( '[Backspace]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [
+ 'square',
+ 'triangle',
+ ] );
+ expectTokensToBeInTheDocument( [ 'square', 'triangle' ] );
+ expectTokensNotToBeInTheDocument( [ 'circle' ] );
+
+ // Try to add the 'hexagon' token again. This time, the token will be
+ // added because the current number of tokens is below the `maxLength`
+ // threshold.
+ await user.type( input, 'hexagon[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 2 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [
+ 'square',
+ 'triangle',
+ 'hexagon',
+ ] );
+ expectTokensToBeInTheDocument( [
+ 'square',
+ 'triangle',
+ 'hexagon',
+ ] );
+ } );
+
+ it( "should not affect the number of tokens set via the `value` prop (ie. not caused by tokenizing the user's input)", () => {
+ render(
+
+ );
+
+ expectTokensToBeInTheDocument( [
+ 'rectangle',
+ 'ellipse',
+ 'pentagon',
+ ] );
+ } );
+
+ it( 'should not affect tokens that were added before the limit was imposed', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ await user.type( input, 'cube[Enter]sphere[Enter]cylinder[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
+ expect( onChangeSpy ).toHaveBeenLastCalledWith( [
+ 'cube',
+ 'sphere',
+ 'cylinder',
+ ] );
+ expectTokensToBeInTheDocument( [ 'cube', 'sphere', 'cylinder' ] );
+
+ // Add a `maxLength` after some tokens have already been added.
+ rerender(
+
+ );
+
+ // Changing `maxLength` doesn't affect existing tokens, even if their
+ // number exceeds the new limit.
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
+ expectTokensToBeInTheDocument( [ 'cube', 'sphere', 'cylinder' ] );
+
+ // Try to add the 'pyramid' token, but because the number of tokens already
+ // exceeds `maxLength`, the token won't be added.
+ await user.type( input, 'pyramid[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 3 );
+ expectTokensToBeInTheDocument( [ 'cube', 'sphere', 'cylinder' ] );
+ expectTokensNotToBeInTheDocument( [ 'pyramid' ] );
+ } );
+ } );
+
+ describe( 'disabled', () => {
+ it( 'should not allow adding tokens when the `disabled` prop is `true`', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'sun' token by typing it and pressing enter to tokenize it.
+ await user.type( input, 'sun[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expect( onChangeSpy ).toHaveBeenCalledWith( [ 'sun' ] );
+ expectTokensToBeInTheDocument( [ 'sun' ] );
+
+ rerender(
+
+ );
+
+ // Try to add 'moon' token. The token is not added because of the `disabled`
+ // prop.
+ await user.type( input, 'moon[Enter]' );
+ expect( onChangeSpy ).toHaveBeenCalledTimes( 1 );
+ expectTokensNotToBeInTheDocument( [ 'moon' ] );
+ } );
+
+ it( 'should not allow removing tokens when the `disable` prop is `true`', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const onChangeSpy = jest.fn();
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Try to delete the last token with the keyboard. The token won't be
+ // deleted, because of the `disabled` prop.
+ await user.type( input, '[Backspace]' );
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+ expectTokensToBeInTheDocument( [ 'sea', 'ocean' ] );
+
+ // Try to delete the last token with the mouse. The token won't be
+ // deleted, because of the `disabled` prop.
+ await user.click(
+ screen.getAllByRole( 'button', { name: 'Remove item' } )[ 0 ]
+ );
+ expect( onChangeSpy ).not.toHaveBeenCalled();
+ expectTokensToBeInTheDocument( [ 'sea', 'ocean' ] );
+ } );
+ } );
+
+ describe( 'messages', () => {
+ const defaultMessages = {
+ added: 'Item added.',
+ removed: 'Item removed.',
+ remove: 'Remove item',
+ __experimentalInvalid: 'Invalid item',
+ };
+ const customMessages = {
+ added: 'Test message for new item.',
+ removed: 'Test message for item delete.',
+ remove: 'Test label for item delete button.',
+ __experimentalInvalid:
+ 'Test message for when an item fails validation.',
+ };
+
+ it( 'should announce to assistive technology the addition of a new token', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'cat' token, check that the aria-live region has been updated.
+ await user.type( input, 'cat[Enter]' );
+
+ expect( screen.getByText( defaultMessages.added ) ).toHaveAttribute(
+ 'aria-live',
+ 'assertive'
+ );
+ } );
+
+ it( 'should announce to assistive technology the addition of a new token with a custom message', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Add 'dog' token, check that the aria-live region has been updated.
+ await user.type( input, 'dog[Enter]' );
+
+ expect( screen.getByText( customMessages.added ) ).toHaveAttribute(
+ 'aria-live',
+ 'assertive'
+ );
+ } );
+
+ it( 'should announce to assistive technology the removal of a token', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render( );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Delete "horse" token
+ await user.type( input, '[Backspace]' );
+
+ expect(
+ screen.getByText( defaultMessages.removed )
+ ).toHaveAttribute( 'aria-live', 'assertive' );
+ } );
+
+ it( 'should announce to assistive technology the removal of a token with a custom message', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Delete "donkey" token
+ await user.type( input, '[Backspace]' );
+
+ expect(
+ screen.getByText( customMessages.removed )
+ ).toHaveAttribute( 'aria-live', 'assertive' );
+ } );
+
+ it( 'should announce to assistive technology the failure of a potential token to pass validation', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render(
+ false }
+ />
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Try to add "eagle" token, which won't be added because of the
+ // __experimentalValidateInput prop.
+ await user.type( input, 'eagle[Enter]' );
+
+ expect(
+ screen.getByText( defaultMessages.__experimentalInvalid )
+ ).toHaveAttribute( 'aria-live', 'assertive' );
+ } );
+
+ it( 'should announce to assistive technology the failure of a potential token to pass validation with a custom message', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render(
+ false }
+ messages={ customMessages }
+ />
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // Try to add "crocodile" token, which won't be added because of the
+ // __experimentalValidateInput prop.
+ await user.type( input, 'crocodile[Enter]' );
+
+ expect(
+ screen.getByText( customMessages.__experimentalInvalid )
+ ).toHaveAttribute( 'aria-live', 'assertive' );
+ } );
+
+ it( 'should announce to assistive technology the result of the matching of the search text against the list of suggestions', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ render(
+
+ );
+
+ const input = screen.getByRole( 'combobox' );
+
+ // No matching suggestions.
+ await user.type( input, 'cat' );
+
+ await waitFor( () =>
+ expect( screen.getByText( 'No results.' ) ).toHaveAttribute(
+ 'aria-live',
+ 'assertive'
+ )
+ );
+
+ // "Donkey" and "Dog" matching
+ await user.clear( input );
+ await user.type( input, 'do' );
+
+ await waitFor( () =>
+ expect(
+ screen.getByText(
+ '2 results found, use up and down arrow keys to navigate.'
+ )
+ ).toHaveAttribute( 'aria-live', 'assertive' )
+ );
+
+ // Only "Donkey" matches
+ await user.type( input, 'nk' );
+
+ await waitFor( () =>
+ expect(
+ screen.getByText(
+ '1 result found, use up and down arrow keys to navigate.'
+ )
+ ).toHaveAttribute( 'aria-live', 'assertive' )
+ );
+ } );
+
+ it( 'should update the label for the "delete" button of a token', async () => {
+ render(
+
+ );
+
+ expect(
+ screen.getAllByRole( 'button', { name: customMessages.remove } )
+ ).toHaveLength( 2 );
+ } );
+ } );
+
+ // This section is definitely testing things in a non-user centric way,
+ // but I wasn't sure if there was a better way.
+ describe( 'aria attributes', () => {
+ it( 'should add the correct aria attributes to the input as the user interacts with it', async () => {
+ const user = userEvent.setup( {
+ advanceTimers: jest.advanceTimersByTime,
+ } );
+
+ const suggestions = [ 'Pine', 'Pistachio', 'Sage' ];
+
+ render( );
+
+ // No suggestions visible
+ const input = screen.getByRole( 'combobox' );
+
+ expect( input ).toHaveAttribute( 'autoComplete', 'off' );
+ expect( input ).toHaveAttribute( 'aria-autocomplete', 'list' );
+ expect( input ).toHaveAttribute( 'aria-expanded', 'false' );
+ expect( input ).not.toHaveAttribute( 'aria-owns' );
+ expect( input ).not.toHaveAttribute( 'aria-activedescendant' );
+
+ // Typing "Pi" will show the "Pistachio" and "Pine" suggestions.
+ await user.type( input, 'Pi' );
+
+ const suggestionList = screen.getByRole( 'listbox' );
+ expect( suggestionList ).toBeVisible();
+
+ expect( input ).toHaveAttribute( 'aria-expanded', 'true' );
+ expect( input ).toHaveAttribute( 'aria-owns', suggestionList.id );
+ expect( input ).not.toHaveAttribute( 'aria-activedescendant' );
+
+ // Select the "Pine" suggestion
+ await user.click( input );
+ await user.keyboard( '[ArrowDown]' );
+
+ const pineSuggestion = within( suggestionList ).getByRole(
+ 'option',
+ { name: 'Pine', selected: true }
+ );
+ expect( input ).toHaveAttribute( 'aria-expanded', 'true' );
+ expect( input ).toHaveAttribute( 'aria-owns', suggestionList.id );
+ expect( input ).toHaveAttribute(
+ 'aria-activedescendant',
+ pineSuggestion.id
+ );
+
+ // Add the suggestion, which hides the list
+ await user.keyboard( '[Enter]' );
+
+ expect( screen.queryByRole( 'listbox' ) ).not.toBeInTheDocument();
+
+ expect( input ).toHaveAttribute( 'aria-expanded', 'false' );
+ expect( input ).not.toHaveAttribute( 'aria-owns' );
+ expect( input ).not.toHaveAttribute( 'aria-activedescendant' );
+ } );
+ } );
+} );
diff --git a/packages/components/src/form-token-field/test/lib/fixtures.js b/packages/components/src/form-token-field/test/lib/fixtures.js
deleted file mode 100644
index 12a61bfd62aed2..00000000000000
--- a/packages/components/src/form-token-field/test/lib/fixtures.js
+++ /dev/null
@@ -1,89 +0,0 @@
-export default {
- specialTokens: {
- textEscaped: [ 'a b', 'i <3 tags', '1&2&3&4' ],
- htmlEscaped: [
- 'a b',
- 'i <3 tags',
- '1&2&3&4',
- ],
- textUnescaped: [ 'a b', 'i <3 tags', '1&2&3&4' ],
- htmlUnescaped: [
- 'a b',
- 'i <3 tags',
- '1&2&3&4',
- ],
- },
- specialSuggestions: {
- default: [
- 'the',
- 'of',
- 'and',
- 'to',
- 'a',
- 'in',
- 'for',
- 'is',
- 'on',
- 'that',
- 'by',
- 'this',
- 'with',
- 'i',
- 'you',
- 'it',
- 'not',
- 'or',
- 'be',
- 'are',
- 'from',
- 'at',
- 'as',
- 'your',
- 'all',
- 'have',
- 'new',
- 'more',
- 'an',
- 'was',
- 'we',
- 'associate',
- 'snake',
- 'pipes',
- 'sound',
- ],
- textEscaped: [
- '<3',
- 'Stuff & Things',
- 'Tags & Stuff',
- 'Tags & Stuff 2',
- ],
- textUnescaped: [
- '<3',
- 'Stuff & Things',
- 'Tags & Stuff',
- 'Tags & Stuff 2',
- ],
- matchAmpersandUnescaped: [
- [ 'Tags ', '& S', 'tuff' ],
- [ 'Tags ', '& S', 'tuff 2' ],
- ],
- matchAmpersandSequence: [
- [ 'Tag', 's &', ' Stuff' ],
- [ 'Tag', 's &', ' Stuff 2' ],
- ],
- matchAmpersandEscaped: [],
- },
- matchingSuggestions: {
- th: [
- [ 'th', 'e' ],
- [ 'th', 'at' ],
- [ 'th', 'is' ],
- [ 'wi', 'th' ],
- ],
- so: [
- [ 'so', 'und' ],
- [ 'as', 'so', 'ciate' ],
- ],
- at: [ [ 'at' ], [ 'th', 'at' ], [ 'associ', 'at', 'e' ] ],
- },
-};
diff --git a/packages/components/src/form-token-field/test/lib/token-field-wrapper.tsx b/packages/components/src/form-token-field/test/lib/token-field-wrapper.tsx
deleted file mode 100644
index 6195c7fa70c78b..00000000000000
--- a/packages/components/src/form-token-field/test/lib/token-field-wrapper.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * External dependencies
- */
-import type { ComponentProps } from 'react';
-
-/**
- * WordPress dependencies
- */
-import { Component } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import fixtures from './fixtures';
-import type { FormTokenFieldProps } from '../../types';
-import FormTokenField from '../../';
-
-const {
- specialSuggestions: { default: suggestions },
-} = fixtures;
-
-function unescapeAndFormatSpaces( str: string ) {
- const nbsp = String.fromCharCode( 160 );
- const escaped = new DOMParser().parseFromString( str, 'text/html' );
- return escaped.documentElement.textContent?.replace( / /g, nbsp ) ?? '';
-}
-
-class TokenFieldWrapper extends Component<
- FormTokenFieldProps,
- {
- tokenSuggestions: ComponentProps<
- typeof FormTokenField
- >[ 'suggestions' ];
- tokens: ComponentProps< typeof FormTokenField >[ 'value' ];
- isExpanded: boolean;
- }
-> {
- constructor( props: FormTokenFieldProps ) {
- super( props );
- this.state = {
- tokenSuggestions: suggestions,
- tokens: Object.freeze( [ 'foo', 'bar' ] ) as string[],
- isExpanded: false,
- };
- this.onTokensChange = this.onTokensChange.bind( this );
- }
-
- render() {
- return (
-
- );
- }
-
- onTokensChange(
- value: ComponentProps< typeof FormTokenField >[ 'value' ]
- ) {
- this.setState( { tokens: value } );
- }
-}
-
-export default TokenFieldWrapper;