Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'

import { AutoComplete } from './AutoComplete'

describe('AutoComplete', () => {
const options = [
{ label: 'Beijing', value: 'beijing' },
{ label: 'Shanghai', value: 'shanghai' },
{ label: 'Shenzhen', value: 'shenzhen' },
]

it('exposes combobox and listbox semantics when focused', async () => {
const user = userEvent.setup()

render(<AutoComplete aria-label="City" options={options} />)

const input = screen.getByRole('combobox', { name: 'City' })
expect(input).toHaveAttribute('aria-expanded', 'false')

await user.click(input)

expect(input).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('listbox')).toBeInTheDocument()
expect(screen.getAllByRole('option')).toHaveLength(options.length)
})

it('selects the active option with the keyboard', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onSelect = vi.fn()

render(
<AutoComplete aria-label="City" options={options} onChange={onChange} onSelect={onSelect} />,
)

const input = screen.getByRole('combobox', { name: 'City' })

await user.click(input)
await waitFor(() => expect(input).toHaveAttribute('aria-activedescendant'))
await user.keyboard('{ArrowDown}{Enter}')

expect(onChange).toHaveBeenLastCalledWith('shanghai')
expect(onSelect).toHaveBeenCalledWith('shanghai')
expect(input).toHaveValue('shanghai')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})

it('closes the listbox with Escape', async () => {
const user = userEvent.setup()

render(<AutoComplete aria-label="City" options={options} />)

const input = screen.getByRole('combobox', { name: 'City' })

await user.click(input)
expect(screen.getByRole('listbox')).toBeInTheDocument()

await user.keyboard('{Escape}')

expect(input).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
})
113 changes: 102 additions & 11 deletions packages/ui/src/components/form/controls/AutoComplete/AutoComplete.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { forwardRef, useState, useRef, useEffect, type InputHTMLAttributes } from 'react'
import { forwardRef, useEffect, useId, useRef, useState, type InputHTMLAttributes } from 'react'
import { cn } from '../../../../utils/cn'

export interface AutoCompleteOption {
value: string
label?: string
}

