Explorar el Código

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
l-obrien-overleaf hace 5 meses
padre
commit
1be454b95c

+ 319 - 0
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<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

+ 306 - 0
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<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

+ 531 - 0
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<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')
+    })
+  })
+})