From 1be454b95c346db40cb85294ad7981b1a97ecbb7 Mon Sep 17 00:00:00 2001 From: l-obrien-overleaf Date: Thu, 5 Mar 2026 12:42:00 +0000 Subject: [PATCH] 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 --- .../shared/components/ol/ol-autocomplete.tsx | 319 +++++++++++ .../stories/shared/autocomplete.stories.tsx | 306 ++++++++++ .../shared/ol-autocomplete.spec.tsx | 531 ++++++++++++++++++ 3 files changed, 1156 insertions(+) create mode 100644 services/web/frontend/js/shared/components/ol/ol-autocomplete.tsx create mode 100644 services/web/frontend/stories/shared/autocomplete.stories.tsx create mode 100644 services/web/test/frontend/components/shared/ol-autocomplete.spec.tsx diff --git a/services/web/frontend/js/shared/components/ol/ol-autocomplete.tsx b/services/web/frontend/js/shared/components/ol/ol-autocomplete.tsx new file mode 100644 index 0000000000..69ac0ade7c --- /dev/null +++ b/services/web/frontend/js/shared/components/ol/ol-autocomplete.tsx @@ -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 +} + +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({ + 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 ( +
+ + {label} + +
+ + {internalInputValue && !disabled && ( + + + + )} +
+ +
    + {hasGroupedItems ? ( + <> + {inputItems.map((item, index) => { + const previousItem = inputItems[index - 1] + const hasGroupHeader = + item.group && + (!previousItem || previousItem.group !== item.group) + + return ( + + {hasGroupHeader && index > 0 && ( +
  • + )} + {hasGroupHeader && ( +
  • + {item.group} +
  • + )} +
  • + + {item.label} + +
  • +
    + ) + })} + {showCreateOption && ( + <> +
  • +
  • + + {createOptionPrefix} + '{internalInputValue}' + +
  • + + )} + + ) : ( + displayItems.map((item, index) => { + const isCreateOption = item.type === 'create' + const displayValue = isCreateOption ? item.inputValue : item.label + + return ( +
  • + + {isCreateOption ? ( + <> + {createOptionPrefix} + '{displayValue}' + + ) : ( + displayValue + )} + +
  • + ) + }) + )} +
+
+ ) +} + +const OLAutocomplete = forwardRef( + (props, ref) => { + return + } +) + +OLAutocomplete.displayName = 'OLAutocomplete' + +export default OLAutocomplete diff --git a/services/web/frontend/stories/shared/autocomplete.stories.tsx b/services/web/frontend/stories/shared/autocomplete.stories.tsx new file mode 100644 index 0000000000..f5449afbbd --- /dev/null +++ b/services/web/frontend/stories/shared/autocomplete.stories.tsx @@ -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 + +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 ( +
+ +
+ Current value: {value || '(empty)'} +
+
+ ) +} + +export const Default: StoryObj = { + render: args => , + args: { + items: sampleItems, + label: 'Select a framework', + placeholder: 'Type to search...', + showLabel: true, + allowCreate: true, + }, +} + +export const WithoutCreateOption: StoryObj = { + render: args => , + args: { + items: programmingLanguages, + label: 'Programming language', + placeholder: 'Choose a language', + showLabel: true, + allowCreate: false, + }, +} + +export const AllowCreatePredicate: StoryObj = { + render: 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 = { + render: args => , + args: { + items: countries, + label: 'Country', + placeholder: 'Select your country', + showLabel: false, + allowCreate: true, + }, +} + +export const CustomCreatePrefix: StoryObj = { + render: 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 = { + render: args => , + args: { + items: sampleItems, + label: 'Framework (disabled)', + placeholder: 'Cannot interact', + showLabel: true, + disabled: true, + }, +} + +export const SmallList: StoryObj = { + render: 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 = { + render: args => , + args: { + items: entryTypeItems, + label: 'Entry type', + placeholder: 'Enter entry type', + showLabel: true, + allowCreate: true, + }, +} + +export const GroupedWithoutCreate: StoryObj = { + render: 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 = { + render: 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 = { + render: 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 = { + 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 diff --git a/services/web/test/frontend/components/shared/ol-autocomplete.spec.tsx b/services/web/test/frontend/components/shared/ol-autocomplete.spec.tsx new file mode 100644 index 0000000000..eb09bb9113 --- /dev/null +++ b/services/web/test/frontend/components/shared/ol-autocomplete.spec.tsx @@ -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 & + Pick & { + 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) => { + event.preventDefault() + if (props.onSubmit) { + const formData = new FormData(event.target as HTMLFormElement) + props.onSubmit(Object.fromEntries(formData.entries())) + } + } + + cy.mount( +
+
+ + + +
+ ) +} + +describe('', 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) { + event.preventDefault() + const formData = new FormData(event.target as HTMLFormElement) + onSubmit(Object.fromEntries(formData.entries())) + } + + return ( + + { + 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, + }) + } + } + }} + /> + + submit + + ) + } + + const submitHandler = cy.stub().as('submitHandler') + cy.mount( +
+ +
+ ) + + 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') + }) + }) +})