export interface AutoCompleteProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'onSelect'> {
export interface AutoCompleteProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onSelect'
> {
options?: AutoCompleteOption[]
value?: string
defaultValue?: string
Expand All @@ -24,6 +27,8 @@ export const AutoComplete = forwardRef<HTMLInputElement, AutoCompleteProps>(func
defaultValue = '',
onChange,
onSelect,
onFocus,
onKeyDown,
filterOption = true,
allowClear = false,
placeholder,
Expand All @@ -33,7 +38,10 @@ export const AutoComplete = forwardRef<HTMLInputElement, AutoCompleteProps>(func
) {
const [internal, setInternal] = useState(defaultValue)
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const wrapperRef = useRef<HTMLDivElement>(null)
const generatedId = useId()
const listboxId = `${generatedId}-listbox`
const val = controlledValue ?? internal

const filtered = options.filter((opt) => {
Expand All @@ -47,6 +55,18 @@ export const AutoComplete = forwardRef<HTMLInputElement, AutoCompleteProps>(func
onChange?.(v)
}

const isListOpen = open && filtered.length > 0
const activeOption = isListOpen && activeIndex >= 0 ? filtered[activeIndex] : undefined

const getOptionId = (index: number) => `${generatedId}-option-${index}`

const selectOption = (option: AutoCompleteOption) => {
update(option.value)
onSelect?.(option.value)
setOpen(false)
setActiveIndex(-1)
}

useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
Expand All @@ -56,41 +76,112 @@ export const AutoComplete = forwardRef<HTMLInputElement, AutoCompleteProps>(func
return () => document.removeEventListener('mousedown', handler)
})

useEffect(() => {
if (!isListOpen) {
setActiveIndex(-1)
return
}

setActiveIndex((current) => {
if (current >= 0 && current < filtered.length) return current
return 0
})
}, [filtered.length, isListOpen])

return (
<div ref={wrapperRef} className={cn('relative inline-block w-full', className)}>
<div className="relative">
<input
ref={ref}
role="combobox"
aria-autocomplete="list"
aria-expanded={isListOpen}
aria-controls={isListOpen ? listboxId : undefined}
aria-activedescendant={activeOption ? getOptionId(activeIndex) : undefined}
value={val}
placeholder={placeholder}
onChange={(e) => {
update(e.target.value)
setOpen(true)
setActiveIndex(0)
}}
onFocus={(event) => {
onFocus?.(event)
setOpen(true)
}}
onKeyDown={(event) => {
onKeyDown?.(event)
if (event.defaultPrevented) return

if (event.key === 'ArrowDown') {
event.preventDefault()
if (filtered.length === 0) return
setOpen(true)
setActiveIndex((current) => (current < 0 ? 0 : (current + 1) % filtered.length))
return
}

if (event.key === 'ArrowUp') {
event.preventDefault()
if (filtered.length === 0) return
setOpen(true)
setActiveIndex((current) =>
current < 0
? filtered.length - 1
: (current - 1 + filtered.length) % filtered.length,
)
return
}

if (event.key === 'Enter' && activeOption) {
event.preventDefault()
selectOption(activeOption)
return
}

if (event.key === 'Escape') {
setOpen(false)
setActiveIndex(-1)
}
}}
onFocus={() => setOpen(true)}
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
{...props}
/>
{allowClear && val ? (
<button
type="button"
aria-label="Clear input"
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
onClick={() => { update(''); setOpen(false) }}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
update('')
setOpen(false)
setActiveIndex(-1)
}}
>
</button>
) : null}
</div>
{open && filtered.length > 0 ? (
<ul className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{filtered.map((opt) => (
{isListOpen ? (
<ul
id={listboxId}
role="listbox"
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
{filtered.map((opt, index) => (
<li
key={opt.value}
className="cursor-pointer px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-brand-900/20"
id={getOptionId(index)}
role="option"
aria-selected={index === activeIndex}
className={cn(
'cursor-pointer px-3 py-2 text-sm hover:bg-brand-50 dark:hover:bg-brand-900/20',
index === activeIndex && 'bg-brand-50 dark:bg-brand-900/20',
)}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={() => {
update(opt.value)
onSelect?.(opt.value)
setOpen(false)
selectOption(opt)
}}
>
{opt.label ?? opt.value}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'

import { InputNumber } from './InputNumber'

describe('InputNumber', () => {
it('exposes spinbutton semantics on the editable value', () => {
render(<InputNumber aria-label="Quantity" defaultValue={2} min={1} max={5} />)

const input = screen.getByRole('spinbutton', { name: 'Quantity' })

expect(input).toHaveValue('2')
expect(input).toHaveAttribute('aria-valuenow', '2')
expect(input).toHaveAttribute('aria-valuemin', '1')
expect(input).toHaveAttribute('aria-valuemax', '5')
})

it('forwards form control props to the spinbutton', () => {
render(
<InputNumber
id="quantity"
aria-label="Quantity"
aria-describedby="quantity-help"
aria-invalid="true"
/>,
)

const input = screen.getByRole('spinbutton', { name: 'Quantity' })

expect(input).toHaveAttribute('id', 'quantity')
expect(input).toHaveAttribute('aria-describedby', 'quantity-help')
expect(input).toHaveAttribute('aria-invalid', 'true')
})

it('supports named increment and decrement controls', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(<InputNumber aria-label="Quantity" defaultValue={2} onChange={onChange} />)

await user.click(screen.getByRole('button', { name: 'Increase value' }))
expect(onChange).toHaveBeenLastCalledWith(3)

await user.click(screen.getByRole('button', { name: 'Decrease value' }))
expect(onChange).toHaveBeenLastCalledWith(2)
})

it('supports keyboard stepping and boundary shortcuts', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(
<InputNumber
aria-label="Quantity"
defaultValue={1}
min={0}
max={5}
step={2}
onChange={onChange}
/>,
)

const input = screen.getByRole('spinbutton', { name: 'Quantity' })

await user.click(input)
await user.keyboard('{ArrowUp}')
expect(input).toHaveValue('3')
expect(onChange).toHaveBeenLastCalledWith(3)

await user.keyboard('{End}')
expect(input).toHaveValue('5')
expect(onChange).toHaveBeenLastCalledWith(5)

await user.keyboard('{Home}')
expect(input).toHaveValue('0')
expect(onChange).toHaveBeenLastCalledWith(0)
})
})
Loading
Loading