Initial working version of auto complete button component (#31823)

* Initial working version of auto complete button component

* generalising button auto complete for use in bibtex entry form

* Adding optional fuzzy searching

* Restoring delete optional field

* Removing dropdown specific styling

* Updating item/group interface for autocomplete

* Auto complete allowing full keyboard nav functionality

* Custom class for dropdown-upward

* Adding error validation for duplicate name with standard field

* fixing type errors

* Replacing Fuse with MiniSearch

* Adding clear button and frontend tests for ol-autocomplete

* Adding fuzzysearch option to autocomplete story

* removing unused vars and noddy comment

* Fixing lint failure

* Updating fuzzy search threshold

* Using downshift natural highlight

* Required label for aria compliancy

* changing how create item is handled

* addressing review comments

* Using AutoExpandingTextArea for optional fields and hooking in validation

* Formatting

* Requiring items prop for ol-autocomplete

* Fixing type failure in test

GitOrigin-RevId: 9b8f719fbb2bdd75fc1d5a9076908559040a8a78
This commit is contained in:
l-obrien-overleaf
2026-03-05 12:42:00 +00:00
committed by Copybot
parent 9d58797a04
commit 1be454b95c
3 changed files with 1156 additions and 0 deletions
@@ -0,0 +1,319 @@
import { useCombobox } from 'downshift'
import { forwardRef, useState, useMemo, Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import MiniSearch from 'minisearch'
import OLButton from '@/shared/components/ol/ol-button'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import {
DropdownHeader,
DropdownItem,
} from '@/shared/components/dropdown/dropdown-menu'
import MaterialIcon from '@/shared/components/material-icon'
const FUZZY_SEARCH_THRESHOLD = 0.5
export type OLAutocompleteItem = {
value: string
label: string
group?: string
}
export type OLAutocompleteProps = {
items: OLAutocompleteItem[]
onChange: (value: string) => void
placeholder?: string
label: string
showLabel?: boolean
allowCreate?: boolean | ((value: string) => boolean)
disabled?: boolean
createOptionPrefix?: string
useFuzzySearch?: boolean
inputRef?: React.ForwardedRef<HTMLInputElement>
}
type OLAutocompleteDisplayItem =
| {
type: 'item'
value: string
label: string
}
| {
type: 'create'
inputValue: string
}
function OLAutocompleteInternal({
items,
onChange,
placeholder,
label,
showLabel = false,
allowCreate = true,
disabled = false,
createOptionPrefix = '+ Create',
useFuzzySearch = false,
inputRef,
}: OLAutocompleteProps) {
const { t } = useTranslation()
const searchIndex = useMemo(() => {
if (!useFuzzySearch) return null
const searchIndex = new MiniSearch({
fields: ['label'],
storeFields: ['value', 'label', 'group'],
idField: 'value',
})
searchIndex.addAll(items)
return searchIndex
}, [items, useFuzzySearch])
const [internalInputValue, setInternalInputValue] = useState('')
const inputItems = useMemo(() => {
if (!internalInputValue) {
return items
}
if (useFuzzySearch && searchIndex) {
const results = searchIndex.search(internalInputValue, {
fuzzy: FUZZY_SEARCH_THRESHOLD,
prefix: true,
})
return results.map(result => ({
value: result.value,
label: result.label,
group: result.group,
}))
}
return items.filter(item =>
item.label.toLowerCase().includes(internalInputValue.toLowerCase())
)
}, [items, internalInputValue, searchIndex, useFuzzySearch])
const exactMatch = inputItems.some(
item => item.label.toLowerCase() === internalInputValue.toLowerCase()
)
const allowCreateForInput =
typeof allowCreate === 'function'
? allowCreate(internalInputValue)
: allowCreate
const showCreateOption =
allowCreateForInput && internalInputValue && !exactMatch
const displayItems: OLAutocompleteDisplayItem[] = [
...inputItems.map(item => ({
type: 'item' as const,
value: item.value,
label: item.label,
})),
...(showCreateOption
? [
{
type: 'create' as const,
inputValue: internalInputValue,
},
]
: []),
]
const hasGroupedItems = inputItems.some(item => Boolean(item.group))
const {
isOpen,
getLabelProps,
getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
selectItem,
} = useCombobox<OLAutocompleteDisplayItem>({
inputValue: internalInputValue,
items: displayItems,
defaultHighlightedIndex: 0,
itemToString: item => {
if (!item) return ''
return item.type === 'create' ? item.inputValue : item.label
},
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
if (selectedItem.type === 'create') {
onChange(selectedItem.inputValue)
setInternalInputValue(selectedItem.inputValue)
} else {
onChange(selectedItem.value)
setInternalInputValue(selectedItem.label)
}
}
},
onInputValueChange: ({ inputValue = '' }) => {
setInternalInputValue(inputValue)
},
})
const shouldShowDropdown = isOpen && displayItems.length > 0
const handleClear = () => {
selectItem(null)
setInternalInputValue('')
onChange('')
}
return (
<div className={classnames('dropdown', 'd-block')}>
<OLFormLabel
{...getLabelProps()}
className={showLabel ? '' : 'visually-hidden'}
>
{label}
</OLFormLabel>
<div className="position-relative">
<OLFormControl
{...getInputProps({
ref: inputRef,
})}
placeholder={placeholder}
disabled={disabled}
/>
{internalInputValue && !disabled && (
<OLButton
variant="ghost"
size="sm"
className="position-absolute top-50 end-0 translate-middle-y me-1"
onClick={handleClear}
aria-label={t('delete')}
>
<MaterialIcon type="close" />
</OLButton>
)}
</div>
<ul
{...getMenuProps()}
className={classnames('dropdown-menu', 'select-dropdown-menu', {
show: shouldShowDropdown,
})}
>
{hasGroupedItems ? (
<>
{inputItems.map((item, index) => {
const previousItem = inputItems[index - 1]
const hasGroupHeader =
item.group &&
(!previousItem || previousItem.group !== item.group)
return (
<Fragment key={`${item.value}${index}`}>
{hasGroupHeader && index > 0 && (
<li role="separator" className="dropdown-divider" />
)}
{hasGroupHeader && (
<li>
<DropdownHeader as="span">{item.group}</DropdownHeader>
</li>
)}
<li
{...getItemProps({
item: {
type: 'item',
value: item.value,
label: item.label,
},
index,
})}
>
<DropdownItem
as="span"
role={undefined}
className={classnames({
'dropdown-item-highlighted': highlightedIndex === index,
})}
>
{item.label}
</DropdownItem>
</li>
</Fragment>
)
})}
{showCreateOption && (
<>
<li role="separator" className="dropdown-divider" />
<li
{...getItemProps({
item: {
type: 'create',
inputValue: internalInputValue,
},
index: displayItems.length - 1,
})}
>
<DropdownItem
as="span"
role={undefined}
className={classnames({
'dropdown-item-highlighted':
highlightedIndex === displayItems.length - 1,
})}
>
<span className="text-muted">{createOptionPrefix} </span>
<strong>'{internalInputValue}'</strong>
</DropdownItem>
</li>
</>
)}
</>
) : (
displayItems.map((item, index) => {
const isCreateOption = item.type === 'create'
const displayValue = isCreateOption ? item.inputValue : item.label
return (
<li
key={
item.type === 'create'
? `create-${item.inputValue}-${index}`
: `${item.value}${index}`
}
{...getItemProps({
item,
index,
})}
>
<DropdownItem
as="span"
role={undefined}
className={classnames({
'dropdown-item-highlighted': highlightedIndex === index,
})}
>
{isCreateOption ? (
<>
<span className="text-muted">{createOptionPrefix} </span>
<strong>'{displayValue}'</strong>
</>
) : (
displayValue
)}
</DropdownItem>
</li>
)
})
)}
</ul>
</div>
)
}
const OLAutocomplete = forwardRef<HTMLInputElement, OLAutocompleteProps>(
(props, ref) => {
return <OLAutocompleteInternal {...props} inputRef={ref} />
}
)
OLAutocomplete.displayName = 'OLAutocomplete'
export default OLAutocomplete
@@ -0,0 +1,306 @@
import { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import OLAutocomplete from '@/shared/components/ol/ol-autocomplete'
type Args = React.ComponentProps<typeof OLAutocomplete>
const sampleItems = [
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'angular', label: 'Angular' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'ember', label: 'Ember' },
{ value: 'backbone', label: 'Backbone' },
{ value: 'preact', label: 'Preact' },
{ value: 'nextjs', label: 'Next.js' },
{ value: 'nuxtjs', label: 'Nuxt.js' },
{ value: 'gatsby', label: 'Gatsby' },
]
const programmingLanguages = [
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'python', label: 'Python' },
{ value: 'java', label: 'Java' },
{ value: 'cpp', label: 'C++' },
{ value: 'csharp', label: 'C#' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' },
{ value: 'ruby', label: 'Ruby' },
{ value: 'php', label: 'PHP' },
{ value: 'swift', label: 'Swift' },
{ value: 'kotlin', label: 'Kotlin' },
{ value: 'dart', label: 'Dart' },
]
const countries = [
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'ca', label: 'Canada' },
{ value: 'au', label: 'Australia' },
{ value: 'de', label: 'Germany' },
{ value: 'fr', label: 'France' },
{ value: 'es', label: 'Spain' },
{ value: 'it', label: 'Italy' },
{ value: 'nl', label: 'Netherlands' },
{ value: 'be', label: 'Belgium' },
]
const entryTypeItems = [
{ value: 'article', label: 'Article', group: 'Most common' },
{ value: 'review', label: 'Review', group: 'Most common' },
{ value: 'book', label: 'Book', group: 'Most common' },
{ value: 'conference', label: 'Conference paper', group: 'Most common' },
{ value: 'thesis', label: 'Thesis', group: 'Most common' },
{ value: 'mastersthesis', label: "Master's thesis", group: 'Most common' },
{ value: 'phdthesis', label: 'PhD thesis', group: 'Most common' },
{ value: 'bookchapter', label: 'Book chapter', group: 'Book types' },
{ value: 'booksection', label: 'Book section', group: 'Book types' },
{ value: 'bookpart', label: 'Book part', group: 'Book types' },
{ value: 'editedbook', label: 'Edited book', group: 'Book types' },
{ value: 'referencebook', label: 'Reference book', group: 'Book types' },
{ value: 'proceedings', label: 'Conference proceedings', group: 'Other' },
{ value: 'journalarticle', label: 'Journal article', group: 'Other' },
{ value: 'techreport', label: 'Technical report', group: 'Other' },
{ value: 'preprint', label: 'Preprint', group: 'Other' },
{ value: 'patent', label: 'Patent', group: 'Other' },
{ value: 'manuscript', label: 'Manuscript', group: 'Other' },
{ value: 'unpublished', label: 'Unpublished', group: 'Other' },
]
function InteractiveAutocomplete(args: Args) {
const [value, setValue] = useState('')
return (
<div style={{ maxWidth: '400px' }}>
<OLAutocomplete {...args} onChange={setValue} />
<div style={{ marginTop: '1rem' }}>
<strong>Current value:</strong> {value || '(empty)'}
</div>
</div>
)
}
export const Default: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: sampleItems,
label: 'Select a framework',
placeholder: 'Type to search...',
showLabel: true,
allowCreate: true,
},
}
export const WithoutCreateOption: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: programmingLanguages,
label: 'Programming language',
placeholder: 'Choose a language',
showLabel: true,
allowCreate: false,
},
}
export const AllowCreatePredicate: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: programmingLanguages,
label: 'Programming language (custom rule)',
placeholder: 'Try typing java or script',
showLabel: true,
allowCreate: value => {
const normalized = value.trim().toLowerCase()
return (
normalized.length >= 4 &&
!normalized.includes('script') &&
normalized !== 'java'
)
},
},
}
export const HiddenLabel: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: countries,
label: 'Country',
placeholder: 'Select your country',
showLabel: false,
allowCreate: true,
},
}
export const CustomCreatePrefix: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: [
{ value: 'alpha', label: 'Project Alpha' },
{ value: 'beta', label: 'Project Beta' },
{ value: 'gamma', label: 'Project Gamma' },
],
label: 'Project name',
placeholder: 'Select or create a project',
showLabel: true,
allowCreate: true,
createOptionPrefix: ' Create new project:',
},
}
export const Disabled: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: sampleItems,
label: 'Framework (disabled)',
placeholder: 'Cannot interact',
showLabel: true,
disabled: true,
},
}
export const SmallList: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
],
label: 'Choose an option',
placeholder: 'Type or select...',
showLabel: true,
allowCreate: true,
},
}
export const GroupedItems: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: entryTypeItems,
label: 'Entry type',
placeholder: 'Enter entry type',
showLabel: true,
allowCreate: true,
},
}
export const GroupedWithoutCreate: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: [
{ value: 'react', label: 'React', group: 'Frontend' },
{ value: 'vue', label: 'Vue', group: 'Frontend' },
{ value: 'angular', label: 'Angular', group: 'Frontend' },
{ value: 'svelte', label: 'Svelte', group: 'Frontend' },
{ value: 'nodejs', label: 'Node.js', group: 'Backend' },
{ value: 'django', label: 'Django', group: 'Backend' },
{ value: 'rails', label: 'Ruby on Rails', group: 'Backend' },
{ value: 'spring', label: 'Spring Boot', group: 'Backend' },
{ value: 'reactnative', label: 'React Native', group: 'Mobile' },
{ value: 'flutter', label: 'Flutter', group: 'Mobile' },
{ value: 'swift', label: 'Swift', group: 'Mobile' },
{ value: 'kotlin', label: 'Kotlin', group: 'Mobile' },
],
label: 'Technology',
placeholder: 'Search technologies...',
showLabel: true,
allowCreate: false,
},
}
export const FuzzySearch: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: [
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'python', label: 'Python' },
{ value: 'rust', label: 'Rust' },
{ value: 'golang', label: 'Go' },
],
label: 'Language (fuzzy search)',
placeholder: 'Try typo searches, e.g. javasript or pyton',
showLabel: true,
allowCreate: true,
useFuzzySearch: true,
},
}
export const GroupedFuzzySearch: StoryObj<Args> = {
render: args => <InteractiveAutocomplete {...args} />,
args: {
items: entryTypeItems,
label: 'Grouped entry type (fuzzy search)',
placeholder: 'Try typo searches, e.g. confernce or theis',
showLabel: true,
allowCreate: true,
useFuzzySearch: true,
},
}
const meta: Meta<typeof OLAutocomplete> = {
title: 'Shared / Components / Autocomplete',
component: OLAutocomplete,
tags: ['autodocs'],
parameters: {
controls: {
include: [
'items',
'label',
'placeholder',
'showLabel',
'allowCreate',
'disabled',
'createOptionPrefix',
'useFuzzySearch',
],
},
},
argTypes: {
items: {
control: 'object',
description:
'Array of available options; use the optional group property for grouped sections',
},
onChange: {
description: 'Callback when value changes',
},
label: {
control: 'text',
description: 'Label for the input',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
showLabel: {
control: 'boolean',
description: 'Show or hide the label visually',
},
allowCreate: {
control: 'boolean',
description:
'Allow creating custom values, or provide a predicate function `(value) => boolean` to conditionally allow create per input value',
table: {
type: {
summary: 'boolean | ((value: string) => boolean)',
},
},
},
disabled: {
control: 'boolean',
description: 'Disable the input',
},
createOptionPrefix: {
control: 'text',
description: 'Text prefix for the create option',
},
useFuzzySearch: {
control: 'boolean',
description: 'Enable fuzzy search matching for suggestions',
},
},
}
export default meta
@@ -0,0 +1,531 @@
import { FormEvent } from 'react'
import OLButton from '@/shared/components/ol/ol-button'
import OLForm from '@/shared/components/ol/ol-form'
import OLAutocomplete, {
OLAutocompleteItem,
OLAutocompleteProps,
} from '../../../../frontend/js/shared/components/ol/ol-autocomplete'
const testItems: OLAutocompleteItem[] = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
{ value: 'elderberry', label: 'Elderberry' },
]
const groupedTestItems: OLAutocompleteItem[] = [
{ value: 'apple', label: 'Apple', group: 'Fruits' },
{ value: 'banana', label: 'Banana', group: 'Fruits' },
{ value: 'carrot', label: 'Carrot', group: 'Vegetables' },
{ value: 'dill', label: 'Dill', group: 'Vegetables' },
]
type RenderProps = Partial<OLAutocompleteProps> &
Pick<OLAutocompleteProps, 'items'> & {
onSubmit?: (formData: object) => void
}
function render(props: RenderProps) {
const changeHandler = props.onChange || cy.stub().as('changeHandler')
const label = props.label ?? 'Select item'
const submitHandler = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (props.onSubmit) {
const formData = new FormData(event.target as HTMLFormElement)
props.onSubmit(Object.fromEntries(formData.entries()))
}
}
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<form onSubmit={submitHandler}>
<OLAutocomplete
items={props.items}
onChange={changeHandler}
placeholder={props.placeholder}
label={label}
showLabel={props.showLabel}
allowCreate={props.allowCreate}
disabled={props.disabled}
createOptionPrefix={props.createOptionPrefix}
useFuzzySearch={props.useFuzzySearch}
/>
<button type="submit">submit</button>
</form>
</div>
)
}
describe('<OLAutocomplete />', function () {
describe('initial rendering', function () {
it('renders with placeholder', function () {
render({ items: testItems, placeholder: 'Search items...' })
cy.findByPlaceholderText('Search items...')
})
it('renders with visible label', function () {
render({
items: testItems,
label: 'Select item',
showLabel: true,
})
cy.findByRole('combobox', { name: 'Select item' }).should('be.visible')
cy.findByText('Select item').should('be.visible')
})
it('renders with visually hidden label', function () {
render({
items: testItems,
label: 'Select item',
showLabel: false,
})
cy.findByRole('combobox', { name: 'Select item' }).should('exist')
cy.get('.visually-hidden').should('exist')
})
it('starts with empty input', function () {
render({ items: testItems })
cy.findByRole('combobox').should('have.value', '')
})
it('does not show clear button when empty', function () {
render({ items: testItems })
cy.findByLabelText('Delete').should('not.exist')
})
it('does not show dropdown initially', function () {
render({ items: testItems })
cy.get('.dropdown-menu.show').should('not.exist')
})
})
describe('items rendering', function () {
it('renders all items when input is focused', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.findByText('Apple')
cy.findByText('Banana')
cy.findByText('Cherry')
cy.findByText('Date')
cy.findByText('Elderberry')
})
it('renders grouped items with headers', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').click()
cy.contains('Fruits')
cy.contains('Vegetables')
cy.findByText('Apple')
cy.findByText('Banana')
cy.findByText('Carrot')
cy.findByText('Dill')
})
it('separates groups with dividers', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').click()
cy.get('.dropdown-divider').should('have.length', 1)
})
it('shows dropdown when typing', function () {
render({ items: testItems })
cy.findByRole('combobox').type('a')
cy.get('.dropdown-menu.show').should('exist')
})
})
describe('filtering', function () {
it('filters items based on input', function () {
render({ items: testItems })
cy.findByRole('combobox').type('ba')
cy.findByText('Banana').should('exist')
cy.findByText('Apple').should('not.exist')
cy.findByText('Cherry').should('not.exist')
})
it('filters items case-insensitively', function () {
render({ items: testItems })
cy.findByRole('combobox').type('CHERRY')
cy.findByText('Cherry').should('exist')
cy.findByText('Apple').should('not.exist')
})
it('shows all items when input is cleared', function () {
render({ items: testItems })
cy.findByRole('combobox').type('ba')
cy.findByText('Banana').should('exist')
cy.findByRole('combobox').clear()
cy.findByRole('combobox').click()
cy.findByText('Apple').should('exist')
cy.findByText('Cherry').should('exist')
})
it('filters grouped items', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').type('car')
cy.findByText('Carrot').should('exist')
cy.contains('Vegetables').should('exist')
cy.findByText('Apple').should('not.exist')
cy.contains('Fruits').should('not.exist')
})
it('hides empty groups after filtering', function () {
render({ items: groupedTestItems })
cy.findByRole('combobox').type('app')
cy.findByText('Apple').should('exist')
cy.contains('Fruits').should('exist')
cy.contains('Vegetables').should('not.exist')
})
})
describe('fuzzy search', function () {
it('performs fuzzy search when enabled', function () {
render({ items: testItems, useFuzzySearch: true })
cy.findByRole('combobox').type('aple')
cy.findByText('Apple').should('exist')
})
it('performs fuzzy search on grouped items', function () {
render({ items: groupedTestItems, useFuzzySearch: true })
cy.findByRole('combobox').type('banan')
cy.findByText('Banana').should('exist')
})
})
describe('item selection', function () {
it('selects an item on click', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').click()
cy.findByText('Banana').click()
cy.get('@changeHandler').should('have.been.calledOnceWith', 'banana')
cy.findByRole('combobox').should('have.value', 'Banana')
})
it('closes dropdown after selection', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.findByText('Cherry').click()
cy.get('.dropdown-menu.show').should('not.exist')
})
it('displays clear button after selection', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.findByText('Apple').click()
cy.findByLabelText('Delete').should('exist')
})
it('cannot select when disabled', function () {
render({ items: testItems, disabled: true })
cy.findByRole('combobox').should('be.disabled')
cy.findByRole('combobox').click({ force: true })
cy.get('.dropdown-menu.show').should('not.exist')
})
it('does not show clear button when disabled', function () {
render({ items: testItems, disabled: true })
cy.findByRole('combobox').type('Apple', { force: true })
cy.findByLabelText('Delete').should('not.exist')
})
})
describe('clear button', function () {
it('clears the input when clicked', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').type('Apple')
cy.findByLabelText('Delete').click()
cy.findByRole('combobox').should('have.value', '')
cy.get('@changeHandler').should('have.been.calledWith', '')
})
it('restores all items after clearing', function () {
render({ items: testItems })
cy.findByRole('combobox').type('ba')
cy.findByText('Banana').should('exist')
cy.findByText('Apple').should('not.exist')
cy.findByLabelText('Delete').click()
cy.findByRole('combobox').click()
cy.findByText('Apple').should('exist')
cy.findByText('Banana').should('exist')
cy.findByText('Cherry').should('exist')
})
})
describe('create option', function () {
it('shows create option when input does not match any item', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").should('exist')
})
it('does not show create option when input matches an item', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('Apple')
cy.contains('+ Create').should('not.exist')
})
it('shows create option with case-insensitive matching', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('apple')
cy.contains('+ Create').should('not.exist')
})
it('invokes onChange with new value when create option is selected', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, allowCreate: true, onChange: changeHandler })
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").click()
cy.get('@changeHandler').should('have.been.calledWithMatch', /grape$/)
cy.findByRole('combobox').should('have.value', 'grape')
})
it('does not show create option when allowCreate is false', function () {
render({ items: testItems, allowCreate: false })
cy.findByRole('combobox').type('grape')
cy.contains('+ Create').should('not.exist')
})
it('does not show create option when allowCreate function returns false', function () {
render({
items: testItems,
allowCreate: value => value !== 'grape',
})
cy.findByRole('combobox').type('grape')
cy.contains('+ Create').should('not.exist')
})
it('shows create option when allowCreate function returns true', function () {
render({
items: testItems,
allowCreate: value => value.length > 2,
})
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").should('exist')
})
it('uses custom create option prefix', function () {
render({
items: testItems,
allowCreate: true,
createOptionPrefix: 'Add new:',
})
cy.findByRole('combobox').type('grape')
cy.contains("Add new: 'grape'").should('exist')
cy.contains('+ Create').should('not.exist')
})
it('shows create option with groups', function () {
render({ items: groupedTestItems, allowCreate: true })
cy.findByRole('combobox').type('grape')
cy.contains("+ Create 'grape'").should('exist')
})
it('separates create option with divider in grouped view', function () {
render({ items: groupedTestItems, allowCreate: true })
cy.findByRole('combobox').type('a')
cy.contains("+ Create 'a'").should('exist')
cy.get('.dropdown-divider').should('have.length', 2)
})
})
describe('keyboard navigation', function () {
it('opens dropdown on ArrowDown key', function () {
render({ items: testItems })
cy.findByRole('combobox').type('{downArrow}')
cy.get('.dropdown-menu.show').should('exist')
})
it('selects first item on Enter when no item is highlighted', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').type('b{enter}')
cy.get('@changeHandler').should('have.been.calledWith', 'banana')
})
it('navigates through items with arrow keys', function () {
render({ items: testItems })
cy.findByRole('combobox').click()
cy.get('.dropdown-item-highlighted').should('contain', 'Apple')
cy.findByRole('combobox').type('{downArrow}')
cy.get('.dropdown-item-highlighted').should('contain', 'Banana')
cy.findByRole('combobox').type('{downArrow}')
cy.get('.dropdown-item-highlighted').should('contain', 'Cherry')
})
it('selects highlighted item on Enter', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, onChange: changeHandler })
cy.findByRole('combobox').click()
cy.findByRole('combobox').type('{downArrow}{enter}')
cy.get('@changeHandler').should('have.been.calledWith', 'banana')
})
it('can navigate to and select create option', function () {
const changeHandler = cy.stub().as('changeHandler')
render({ items: testItems, allowCreate: true, onChange: changeHandler })
cy.findByRole('combobox').type('grape')
// Navigate down through all items to the create option
cy.findByRole('combobox').type('{downArrow}'.repeat(6) + '{enter}')
cy.get('@changeHandler').should('have.been.calledWith', 'grape')
})
})
describe('form integration', function () {
it('works within a form context', function () {
const FormWithAutocomplete = ({
onSubmit,
}: {
onSubmit: (formData: object) => void
}) => {
const changeHandler = cy.stub().as('formChangeHandler')
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.target as HTMLFormElement)
onSubmit(Object.fromEntries(formData.entries()))
}
return (
<OLForm onSubmit={handleSubmit}>
<input
type="hidden"
name="autocomplete_value"
value=""
ref={ref => {
if (ref) {
// Update hidden input when autocomplete changes
const observer = new MutationObserver(() => {
const autocompleteInput = ref.form?.querySelector(
'input[type="text"]'
) as HTMLInputElement
if (autocompleteInput) {
ref.value = autocompleteInput.value
}
})
if (ref.form) {
observer.observe(ref.form, {
subtree: true,
attributes: true,
})
}
}
}}
/>
<OLAutocomplete
items={testItems}
onChange={changeHandler}
placeholder="Search..."
label="Select item"
/>
<OLButton type="submit">submit</OLButton>
</OLForm>
)
}
const submitHandler = cy.stub().as('submitHandler')
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<FormWithAutocomplete onSubmit={submitHandler} />
</div>
)
cy.findByRole('combobox').click()
cy.findByText('Banana').click()
cy.get('@formChangeHandler').should('have.been.calledWith', 'banana')
})
})
describe('edge cases', function () {
it('handles empty items array', function () {
render({ items: [] })
cy.findByRole('combobox').click()
cy.get('.dropdown-menu.show').should('not.exist')
})
it('handles empty groups array', function () {
render({ items: [] })
cy.findByRole('combobox').click()
cy.get('.dropdown-menu.show').should('not.exist')
})
it('shows only create option when no items match', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type('xyz')
cy.contains("+ Create 'xyz'").should('exist')
cy.findByText('Apple').should('not.exist')
})
it('does not trim whitespace in comparisons', function () {
render({ items: testItems, allowCreate: true })
cy.findByRole('combobox').type(' apple ')
cy.contains('+ Create').should('exist')
})
})
})