Spinbutton
An input widget that allows users to select a value from a discrete set or range by using increment/decrement buttons, arrow keys, or typing directly.
🤖 AI Implementation GuideDemo
Native HTML
Use Native HTML First
Before using this custom component, consider using native <input type="number"> elements.
They provide built-in semantics, work without JavaScript, and have native browser validation.
<label for="quantity">Quantity</label>
<input type="number" id="quantity" value="1" min="0" max="100" step="1"> Use custom implementations only when you need custom styling that native elements cannot provide, or when you need specific interaction patterns not available with native inputs.
| Use Case | Native HTML | Custom Implementation |
|---|---|---|
| Basic numeric input | Recommended | Not needed |
| JavaScript disabled support | Works natively | Requires fallback |
| Built-in validation | Native support | Manual implementation |
| Custom button styling | Limited (browser-dependent) | Full control |
| Consistent cross-browser appearance | Varies by browser | Consistent |
| Custom step/large step behavior | Basic step only | PageUp/PageDown support |
| No min/max limits | Requires omitting attributes | Explicit undefined support |
The native <input type="number"> element provides built-in browser validation,
form submission support, and accessible semantics. However, its appearance and spinner button styling
varies significantly across browsers, making custom implementations preferable when visual consistency
is required.
Accessibility Features
WAI-ARIA Role
| Role | Element | Description |
|---|---|---|
spinbutton | Input element | Identifies the element as a spin button that allows users to select a value from a discrete set or range by incrementing/decrementing or typing directly. |
The spinbutton role is used for input controls that let users select a numeric value
by using increment/decrement buttons, arrow keys, or typing directly. It combines the functionality
of a text input with up/down value adjustment.
WAI-ARIA States and Properties
aria-valuenow (Required)
Indicates the current numeric value of the spinbutton. Updated dynamically as the user changes the value.
| Type | Number |
| Required | Yes |
| Update | Must be updated immediately when value changes (keyboard, button click, or text input) |
aria-valuemin
Specifies the minimum allowed value. Only set when a minimum limit exists.
| Type | Number |
| Required | No (only set when min is defined) |
| Note | Omit attribute entirely when no minimum limit exists |
aria-valuemax
Specifies the maximum allowed value. Only set when a maximum limit exists.
| Type | Number |
| Required | No (only set when max is defined) |
| Note | Omit attribute entirely when no maximum limit exists |
aria-valuetext
Provides a human-readable text alternative for the current value. Use when the numeric value alone doesn't convey sufficient meaning.
| Type | String |
| Required | No (recommended when value needs context) |
| Example | "5 items", "3 of 10", "Tuesday" |
aria-disabled
Indicates that the spinbutton is disabled and not interactive.
| Type | Boolean |
| Required | No |
aria-readonly
Indicates that the spinbutton is read-only. Users can navigate with Home/End but cannot change the value.
| Type | Boolean |
| Required | No |
Keyboard Support
| Key | Action |
|---|---|
| Up Arrow | Increases the value by one step |
| Down Arrow | Decreases the value by one step |
| Home | Sets the value to its minimum (only when min is defined) |
| End | Sets the value to its maximum (only when max is defined) |
| Page Up | Increases the value by a large step (default: step * 10) |
| Page Down | Decreases the value by a large step (default: step * 10) |
Note: Unlike the slider pattern, spinbutton uses Up/Down arrows only (not Left/Right). This allows users to type numeric values directly using the text input.
Accessible Naming
Spinbuttons must have an accessible name. This can be provided through:
- Visible label - Using the
labelprop to display a visible label -
aria-label- Provides an invisible label for the spinbutton -
aria-labelledby- References an external element as the label
Text Input
This implementation supports direct text input:
- Numeric keyboard - Uses
inputmode="numeric"for optimal mobile experience - Real-time validation - Value is clamped and rounded to step on each input
- Invalid input handling - Reverts to previous valid value on blur if input is invalid
- IME support - Waits for composition to complete before updating value
Visual Design
This implementation follows WCAG guidelines for accessible visual design:
- Focus indicator - Visible focus ring on the entire controls container (including buttons), providing clear visual feedback for the focused component
- Button states - Visual feedback on hover and active states
- Disabled state - Clear visual indication when spinbutton is disabled
- Read-only state - Distinct visual style for read-only mode
- Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode
References
Source Code
<script lang="ts">
import { cn } from '@/lib/utils';
interface SpinbuttonProps {
defaultValue?: number;
min?: number;
max?: number;
step?: number;
largeStep?: number;
disabled?: boolean;
readOnly?: boolean;
showButtons?: boolean;
label?: string;
valueText?: string;
format?: string;
onvaluechange?: (value: number) => void;
[key: string]: unknown;
}
let {
defaultValue = 0,
min = undefined,
max = undefined,
step = 1,
largeStep,
disabled = false,
readOnly = false,
showButtons = true,
label,
valueText,
format,
onvaluechange,
...restProps
}: SpinbuttonProps = $props();
// Utility functions
function clamp(val: number, minVal?: number, maxVal?: number): number {
let result = val;
if (minVal !== undefined) result = Math.max(minVal, result);
if (maxVal !== undefined) result = Math.min(maxVal, result);
return result;
}
// Ensure step is valid (positive number)
function ensureValidStep(stepVal: number): number {
return stepVal > 0 ? stepVal : 1;
}
function roundToStep(val: number, stepVal: number, minVal?: number): number {
const validStep = ensureValidStep(stepVal);
const base = minVal ?? 0;
const steps = Math.round((val - base) / validStep);
const result = base + steps * validStep;
const decimalPlaces = (validStep.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
}
// Format value helper
function formatValueText(
val: number,
formatStr: string | undefined,
minVal?: number,
maxVal?: number
): string {
if (!formatStr) return String(val);
return formatStr
.replace('{value}', String(val))
.replace('{min}', minVal !== undefined ? String(minVal) : '')
.replace('{max}', maxVal !== undefined ? String(maxVal) : '');
}
// Generate unique ID for label
const labelId = `spinbutton-label-${Math.random().toString(36).slice(2, 9)}`;
// Refs
let inputEl: HTMLInputElement | null = null;
let isComposing = $state(false);
// State
const initialValue = clamp(roundToStep(defaultValue, step, min), min, max);
let value = $state(initialValue);
let inputValue = $state(String(initialValue));
// Computed
const effectiveLargeStep = $derived(largeStep ?? step * 10);
const ariaValueText = $derived.by(() => {
if (valueText) return valueText;
if (format) return formatValueText(value, format, min, max);
return undefined;
});
const ariaLabelledby = $derived.by(() => {
if (restProps['aria-labelledby']) return restProps['aria-labelledby'];
if (label) return labelId;
return undefined;
});
// Update value and dispatch event
function updateValue(newValue: number) {
const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
if (clampedValue !== value) {
value = clampedValue;
inputValue = String(clampedValue);
onvaluechange?.(clampedValue);
}
}
// Keyboard handler
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return;
let newValue = value;
let handled = false;
switch (event.key) {
case 'ArrowUp':
if (!readOnly) {
newValue = value + step;
handled = true;
}
break;
case 'ArrowDown':
if (!readOnly) {
newValue = value - step;
handled = true;
}
break;
case 'Home':
if (min !== undefined) {
newValue = min;
handled = true;
}
break;
case 'End':
if (max !== undefined) {
newValue = max;
handled = true;
}
break;
case 'PageUp':
if (!readOnly) {
newValue = value + effectiveLargeStep;
handled = true;
}
break;
case 'PageDown':
if (!readOnly) {
newValue = value - effectiveLargeStep;
handled = true;
}
break;
default:
return;
}
if (handled) {
event.preventDefault();
updateValue(newValue);
}
}
// Text input handler
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
inputValue = target.value;
if (!isComposing) {
const parsed = parseFloat(target.value);
if (!isNaN(parsed)) {
const clampedValue = clamp(roundToStep(parsed, step, min), min, max);
if (clampedValue !== value) {
value = clampedValue;
onvaluechange?.(clampedValue);
}
}
}
}
// Blur handler
function handleBlur() {
const parsed = parseFloat(inputValue);
if (isNaN(parsed)) {
inputValue = String(value);
} else {
const newValue = clamp(roundToStep(parsed, step, min), min, max);
if (newValue !== value) {
value = newValue;
onvaluechange?.(newValue);
}
inputValue = String(newValue);
}
}
// IME composition handlers
function handleCompositionStart() {
isComposing = true;
}
function handleCompositionEnd() {
isComposing = false;
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
const clampedValue = clamp(roundToStep(parsed, step, min), min, max);
value = clampedValue;
onvaluechange?.(clampedValue);
}
}
// Button handlers
function handleIncrement(event: MouseEvent) {
event.preventDefault();
if (disabled || readOnly) return;
updateValue(value + step);
inputEl?.focus();
}
function handleDecrement(event: MouseEvent) {
event.preventDefault();
if (disabled || readOnly) return;
updateValue(value - step);
inputEl?.focus();
}
</script>
<div class={cn('apg-spinbutton', disabled && 'apg-spinbutton--disabled', restProps.class)}>
{#if label}
<span id={labelId} class="apg-spinbutton-label">
{label}
</span>
{/if}
<div class="apg-spinbutton-controls">
{#if showButtons}
<button
type="button"
tabindex={-1}
aria-label="Decrement"
{disabled}
class="apg-spinbutton-button apg-spinbutton-decrement"
onmousedown={(e) => e.preventDefault()}
onclick={handleDecrement}
>
−
</button>
{/if}
<input
bind:this={inputEl}
type="text"
role="spinbutton"
id={restProps.id}
tabindex={disabled ? -1 : 0}
inputmode="numeric"
value={inputValue}
readonly={readOnly}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={ariaValueText}
aria-label={label ? undefined : restProps['aria-label']}
aria-labelledby={ariaLabelledby}
aria-describedby={restProps['aria-describedby']}
aria-disabled={disabled || undefined}
aria-readonly={readOnly || undefined}
aria-invalid={restProps['aria-invalid']}
data-testid={restProps['data-testid']}
class="apg-spinbutton-input"
oninput={handleInput}
onkeydown={handleKeyDown}
onblur={handleBlur}
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
/>
{#if showButtons}
<button
type="button"
tabindex={-1}
aria-label="Increment"
{disabled}
class="apg-spinbutton-button apg-spinbutton-increment"
onmousedown={(e) => e.preventDefault()}
onclick={handleIncrement}
>
+
</button>
{/if}
</div>
</div> Usage
<script>
import Spinbutton from './Spinbutton.svelte';
function handleChange(value) {
console.log(value);
}
</script>
<!-- Basic usage with aria-label -->
<Spinbutton aria-label="Quantity" />
<!-- With visible label and min/max -->
<Spinbutton
defaultValue={5}
min={0}
max={100}
label="Quantity"
/>
<!-- With format for display and aria-valuetext -->
<Spinbutton
defaultValue={3}
min={1}
max={10}
label="Rating"
format="{value} of {max}"
/>
<!-- Decimal step values -->
<Spinbutton
defaultValue={0.5}
min={0}
max={1}
step={0.1}
label="Opacity"
/>
<!-- Unbounded (no min/max limits) -->
<Spinbutton
defaultValue={0}
label="Counter"
/>
<!-- With callback -->
<Spinbutton
defaultValue={5}
min={0}
max={100}
label="Value"
onvaluechange={handleChange}
/> API
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | number | 0 | Initial value of the spinbutton |
min | number | undefined | Minimum value (undefined = no limit) |
max | number | undefined | Maximum value (undefined = no limit) |
step | number | 1 | Step increment for keyboard/button |
largeStep | number | step * 10 | Large step for PageUp/PageDown |
disabled | boolean | false | Whether the spinbutton is disabled |
readOnly | boolean | false | Whether the spinbutton is read-only |
showButtons | boolean | true | Whether to show increment/decrement buttons |
label | string | - | Visible label (also used as aria-labelledby) |
valueText | string | - | Human-readable value for aria-valuetext |
format | string | - | Format pattern for aria-valuetext (e.g., "{value} of {max}") |
onvaluechange | (value: number) => void | - | Callback when value changes |
One of label, aria-label, or aria-labelledby is required
for accessibility.
Testing
Tests verify APG compliance for ARIA attributes, keyboard interactions, text input handling, and accessibility requirements.
Test Categories
High Priority: ARIA Attributes
| Test | Description |
|---|---|
role="spinbutton" | Element has the spinbutton role |
aria-valuenow | Current value is correctly set and updated |
aria-valuemin | Minimum value is set only when min is defined |
aria-valuemax | Maximum value is set only when max is defined |
aria-valuetext | Human-readable text is set when provided |
aria-disabled | Disabled state is reflected when set |
aria-readonly | Read-only state is reflected when set |
High Priority: Accessible Name
| Test | Description |
|---|---|
aria-label | Accessible name via aria-label attribute |
aria-labelledby | Accessible name via external element reference |
visible label | Visible label provides accessible name |
High Priority: Keyboard Interaction
| Test | Description |
|---|---|
Arrow Up | Increases value by one step |
Arrow Down | Decreases value by one step |
Home | Sets value to minimum (only when min defined) |
End | Sets value to maximum (only when max defined) |
Page Up/Down | Increases/decreases value by large step |
Boundary clamping | Value does not exceed min/max limits |
Disabled state | Keyboard has no effect when disabled |
Read-only state | Arrow keys blocked, Home/End allowed |
High Priority: Button Interaction
| Test | Description |
|---|---|
Increment click | Clicking increment button increases value |
Decrement click | Clicking decrement button decreases value |
Button labels | Buttons have accessible labels |
Disabled/read-only | Buttons blocked when disabled or read-only |
High Priority: Focus Management
| Test | Description |
|---|---|
tabindex="0" | Input is focusable |
tabindex="-1" | Input is not focusable when disabled |
Button tabindex | Buttons have tabindex="-1" (not in tab order) |
Medium Priority: Text Input
| Test | Description |
|---|---|
inputmode="numeric" | Uses numeric keyboard on mobile |
Valid input | aria-valuenow updates on valid text input |
Invalid input | Reverts to previous value on blur with invalid input |
Clamp on blur | Value normalized to step and min/max on blur |
Medium Priority: IME Composition
| Test | Description |
|---|---|
During composition | Value not updated during IME composition |
On composition end | Value updates when composition completes |
Medium Priority: Edge Cases
| Test | Description |
|---|---|
decimal values | Handles decimal step values correctly |
no min/max | Allows unbounded values when no min/max |
clamp to min | defaultValue below min is clamped to min |
clamp to max | defaultValue above max is clamped to max |
Medium Priority: Callbacks
| Test | Description |
|---|---|
onValueChange | Callback is called with new value on change |
Low Priority: HTML Attribute Inheritance
| Test | Description |
|---|---|
className | Custom class is applied to container |
id | ID attribute is set correctly |
data-* | Data attributes are passed through |
Testing Tools
- React: React Testing Library (opens in new tab)
- Vue: Vue Testing Library (opens in new tab)
- Svelte: Svelte Testing Library (opens in new tab)
- Astro: Vitest with JSDOM for Web Component unit tests
- Accessibility: axe-core (opens in new tab)
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Spinbutton from './Spinbutton.svelte';
import SpinbuttonWithLabel from './SpinbuttonWithLabel.test.svelte';
describe('Spinbutton (Svelte)', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="spinbutton"', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
});
it('has aria-valuenow set to current value', () => {
render(Spinbutton, {
props: { defaultValue: 5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
});
it('has aria-valuenow set to 0 when no defaultValue', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-valuemin when min is defined', () => {
render(Spinbutton, {
props: { min: 0, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuemin', '0');
});
it('does not have aria-valuemin when min is undefined', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuemin');
});
it('has aria-valuemax when max is defined', () => {
render(Spinbutton, {
props: { max: 100, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuemax', '100');
});
it('does not have aria-valuemax when max is undefined', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuemax');
});
it('has aria-valuetext when valueText provided', () => {
render(Spinbutton, {
props: { defaultValue: 5, valueText: '5 items', 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuetext', '5 items');
});
it('does not have aria-valuetext when not provided', () => {
render(Spinbutton, {
props: { defaultValue: 5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuetext');
});
it('uses format for aria-valuetext', () => {
render(Spinbutton, {
props: { defaultValue: 5, format: '{value} items', 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuetext', '5 items');
});
it('has aria-disabled="true" when disabled', () => {
render(Spinbutton, {
props: { disabled: true, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-disabled', 'true');
});
it('does not have aria-disabled when not disabled', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-disabled');
});
it('has aria-readonly="true" when readOnly', () => {
render(Spinbutton, {
props: { readOnly: true, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-readonly', 'true');
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
expect(screen.getByRole('spinbutton', { name: 'Quantity' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(SpinbuttonWithLabel);
expect(screen.getByRole('spinbutton', { name: 'Item Count' })).toBeInTheDocument();
});
it('has accessible name via visible label', () => {
render(Spinbutton, {
props: { label: 'Quantity' },
});
expect(screen.getByRole('spinbutton', { name: 'Quantity' })).toBeInTheDocument();
});
});
// 🔴 High Priority: Keyboard Interaction
describe('Keyboard Interaction', () => {
it('increases value by step on ArrowUp', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, step: 1, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
});
it('decreases value by step on ArrowDown', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, step: 1, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowDown}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '4');
});
it('sets min value on Home when min is defined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, min: 0, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{Home}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('Home key has no effect when min is undefined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{Home}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '50');
});
it('sets max value on End when max is defined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, max: 100, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{End}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
});
it('End key has no effect when max is undefined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{End}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '50');
});
it('increases value by large step on PageUp', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, step: 1, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{PageUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '60');
});
it('decreases value by large step on PageDown', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, step: 1, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{PageDown}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '40');
});
it('does not exceed max on ArrowUp', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 100, max: 100, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
});
it('does not go below min on ArrowDown', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 0, min: 0, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowDown}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('does not change value when disabled', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, disabled: true, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
spinbutton.focus();
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
});
it('does not change value on ArrowUp/Down when readOnly', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, readOnly: true, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on input', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(Spinbutton, {
props: { disabled: true, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('tabindex', '-1');
});
it('buttons have tabindex="-1"', () => {
render(Spinbutton, {
props: { showButtons: true, 'aria-label': 'Quantity' },
});
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveAttribute('tabindex', '-1');
});
});
it('focus stays on spinbutton after increment button click', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(spinbutton);
await user.click(incrementButton);
expect(spinbutton).toHaveFocus();
});
});
// 🟡 Medium Priority: Button Interaction
describe('Button Interaction', () => {
it('increases value on increment button click', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(incrementButton);
expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
});
it('decreases value on decrement button click', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
const decrementButton = screen.getByLabelText(/decrement/i);
await user.click(decrementButton);
expect(spinbutton).toHaveAttribute('aria-valuenow', '4');
});
it('hides buttons when showButtons is false', () => {
render(Spinbutton, {
props: { showButtons: false, 'aria-label': 'Quantity' },
});
expect(screen.queryByLabelText(/increment/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/decrement/i)).not.toBeInTheDocument();
});
});
// 🟡 Medium Priority: Text Input
describe('Text Input', () => {
it('accepts direct text input', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '42');
await user.tab();
expect(spinbutton).toHaveAttribute('aria-valuenow', '42');
});
it('reverts to previous value on invalid input', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, 'abc');
await user.tab();
expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
});
it('clamps value to max on valid input exceeding max', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, max: 10, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '999');
await user.tab();
expect(spinbutton).toHaveAttribute('aria-valuenow', '10');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with visible label', async () => {
const { container } = render(Spinbutton, {
props: { label: 'Quantity' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(Spinbutton, {
props: { disabled: true, 'aria-label': 'Quantity' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('calls onvaluechange on keyboard interaction', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, onvaluechange: handleChange, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(handleChange).toHaveBeenCalledWith(6);
});
it('calls onvaluechange on button click', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, onvaluechange: handleChange, 'aria-label': 'Quantity' },
});
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(incrementButton);
expect(handleChange).toHaveBeenCalledWith(6);
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('handles decimal step values correctly', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 0.5, step: 0.1, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0.6');
});
it('handles negative values', () => {
render(Spinbutton, {
props: { defaultValue: -5, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '-5');
});
it('allows value beyond range when min/max undefined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 1000, 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '1001');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('displays visible label when label provided', () => {
render(Spinbutton, {
props: { label: 'Quantity' },
});
expect(screen.getByText('Quantity')).toBeInTheDocument();
});
it('has inputmode="numeric"', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('inputmode', 'numeric');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity', class: 'custom-spinbutton' },
});
const container = screen.getByRole('spinbutton').closest('.apg-spinbutton');
expect(container).toHaveClass('custom-spinbutton');
});
it('sets id attribute on spinbutton element', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity', id: 'my-spinbutton' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('id', 'my-spinbutton');
});
it('passes through data-testid', () => {
render(Spinbutton, {
props: { 'aria-label': 'Quantity', 'data-testid': 'custom-spinbutton' },
});
expect(screen.getByTestId('custom-spinbutton')).toBeInTheDocument();
});
});
}); Resources
- WAI-ARIA APG: Spinbutton Pattern (opens in new tab)
- MDN: <input type="number"> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist