Autocomplete
Item | Name | Description |
---|---|---|
A | <TextField /> | <AutocompleteField /> uses a <TextField /> under the hood. |
B | Value | The placeholder or typed text string. |
C | <Spinner /> | Rendered when loading is true to give a visual cue that content is loading. |
D | <IconButton /> | The <Close /> icon is used within <IconButton /> to clear the entered or selected text string. |
Item | Name | Description |
---|---|---|
A | Container | The parent component–rendered as a <ul> —that wraps <AutocompleteOption /> children components. |
B | <AutocompleteOption /> | The child component—rendered as an <li> —that contains the main content for a given step. |
C | Group label | A <span> generated by the label prop when grouping options. |
D | <AutocompleteOptionGroup /> | A group of <AutocompleteOptions /> using a required label . |
Item | Name | Description |
---|---|---|
A | <Check /> | The <Check /> icon is used to indicate that the <AutocompleteOption /> is selected. The icon is omitted when using the search behavior. |
B | Option content | Contains any other children passed to the <AutocompleteOption /> such as a text string or image. |
For a smooth selection process from a fixed list, use variant="select"
for filtering. This example shows how filtering helps you quickly find and choose valid options, ensuring you can only select from what is available in the listbox. This behavior is similar to a<select />
field.
import React, { useCallback, useEffect, useRef, useState } from 'react'import { Formik } from 'formik'import * as Yup from 'yup'import {AutocompleteField,AutocompleteListbox,AutocompleteOption,FormLayout,} from '@gusto/workbench-formik'import type { Country, Employee } from './_testData'import { countries, employees } from './_testData'const employeesQueryCache = new Map<string, Employee[]>()const queryEmployees = async (search: string): Promise<Employee[]> => {let output = employeesQueryCache.get(search)if (!output) {const getEmployees = async () =>new Promise<Employee[]>(resolve => {const normalizedSearch = search.toLocaleLowerCase()const artificialDelay = Math.floor(Math.random() * 1000)setTimeout(() => {const data = employees.filter(e => {return (e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||e.Title.toLocaleLowerCase().includes(normalizedSearch))})resolve(data)}, artificialDelay)})output = await getEmployees()employeesQueryCache.set(search, output)}return output}export const Filtering = () => {const getOptions = useCallback((searchValue: string) => {return countries.filter(country => {return country.name.toLocaleLowerCase().startsWith(searchValue.toLocaleLowerCase())}).slice(0, 5)}, [])const [options, setOptions] = useState(() => getOptions(''))const selectedCountry = useRef<Country | null>(null)return (<FormikinitialValues={{country: 'af',}}validationSchema={Yup.object().shape({country: Yup.string().nullable(),})}onSubmit={values => alert(JSON.stringify(values))}><FormLayoutfields={<AutocompleteFieldname="country"label="Country"onDebouncedChange={e => setOptions(getOptions(e.target.value))}variant="select"defaultSelectedOptions={[{label: 'Afghanistan',value: 'af',},]}><AutocompleteListbox>{options.map(opt => (<AutocompleteOptionkey={opt.code}option={{ label: opt.name, value: opt.code }}onClick={() => {selectedCountry.current = opt}}/>))}</AutocompleteListbox></AutocompleteField>}/></Formik>)}
When you need flexibility to enter any valid text, use variant="text"
for searching. This example shows how searching allows you to type and submit any text, providing a versatile input experience beyond predefined options.
import React, { useEffect, useMemo, useState } from 'react'import { Formik } from 'formik'import * as Yup from 'yup'import {Flex,FlexItem,Heading,HeadingSubtext,HeadingText,Img,} from '@gusto/workbench'import {AutocompleteField,AutocompleteListbox,AutocompleteOption,FormLayout,} from '@gusto/workbench-formik'import { Search } from '@gusto/workbench-icons'import type { Employee } from './_testData'import { employees } from './_testData'const employeesQueryCache = new Map<string, Employee[]>()const queryEmployees = async (search: string): Promise<Employee[]> => {let output = employeesQueryCache.get(search)if (!output) {const getEmployees = async () =>new Promise<Employee[]>(resolve => {const normalizedSearch = search.toLocaleLowerCase()const artificialDelay = Math.floor(Math.random() * 1000)setTimeout(() => {const data = employees.filter(e => {return (e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||e.Title.toLocaleLowerCase().includes(normalizedSearch))})resolve(data)}, artificialDelay)})output = await getEmployees()employeesQueryCache.set(search, output)}return output}const useEmployeesQuery = (search: string) => {const [loading, setLoading] = useState(false)const [data, setData] = useState<Employee[] | undefined>()useEffect(() => {const fetchEmployees = async () => {setLoading(true)setData(await queryEmployees(search))setLoading(false)}void fetchEmployees()}, [search])return {data,loading,}}export const SearchSingle = () => {const [searchTerm, setSearchTerm] = useState('')const [queryEnabled, setQueryEnabled] = useState(false)const { data, loading } = useEmployeesQuery(searchTerm)const options = useMemo(() =>data?.map(({ Email: email, 'Name-FL': fullName, ...rest }) => ({email,fullName,label: fullName,value: email,...rest,})) ?? [],[data],)return (<FormikinitialValues={{employee: '',}}validationSchema={Yup.object().shape({employee: Yup.string().nullable().required('Country is required'),})}onSubmit={values => alert(JSON.stringify(values))}><FormLayoutfields={<AutocompleteFieldlabel="Employee search"helperText="Select an employee"name="employeeName"before={<Search />}loading={queryEnabled ? loading : false}placeholder="Search employees..."onDebouncedChange={e => setSearchTerm(e.target.value)}onFocus={() => setQueryEnabled(true)}variant="text"><AutocompleteListbox>{options.map(({ Avatar: avatar, Title: jobTitle, email, fullName }) => (<AutocompleteOptionkey={email}option={{ label: fullName, value: email }}><Flex columnGap={3} alignItems="center"><FlexItemwidth={48}borderRadius={1000}overflow="hidden"><Imgsrc={avatar}alt={`Avatar for ${fullName}`}width={500}height={500}/></FlexItem><FlexItem>{/* Done for styling only— this has no semantic meaning because it is inside of an option */}<Heading role="presentation" level={5} marginBottom={0}><HeadingText>{fullName}</HeadingText><HeadingSubtext>{jobTitle}</HeadingSubtext></Heading></FlexItem></Flex></AutocompleteOption>),)}</AutocompleteListbox></AutocompleteField>}/></Formik>)}
For easier navigation through options, use grouping with the use of <AutocompleteOptionGroup />
and <AutocompleteSeparator />
. This example shows how grouping organizes the listbox options into categories, making it simpler to find and select the right choice.
import React, { useEffect, useMemo, useState } from 'react'import { Formik } from 'formik'import * as Yup from 'yup'import {AutocompleteField,AutocompleteListbox,AutocompleteOption,AutocompleteOptionGroup,AutocompleteSeparator,FormLayout,} from '@gusto/workbench-formik'import type { Employee } from './_testData'import { employees } from './_testData'const employeesQueryCache = new Map<string, Employee[]>()const queryEmployees = async (search: string): Promise<Employee[]> => {let output = employeesQueryCache.get(search)if (!output) {const getEmployees = async () =>new Promise<Employee[]>(resolve => {const normalizedSearch = search.toLocaleLowerCase()const artificialDelay = Math.floor(Math.random() * 1000)setTimeout(() => {const data = employees.filter(e => {return (e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||e.Title.toLocaleLowerCase().includes(normalizedSearch))})resolve(data)}, artificialDelay)})output = await getEmployees()employeesQueryCache.set(search, output)}return output}const useEmployeesQuery = (search: string) => {const [loading, setLoading] = useState(false)const [data, setData] = useState<Employee[] | undefined>()useEffect(() => {const fetchEmployees = async () => {setLoading(true)setData(await queryEmployees(search))setLoading(false)}void fetchEmployees()}, [search])return {data,loading,}}export const Grouped = () => {const [searchTerm, setSearchTerm] = useState('')const { data, loading } = useEmployeesQuery(searchTerm)const options = useMemo(() =>data?.map(({Department: department,Email: email,'Name-FL': fullName,...rest}) => ({department,email,fullName,label: fullName,value: email,...rest,}),) ?? [],[data],)const groupedOptions = useMemo(() => {const sortedOptions = [...options].sort((a, b) => {return a.department.localeCompare(b.department, undefined, {sensitivity: 'base',})})const groups: Record<string, typeof options> = {}sortedOptions.forEach(option => {const groupKey = option.departmentconst groupValue = groups[groupKey] ?? []groupValue.push(option)groups[groupKey] = groupValue})return groups}, [options])return (<FormikinitialValues={{employee: '',}}validationSchema={Yup.object().shape({employee: Yup.string().nullable().required('Country is required'),})}onSubmit={values => alert(JSON.stringify(values))}><FormLayoutonSubmit={e => {const employeeName = new FormData(e.target as HTMLFormElement).get('employeeName',)let formData = 'Empty'if (typeof employeeName === 'string' && employeeName.length > 0) {formData = `Selected: ${employeeName}`}console.log(formData)e.preventDefault()}}fields={<AutocompleteFieldname="employee"label="Employee"onDebouncedChange={e => setSearchTerm(e.target.value)}placeholder="Enter a country name"loading={loading}><AutocompleteListbox>{Object.entries(groupedOptions).map(([groupLabel, groupOptions], index) => {return (<React.Fragment key={groupLabel}>{index > 0 ? <AutocompleteSeparator /> : null}<AutocompleteOptionGroup label={groupLabel}>{groupOptions.map(option => (<AutocompleteOptionkey={option.value}option={{label: option.label,value: option.value,}}>{option.label}</AutocompleteOption>))}</AutocompleteOptionGroup></React.Fragment>)},)}</AutocompleteListbox></AutocompleteField>}/></Formik>)}
When you need to choose more than one option, use multiple selection. This example demonstrates how you can select multiple items from the autocomplete, giving you the flexibility to choose all the options you need.
import React, { useEffect, useRef, useState } from 'react'import { Formik } from 'formik'import * as Yup from 'yup'import { type AutocompleteOptionBase } from '@gusto/workbench'import {AutocompleteField,AutocompleteListbox,AutocompleteOption,FormLayout,} from '@gusto/workbench-formik'import { Search } from '@gusto/workbench-icons'import type { Employee } from './_testData'import { employees } from './_testData'const employeesQueryCache = new Map<string, Employee[]>()const queryEmployees = async (search: string): Promise<Employee[]> => {let output = employeesQueryCache.get(search)if (!output) {const getEmployees = async () =>new Promise<Employee[]>(resolve => {const normalizedSearch = search.toLocaleLowerCase()const artificialDelay = Math.floor(Math.random() * 1000)setTimeout(() => {const data = employees.filter(e => {return (e['Name-FL'].toLocaleLowerCase().includes(normalizedSearch) ||e.Title.toLocaleLowerCase().includes(normalizedSearch))})resolve(data)}, artificialDelay)})output = await getEmployees()employeesQueryCache.set(search, output)}return output}const useEmployeesQuery = (search: string) => {const [loading, setLoading] = useState(false)const [data, setData] = useState<Employee[] | undefined>()useEffect(() => {const fetchEmployees = async () => {setLoading(true)setData(await queryEmployees(search))setLoading(false)}void fetchEmployees()}, [search])return {data,loading,}}export const SearchMultiple = () => {const inputRef = useRef<HTMLInputElement>(null)const [searchTerm, setSearchTerm] = useState('')const [queryEnabled, setQueryEnabled] = useState(false)const { data, loading } = useEmployeesQuery(searchTerm)const [selectedOptions, setSelectedOptions] = useState<AutocompleteOptionBase[]>([{label: 'Emily Lee',value: 'Emily Lee',},])return (<FormikinitialValues={{employee: [],}}validationSchema={Yup.object().shape({employee: Yup.string().nullable().required('Employee is required'),})}onSubmit={values => alert(JSON.stringify(values))}><FormLayoutfields={<AutocompleteFieldref={inputRef}before={<Search />}label="Employee"name="employee"selectedOptions={selectedOptions}onDebouncedChange={e => setSearchTerm(e.target.value)}onSelectedOptionsChange={(options: AutocompleteOptionBase[]) => {setSelectedOptions(options)}}variant="select"onFocus={() => setQueryEnabled(true)}placeholder="Type to filter options..."loading={queryEnabled ? loading : false}helperText="Select any number of employees"multiple><inputtype="hidden"value={selectedOptions.map(v => v.value?.toString() ?? '')}name="employees"/><AutocompleteListbox>{data?.map(({ Digit, 'Name-FL': name }) => (<AutocompleteOptionkey={Digit}option={{ label: name, value: name }}>{name}</AutocompleteOption>))}</AutocompleteListbox></AutocompleteField>}/></Formik>)}
Name | Type | Default | Description |
---|---|---|---|
after | ReactNode | - | Content shown after the input value. By default this content is not interactive and will ignore pointer events. Any interactive elements must be styled with pointer-events: auto; . |
before | ReactNode | - | Content shown before the input value. By default this content is not interactive and will ignore pointer events. Any interactive elements must be styled with pointer-events: auto; . |
children | ReactNode | - | Content of the field; typically an AutocompleteSelect and AutocompleteListbox |
component | ReactNode | - | Component override for the input element |
counter | ReactNode | - | Indicator of a limit placed on a field, e.g. maximum character count or remaining hours; most commonly using CharacterCount |
debounceDelay | number | 200 | In milliseconds, the amount of time between value changes before the onDebouncedChange event is triggered |
defaultSelectedOptions | AutocompleteOptionBase[] | - | The default options selected on initial render. |
preventSelectionOnBlur | boolean | When true , prevents selection of the active option when the input loses focus (on Tab or click away). | |
fit | content container | container | Determines the point of reference for the width of the component. If set to content , the size will be set by the size of the input. If set to container , the size will expand to fit the containing element. |
helperText | ReactNode | - | A brief description or hint for the input ; can be used to link to an external resource. |
invalid | boolean | - | If true , the field will be shown in an invalid state. |
label Required | string | - | Label associated with the input . |
loading | boolean | false | If true , the loading Spinner is displayed. |
onClear | () => void | - | Called when the ESCAPE key is pressed or the clear button is clicked. |
onDebouncedChange | (e: React.ChangeEvent<HTMLInputElement>) => void | - | Called after the debounceDelay has passed since the last input value change. |
onImmediateChange | (e: React.ChangeEvent<HTMLInputElement>) => void | - | Called as soon as the input value changes. |
onSelectedOptionsChange | (options: AutocompleteOptionBase[]) => void | - | Called when the selected options change. |
optional | boolean | - | Used to indicate the field is optional |
validationText | ReactNode | - | Validation message associated with the input . |
variant | select text | text | Determines the behavior of input entered in the field.text behaves like an input text element and allows any input-valued to be used, whether or not it matches an option .select behaves more like a select element and only allows available option elements to be used. |
Name | Type | Default | Description |
---|---|---|---|
children | ReactNode | - | Content of the component, typically a series of AutocompleteOption |
Name | Type | Default | Description |
---|---|---|---|
children | ReactNode | - | Content of the component |
label Deprecated | string | - | Use option instead The text entered into the input element when the option is selected |
value Deprecated | string number | - | Use option instead The value for the option. |
option Required | AutocompleteOptionBase | - | The option configuration object: { label: string, value: string } |
Name | Type | Default | Description |
---|---|---|---|
label Required | string | - | The group label |
- All included icons meet the minimum requirement of 3:1 contrast ratio for non-text content For more information, see footnote 1,For more information, see footnote 2
- The focus indicators meet the minimum requirements for focus appearance and focus visibility For more information, see footnote 3, For more information, see footnote 4
- The touch-target areas for the input and options meet the minimum requirement for target size For more information, see footnote 5
- This component uses visuallyHidden with
role="alert"
to announce to the screen reader how many options are available For more information, see footnote 6,For more information, see footnote 7,For more information, see footnote 8,For more information, see footnote 9 - An accessible loading message (a text alternative to the spinner icon) is set based on the value of the
loading
attribute to indicate loading options - Autocomplete Listbox is portaled and positioned using Popper. This comes with the benefit of flexibility, but is not a standard pattern and requires certain accommodations to communicate to the user that they are in a special navigation mode and provide instructions on how to exit. We follow a pattern similar to the Spectrum Design System in which all content outside the control is hidden using aria-hidden. For more information, see footnote 11
- We have divided escape key presses to allow for both keyboard and screen reader users. The first press closes the Autocomplete Listbox and the second will clear the selected value as well as the value of the
input
. This allows screen readers to close the Autocomplete Listbox and navigate forward. For more information, see footnote 12, For more information, see footnote 13
The following testing snippet(s) offer suggestions for testing the component using React Testing Library with occasional help from Jest.
const countries = [{ code: 'af', name: 'Afghanistan' },// ...continued];// Tested component where state is being managedconst TestedComponent = () => {const [inputValue, setInputValue] = useState('');const [selectedCountry, setSelectedCountry] = useState<Country | null>(null);const options = useMemo(() => {const searchValue = inputValue.toLowerCase();return countries.filter(country => country.name.startsWith('A')).filter(country => country.name.toLowerCase().startsWith(searchValue)).slice(0, 6);}, [inputValue]);return (<AutocompleteFieldlabel="Country"value={inputValue}onChange={e => setInputValue(e.target.value)}><AutocompleteSelectname="country"value={selectedCountry.code}onChange={e => {const newCountry = countries.find(country => country.code === e.target.value) ?? null;setSelectedCountry(newCountry);}}>{selectedCountry ? (<Optionlabel={selectedCountry.name}value={selectedCountry.code} />) : null}</AutocompleteSelect><AutocompleteListbox aria-label="Country options"><AutocompleteOptionGroup label="A">{options.map(option => (<AutocompleteOption key={option.code} option={{ label: name, value: code }>{option.name}</AutocompleteOption>))}</AutocompleteOptionGroup></AutocompleteListbox></AutocompleteField>};render(<TestedComponent />);const combobox = screen.getByRole('combobox', {name: 'Country',});// The listbox, clear button, and the options elements will all be hidden// on first render. To unhide them, search for something:userEvent.type(combobox, 'a');expect(combobox).toHaveDisplayValue('a');expect(listbox).toBeVisible();const clearButton = screen.getByRole('button', {name: 'Clear',});const listbox = screen.getByRole('listbox', {name: 'Country options',});const group = within(listbox).getByRole('group', {name: 'A',});const afghanistanOption = within(group).getByRole('option', {name: 'Afghanistan',});// To select an option:userEvent.click(afghanistanOption);// To clear the selected option:userEvent.click(clearButton);// Note: we recommend that you do not directly assert the selected value.// Instead, assert displayed values and things you can see. If the selected// value changes something else in the UI, assert on that instead