mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-02 21:59:00 +02:00
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:
committed by
Copybot
parent
9d58797a04
commit
1be454b95c
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user