{
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