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.
Demo
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 Roles
| Role | Target 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. |
WAI-ARIA Properties
aria-valuenow
Must be updated immediately when value changes (keyboard, button click, or text input)
- Values
- Number (current value)
- Required
- Yes
aria-valuemin
Only set when min is defined. Omit attribute entirely when no minimum limit exists.
- Values
- Number
- Required
- No
aria-valuemax
Only set when max is defined. Omit attribute entirely when no maximum limit exists.
- Values
- Number
- Required
- No
aria-valuetext
Provides a human-readable text alternative for the current value. Use when the numeric value alone doesn’t convey sufficient meaning.
- Values
- String (e.g.,
5 items,3 of 10) - Required
- No
aria-disabled
Indicates that the spinbutton is disabled and not interactive.
- Values
- true | false
- Required
- No
aria-readonly
Indicates that the spinbutton is read-only. Users can navigate with Home/End but cannot change the value.
- Values
- true | false
- Required
- No
aria-label
Provides an invisible label for the spinbutton
- Values
- String
- Required
- Conditional (required if no visible label)
aria-labelledby
References an external element as the label
- Values
- ID reference
- Required
Conditional (required if visible label exists)
Keyboard Support
| Key | Action |
|---|---|
| ArrowUp | Increases the value by one step |
| ArrowDown | 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) |
- 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.
- 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.
- Spinbuttons must have an accessible name. This can be provided through a visible label using the label prop, aria-label for an invisible label, or aria-labelledby to reference an external element.
Focus Management
| Event | Behavior |
|---|---|
| Input element | tabindex="0" |
| Disabled input | tabindex="-1" |
| Increment/decrement buttons | tabindex="-1" (not in tab order) |
| Button click | Focus stays on spinbutton (does NOT move to button) |
Visual Design
- Focus indicator - Visible focus ring on the entire controls container (including buttons)
- 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
import { useId, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
// Label: one of these required (exclusive)
type LabelProps =
| { label: string; 'aria-label'?: never; 'aria-labelledby'?: never }
| { label?: never; 'aria-label': string; 'aria-labelledby'?: never }
| { label?: never; 'aria-label'?: never; 'aria-labelledby': string };
// ValueText: exclusive with format
type ValueTextProps =
| { valueText: string; format?: never }
| { valueText?: never; format?: string }
| { valueText?: never; format?: never };
type SpinbuttonBaseProps = {
defaultValue?: number;
min?: number;
max?: number;
step?: number;
largeStep?: number;
disabled?: boolean;
readOnly?: boolean;
showButtons?: boolean;
onValueChange?: (value: number) => void;
className?: string;
id?: string;
'aria-describedby'?: string;
'aria-invalid'?: boolean;
'data-testid'?: string;
};
export type SpinbuttonProps = SpinbuttonBaseProps & LabelProps & ValueTextProps;
// Clamp value to range (only if min/max defined)
const clamp = (value: number, min?: number, max?: number): number => {
let result = value;
if (min !== undefined) result = Math.max(min, result);
if (max !== undefined) result = Math.min(max, result);
return result;
};
// Ensure step is valid (positive number)
const ensureValidStep = (step: number): number => {
return step > 0 ? step : 1;
};
// Round to step with floating-point precision handling
const roundToStep = (value: number, step: number, min?: number): number => {
const validStep = ensureValidStep(step);
const base = min ?? 0;
const steps = Math.round((value - base) / validStep);
const result = base + steps * validStep;
// Handle floating-point precision (e.g., 0.1 + 0.2 = 0.30000000000000004)
const decimals = (validStep.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimals));
};
// Format value text
const formatValueText = (format: string, value: number, min?: number, max?: number): string => {
return format
.replace('{value}', String(value))
.replace('{min}', min !== undefined ? String(min) : '')
.replace('{max}', max !== undefined ? String(max) : '');
};
export function Spinbutton(props: SpinbuttonProps) {
const {
defaultValue = 0,
min,
max,
step = 1,
largeStep,
disabled = false,
readOnly = false,
showButtons = true,
onValueChange,
className,
id,
'aria-describedby': ariaDescribedby,
'aria-invalid': ariaInvalid,
'data-testid': dataTestId,
...labelProps
} = props;
const generatedId = useId();
const labelId = `${generatedId}-label`;
const inputRef = useRef<HTMLInputElement>(null);
// Get label-related props
const label = 'label' in labelProps ? labelProps.label : undefined;
const ariaLabel = 'aria-label' in labelProps ? labelProps['aria-label'] : undefined;
const ariaLabelledby =
'aria-labelledby' in labelProps ? labelProps['aria-labelledby'] : undefined;
// Get valueText-related props
const valueText = 'valueText' in props ? props.valueText : undefined;
const format = 'format' in props ? props.format : undefined;
// Initialize value with clamping and rounding
const initialValue = clamp(roundToStep(defaultValue, step, min), min, max);
const [value, setValue] = useState(initialValue);
const [inputValue, setInputValue] = useState(String(initialValue));
const [isComposing, setIsComposing] = useState(false);
const effectiveLargeStep = largeStep ?? step * 10;
// Compute aria-valuetext
const getAriaValueText = (): string | undefined => {
if (valueText) return valueText;
if (format) return formatValueText(format, value, min, max);
return undefined;
};
const computedValueText = getAriaValueText();
// Update value and call callback
const updateValue = (newValue: number) => {
const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
if (clampedValue !== value) {
setValue(clampedValue);
setInputValue(String(clampedValue));
onValueChange?.(clampedValue);
}
};
// Handle keyboard events
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
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);
}
};
// Handle text input change
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newInputValue = event.target.value;
setInputValue(newInputValue);
if (!isComposing) {
const parsed = parseFloat(newInputValue);
if (!isNaN(parsed)) {
const clampedValue = clamp(roundToStep(parsed, step, min), min, max);
if (clampedValue !== value) {
setValue(clampedValue);
onValueChange?.(clampedValue);
}
}
}
};
// Handle blur - validate and finalize input
const handleBlur = () => {
const parsed = parseFloat(inputValue);
if (isNaN(parsed)) {
// Revert to previous valid value
setInputValue(String(value));
} else {
// Clamp and round the value
const newValue = clamp(roundToStep(parsed, step, min), min, max);
if (newValue !== value) {
setValue(newValue);
onValueChange?.(newValue);
}
setInputValue(String(newValue));
}
};
// IME composition handlers
const handleCompositionStart = () => setIsComposing(true);
const handleCompositionEnd = () => {
setIsComposing(false);
// Validate and update after composition ends
const parsed = parseFloat(inputValue);
if (!isNaN(parsed)) {
const clampedValue = clamp(roundToStep(parsed, step, min), min, max);
setValue(clampedValue);
onValueChange?.(clampedValue);
}
};
// Button handlers
const handleIncrement = (event: React.MouseEvent) => {
event.preventDefault();
if (disabled || readOnly) return;
updateValue(value + step);
inputRef.current?.focus();
};
const handleDecrement = (event: React.MouseEvent) => {
event.preventDefault();
if (disabled || readOnly) return;
updateValue(value - step);
inputRef.current?.focus();
};
return (
<div className={cn('apg-spinbutton', disabled && 'apg-spinbutton--disabled', className)}>
{label && (
<span className="apg-spinbutton-label" id={labelId}>
{label}
</span>
)}
<div className="apg-spinbutton-controls">
{showButtons && (
<button
type="button"
tabIndex={-1}
aria-label="Decrement"
onMouseDown={(e) => e.preventDefault()}
onClick={handleDecrement}
disabled={disabled}
className="apg-spinbutton-button apg-spinbutton-decrement"
>
−
</button>
)}
<input
ref={inputRef}
type="text"
role="spinbutton"
id={id}
tabIndex={disabled ? -1 : 0}
inputMode="numeric"
value={inputValue}
readOnly={readOnly}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={computedValueText}
aria-label={ariaLabel}
aria-labelledby={label ? labelId : ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-disabled={disabled || undefined}
aria-readonly={readOnly || undefined}
aria-invalid={ariaInvalid}
data-testid={dataTestId}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
className="apg-spinbutton-input"
/>
{showButtons && (
<button
type="button"
tabIndex={-1}
aria-label="Increment"
onMouseDown={(e) => e.preventDefault()}
onClick={handleIncrement}
disabled={disabled}
className="apg-spinbutton-button apg-spinbutton-increment"
>
+
</button>
)}
</div>
</div>
);
} Usage
import { Spinbutton } from './Spinbutton';
function App() {
return (
<div>
{/* 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={(value) => console.log(value)}
/>
</div>
);
} 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 |
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
- Playwright (opens in new tab) - E2E testing (178 cross-framework tests)
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- axe-core (opens in new tab) - Automated accessibility testing
See the Testing Strategy guide for details.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Spinbutton } from './Spinbutton';
describe('Spinbutton', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="spinbutton"', () => {
render(<Spinbutton aria-label="Quantity" />);
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
});
it('has aria-valuenow set to current value', () => {
render(<Spinbutton 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 aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-valuemin when min is defined', () => {
render(<Spinbutton 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 aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuemin');
});
it('has aria-valuemax when max is defined', () => {
render(<Spinbutton 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 aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuemax');
});
it('has aria-valuetext when valueText provided', () => {
render(<Spinbutton 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 defaultValue={5} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuetext');
});
it('uses format for aria-valuetext', () => {
render(<Spinbutton defaultValue={5} format="{value} items" aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuetext', '5 items');
});
it('updates aria-valuetext on value change', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} format="{value} items" aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuetext', '6 items');
});
it('has aria-disabled="true" when disabled', () => {
render(<Spinbutton disabled 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 aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-disabled');
});
it('has aria-readonly="true" when readOnly', () => {
render(<Spinbutton readOnly aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-readonly', 'true');
});
it('does not have aria-readonly when not readOnly', () => {
render(<Spinbutton aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-readonly');
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(<Spinbutton aria-label="Quantity" />);
expect(screen.getByRole('spinbutton', { name: 'Quantity' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<>
<span id="spinbutton-label">Item Count</span>
<Spinbutton aria-labelledby="spinbutton-label" />
</>
);
expect(screen.getByRole('spinbutton', { name: 'Item Count' })).toBeInTheDocument();
});
it('has accessible name via visible label', () => {
render(<Spinbutton 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 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 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 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 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 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 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 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'); // default largeStep = step * 10
});
it('decreases value by large step on PageDown', async () => {
const user = userEvent.setup();
render(<Spinbutton 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('uses custom largeStep when provided', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={50} step={1} largeStep={20} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{PageUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '70');
});
it('respects custom step value', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={50} step={5} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '55');
});
it('does not exceed max on ArrowUp', async () => {
const user = userEvent.setup();
render(<Spinbutton 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 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 defaultValue={5} disabled 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 defaultValue={5} readOnly 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 aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(<Spinbutton disabled aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('tabindex', '-1');
});
it('is focusable via Tab', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<Spinbutton aria-label="Quantity" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Focus spinbutton
expect(screen.getByRole('spinbutton')).toHaveFocus();
});
it('is not focusable via Tab when disabled', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<Spinbutton disabled aria-label="Quantity" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Focus "After" button (skip spinbutton)
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('buttons have tabindex="-1"', () => {
render(<Spinbutton aria-label="Quantity" showButtons />);
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 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();
});
it('focus stays on spinbutton after decrement button click', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
const decrementButton = screen.getByLabelText(/decrement/i);
await user.click(spinbutton);
await user.click(decrementButton);
expect(spinbutton).toHaveFocus();
});
});
// 🟡 Medium Priority: Button Interaction
describe('Button Interaction', () => {
it('increases value on increment button click', async () => {
const user = userEvent.setup();
render(<Spinbutton 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 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('increment button does not exceed max', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={100} max={100} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(incrementButton);
expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
});
it('decrement button does not go below min', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={0} min={0} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
const decrementButton = screen.getByLabelText(/decrement/i);
await user.click(decrementButton);
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('buttons are disabled when component is disabled', async () => {
render(<Spinbutton defaultValue={5} disabled aria-label="Quantity" />);
const incrementButton = screen.getByLabelText(/increment/i);
const decrementButton = screen.getByLabelText(/decrement/i);
expect(incrementButton).toBeDisabled();
expect(decrementButton).toBeDisabled();
});
it('hides buttons when showButtons is false', () => {
render(<Spinbutton aria-label="Quantity" showButtons={false} />);
expect(screen.queryByLabelText(/increment/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/decrement/i)).not.toBeInTheDocument();
});
it('keyboard still works when showButtons is false', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} aria-label="Quantity" showButtons={false} />);
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
});
});
// 🟡 Medium Priority: Text Input
describe('Text Input', () => {
it('accepts direct text input', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '42');
await user.tab(); // blur to confirm
expect(spinbutton).toHaveAttribute('aria-valuenow', '42');
});
it('reverts to previous value on invalid input', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, 'abc');
await user.tab(); // blur to confirm
expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
});
it('clamps value to max on valid input exceeding max', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} max={10} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '999');
await user.tab(); // blur to confirm
expect(spinbutton).toHaveAttribute('aria-valuenow', '10');
});
it('clamps value to min on valid input below min', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} min={0} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '-10');
await user.tab(); // blur to confirm
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('does not allow text input when readOnly', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} readOnly aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.type(spinbutton, '42');
expect(spinbutton).toHaveValue('5');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<Spinbutton aria-label="Quantity" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with visible label', async () => {
const { container } = render(<Spinbutton label="Quantity" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-labelledby', async () => {
const { container } = render(
<>
<span id="label">Quantity</span>
<Spinbutton aria-labelledby="label" />
</>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(<Spinbutton disabled aria-label="Quantity" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when readOnly', async () => {
const { container } = render(<Spinbutton readOnly aria-label="Quantity" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with valueText', async () => {
const { container } = render(
<Spinbutton defaultValue={5} valueText="5 items" 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 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 defaultValue={5} onValueChange={handleChange} aria-label="Quantity" />);
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(incrementButton);
expect(handleChange).toHaveBeenCalledWith(6);
});
it('calls onValueChange on text input', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} onValueChange={handleChange} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '42');
await user.tab();
expect(handleChange).toHaveBeenCalledWith(42);
});
it('does not call onValueChange when disabled', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<Spinbutton defaultValue={5} disabled onValueChange={handleChange} aria-label="Quantity" />
);
const spinbutton = screen.getByRole('spinbutton');
spinbutton.focus();
await user.keyboard('{ArrowUp}');
expect(handleChange).not.toHaveBeenCalled();
});
it('does not call onValueChange when value does not change', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<Spinbutton
defaultValue={100}
max={100}
onValueChange={handleChange}
aria-label="Quantity"
/>
);
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(handleChange).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('handles decimal step values correctly', async () => {
const user = userEvent.setup();
render(<Spinbutton 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 defaultValue={-5} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '-5');
});
it('handles negative min/max range', () => {
render(<Spinbutton defaultValue={0} min={-50} max={50} aria-label="Temperature" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
expect(spinbutton).toHaveAttribute('aria-valuemin', '-50');
expect(spinbutton).toHaveAttribute('aria-valuemax', '50');
});
it('clamps defaultValue to max when exceeding', () => {
render(<Spinbutton defaultValue={150} max={100} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
});
it('clamps defaultValue to min when below', () => {
render(<Spinbutton defaultValue={-10} min={0} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('rounds value to step', () => {
render(<Spinbutton defaultValue={53} step={5} aria-label="Quantity" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '55');
});
it('allows value beyond range when min/max undefined', async () => {
const user = userEvent.setup();
render(<Spinbutton 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 label="Quantity" />);
expect(screen.getByText('Quantity')).toBeInTheDocument();
});
it('has inputmode="numeric"', () => {
render(<Spinbutton 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 aria-label="Quantity" className="custom-spinbutton" />);
const container = screen.getByRole('spinbutton').closest('.apg-spinbutton');
expect(container).toHaveClass('custom-spinbutton');
});
it('sets id attribute on spinbutton element', () => {
render(<Spinbutton aria-label="Quantity" id="my-spinbutton" />);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('id', 'my-spinbutton');
});
it('passes through data-* attributes', () => {
render(<Spinbutton aria-label="Quantity" data-testid="custom-spinbutton" />);
expect(screen.getByTestId('custom-spinbutton')).toBeInTheDocument();
});
it('supports aria-describedby', () => {
render(
<>
<Spinbutton aria-label="Quantity" aria-describedby="desc" />
<p id="desc">Enter the number of items</p>
</>
);
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-describedby', 'desc');
});
});
}); 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