Files
Verso/services/web/test/frontend/components/shared/ol-autocomplete.spec.tsx
T
Eric Mc Sween e0488a8d3b Merge pull request #33534 from overleaf/em-fix-autocomplete-32913
Fix autocomplete dropdown closing on blur and input re-click

GitOrigin-RevId: 82f45f0f1ae9e2b3846906d962a3f16e5b2963e4
2026-05-11 08:06:11 +00:00

634 lines
20 KiB
TypeScript

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}
expandUp={props.expandUp}
onClose={props.onClose}
isOpen={props.isOpen}
/>
<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')
})
it('calls onClose when an item is selected', function () {
const closeHandler = cy.stub().as('closeHandler')
render({ items: testItems, onClose: closeHandler })
cy.findByRole('combobox').click()
cy.findByText('Banana').click()
cy.get('@closeHandler').should('have.been.calledOnce')
})
it('calls onClose when Escape is pressed', function () {
const closeHandler = cy.stub().as('closeHandler')
render({ items: testItems, onClose: closeHandler })
cy.findByRole('combobox').click()
cy.findByRole('combobox').type('{esc}')
cy.get('@closeHandler').should('have.been.calledOnce')
})
})
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')
})
})
describe('controlled isOpen prop', function () {
it('keeps dropdown open when input is blurred', function () {
render({ items: testItems, isOpen: true })
cy.findByRole('combobox').click()
cy.get('.dropdown-menu.show').should('exist')
cy.findByRole('combobox').blur()
cy.get('.dropdown-menu.show').should('exist')
})
it('keeps dropdown open when input is clicked while already open', function () {
render({ items: testItems, isOpen: true })
cy.findByRole('combobox').click()
cy.get('.dropdown-menu.show').should('exist')
cy.findByRole('combobox').click()
cy.get('.dropdown-menu.show').should('exist')
})
it('calls onClose when Escape is pressed', function () {
const closeHandler = cy.stub().as('closeHandler')
render({ items: testItems, isOpen: true, onClose: closeHandler })
cy.findByRole('combobox').type('{esc}')
cy.get('@closeHandler').should('have.been.calledOnce')
})
})
describe('expandUp prop', function () {
it('renders search bar before results list when expandUp is false', function () {
render({ items: testItems, expandUp: false })
cy.findByRole('combobox').click()
cy.get('.ol-autocomplete').within(() => {
cy.get('.dropdown-menu').then($menu => {
cy.findByRole('combobox').then($input => {
const inputTop = $input[0].getBoundingClientRect().top
const menuTop = $menu[0].getBoundingClientRect().top
expect(inputTop).to.be.lessThan(menuTop)
})
})
})
})
it('renders results list before search bar when expandUp is true', function () {
render({ items: testItems, expandUp: true })
cy.findByRole('combobox').click()
cy.get('.ol-autocomplete').within(() => {
cy.get('.dropdown-menu').then($menu => {
cy.findByRole('combobox').then($input => {
const inputTop = $input[0].getBoundingClientRect().top
const menuTop = $menu[0].getBoundingClientRect().top
expect(menuTop).to.be.lessThan(inputTop)
})
})
})
})
it('applies correct margin class when expandUp is false', function () {
render({ items: testItems, expandUp: false })
cy.get('.ol-autocomplete').within(() => {
cy.get('.mb-3').should('exist')
cy.get('.mt-3').should('not.exist')
})
})
it('applies correct margin class when expandUp is true', function () {
render({ items: testItems, expandUp: true })
cy.get('.ol-autocomplete').within(() => {
cy.get('.mt-3').should('exist')
cy.get('.mb-3').should('not.exist')
})
})
})
})