diff --git a/packages/ui/src/components/form/controls/AutoComplete/AutoComplete.test.tsx b/packages/ui/src/components/form/controls/AutoComplete/AutoComplete.test.tsx new file mode 100644 index 0000000..0ff6ca1 --- /dev/null +++ b/packages/ui/src/components/form/controls/AutoComplete/AutoComplete.test.tsx @@ -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() + + 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( + , + ) + + 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() + + 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() + }) +}) diff --git a/packages/ui/src/components/form/controls/AutoComplete/AutoComplete.tsx b/packages/ui/src/components/form/controls/AutoComplete/AutoComplete.tsx index e11add1..429c3c6 100644 --- a/packages/ui/src/components/form/controls/AutoComplete/AutoComplete.tsx +++ b/packages/ui/src/components/form/controls/AutoComplete/AutoComplete.tsx @@ -1,4 +1,4 @@ -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 { @@ -6,7 +6,10 @@ export interface AutoCompleteOption { label?: string } -export interface AutoCompleteProps extends Omit, 'onChange' | 'onSelect'> { +export interface AutoCompleteProps extends Omit< + InputHTMLAttributes, + 'onChange' | 'onSelect' +> { options?: AutoCompleteOption[] value?: string defaultValue?: string @@ -24,6 +27,8 @@ export const AutoComplete = forwardRef(func defaultValue = '', onChange, onSelect, + onFocus, + onKeyDown, filterOption = true, allowClear = false, placeholder, @@ -33,7 +38,10 @@ export const AutoComplete = forwardRef(func ) { const [internal, setInternal] = useState(defaultValue) const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) const wrapperRef = useRef(null) + const generatedId = useId() + const listboxId = `${generatedId}-listbox` const val = controlledValue ?? internal const filtered = options.filter((opt) => { @@ -47,6 +55,18 @@ export const AutoComplete = forwardRef(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) => { @@ -56,41 +76,112 @@ export const AutoComplete = forwardRef(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 (
{ 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 ? ( ) : null}
- {open && filtered.length > 0 ? ( -
    - {filtered.map((opt) => ( + {isListOpen ? ( +
      + {filtered.map((opt, index) => (
    • setActiveIndex(index)} onMouseDown={() => { - update(opt.value) - onSelect?.(opt.value) - setOpen(false) + selectOption(opt) }} > {opt.label ?? opt.value} diff --git a/packages/ui/src/components/form/controls/InputNumber/InputNumber.test.tsx b/packages/ui/src/components/form/controls/InputNumber/InputNumber.test.tsx new file mode 100644 index 0000000..0c80b4a --- /dev/null +++ b/packages/ui/src/components/form/controls/InputNumber/InputNumber.test.tsx @@ -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() + + 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( + , + ) + + 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() + + 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( + , + ) + + 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) + }) +}) \ No newline at end of file diff --git a/packages/ui/src/components/form/controls/InputNumber/InputNumber.tsx b/packages/ui/src/components/form/controls/InputNumber/InputNumber.tsx index facff9b..a69cb32 100644 --- a/packages/ui/src/components/form/controls/InputNumber/InputNumber.tsx +++ b/packages/ui/src/components/form/controls/InputNumber/InputNumber.tsx @@ -17,7 +17,12 @@ export interface InputNumberProps extends Omit, ' export const InputNumber = forwardRef(function InputNumber( { + id, className, + 'aria-describedby': ariaDescribedBy, + 'aria-invalid': ariaInvalid, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, value: controlledValue, defaultValue, min = -Infinity, @@ -49,6 +54,9 @@ export const InputNumber = forwardRef(function } const heightCls = { 'h-8': size === 'sm', 'h-10': size === 'md', 'h-11': size === 'lg' } + const ariaValueNow = val ?? undefined + const ariaValueMin = Number.isFinite(min) ? min : undefined + const ariaValueMax = Number.isFinite(max) ? max : undefined return (
      (function {controls ? ( ) : null} { const raw = e.target.value - if (raw === '' || raw === '-') { update(null); return } + if (raw === '' || raw === '-') { + update(null) + return + } const n = Number(raw) if (!isNaN(n)) update(n) }} - onBlur={() => { if (val !== null) update(clamp(val)) }} + onKeyDown={(event) => { + if (event.key === 'ArrowUp') { + event.preventDefault() + update((val ?? 0) + step) + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + update((val ?? 0) - step) + return + } + + if (event.key === 'Home' && Number.isFinite(min)) { + event.preventDefault() + update(min) + return + } + + if (event.key === 'End' && Number.isFinite(max)) { + event.preventDefault() + update(max) + } + }} + onBlur={() => { + if (val !== null) update(clamp(val)) + }} className="w-16 min-w-0 flex-1 bg-transparent px-2 text-center text-sm outline-none dark:text-slate-100" /> {controls ? (
      ) -}) +}) \ No newline at end of file