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. |
status |
Visually hidden live region | Announces the new value when an increment/decrement button is clicked. Used only as a fallback in browsers that do not support Element.ariaNotify(). Rendered only when the buttons are shown. |
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) |
Value Announcement on Button Click
When an increment/decrement button is clicked, the new value (aria-valuetext if set, otherwise the number) is announced using one of these two techniques:
- If the browser supports
Element.ariaNotify()(opens in new tab), it is called on the spinbutton. This is a progressive enhancement applied only after confirming support at runtime. - Otherwise, the value is written to a visually hidden
role="status"live region. The live region is emptied after 1 second to avoid leaving stale content in the document.
Keyboard changes are not announced this way because the focused spinbutton already reports its own value changes.
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
---
/**
* APG Spinbutton Pattern - Astro Implementation
*
* A control that allows users to select a value from a discrete set or range.
* Uses Web Components for interactive behavior.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/
*/
export interface Props {
/** Default value */
defaultValue?: number;
/** Minimum value (undefined = no limit) */
min?: number;
/** Maximum value (undefined = no limit) */
max?: number;
/** Step increment (default: 1) */
step?: number;
/** Large step for PageUp/PageDown */
largeStep?: number;
/** Whether spinbutton is disabled */
disabled?: boolean;
/** Whether spinbutton is read-only */
readOnly?: boolean;
/** Show increment/decrement buttons (default: true) */
showButtons?: boolean;
/** Visible label text */
label?: string;
/** Human-readable value text for aria-valuetext */
valueText?: string;
/** Format pattern for dynamic value display (e.g., "{value} items") */
format?: string;
/** Spinbutton id */
id?: string;
/** Additional CSS class */
class?: string;
/** Accessible label when no visible label */
'aria-label'?: string;
/** Reference to external label element */
'aria-labelledby'?: string;
/** Reference to description element */
'aria-describedby'?: string;
/** Whether the input value is invalid */
'aria-invalid'?: boolean;
/** Test ID */
'data-testid'?: string;
}
const {
defaultValue = 0,
min,
max,
step = 1,
largeStep,
disabled = false,
readOnly = false,
showButtons = true,
label,
valueText,
format,
id,
class: className = '',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-describedby': ariaDescribedby,
'aria-invalid': ariaInvalid,
'data-testid': dataTestid,
} = Astro.props;
// Utility functions
const 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;
};
const roundToStep = (val: number, stepVal: number, minVal?: number): number => {
const base = minVal ?? 0;
const steps = Math.round((val - base) / stepVal);
const result = base + steps * stepVal;
const decimalPlaces = (stepVal.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
};
// Calculate initial value
const initialValue = clamp(roundToStep(defaultValue, step, min), min, max);
// Format value helper
const formatValueText = (value: number, formatStr?: string): string => {
if (!formatStr) return String(value);
return formatStr
.replace('{value}', String(value))
.replace('{min}', min !== undefined ? String(min) : '')
.replace('{max}', max !== undefined ? String(max) : '');
};
// Initial aria-valuetext
const getInitialAriaValueText = (): string | undefined => {
if (valueText) return valueText;
if (format) return formatValueText(initialValue, format);
return undefined;
};
const initialAriaValueText = getInitialAriaValueText();
// Generate unique label ID
const labelId = label ? `spinbutton-label-${Math.random().toString(36).slice(2, 9)}` : undefined;
const effectiveLargeStep = largeStep ?? step * 10;
---
<apg-spinbutton
data-min={min}
data-max={max}
data-step={step}
data-large-step={effectiveLargeStep}
data-disabled={disabled}
data-readonly={readOnly}
data-format={format}
>
<div class={`apg-spinbutton ${disabled ? 'apg-spinbutton--disabled' : ''} ${className}`.trim()}>
{
label && (
<span id={labelId} class="apg-spinbutton-label">
{label}
</span>
)
}
<div class="apg-spinbutton-controls">
{
showButtons && (
<button
type="button"
tabindex={-1}
aria-label="Decrement"
disabled={disabled}
class="apg-spinbutton-button apg-spinbutton-decrement"
>
−
</button>
)
}
<input
type="text"
role="spinbutton"
id={id}
tabindex={disabled ? -1 : 0}
inputmode="numeric"
value={String(initialValue)}
readonly={readOnly}
aria-valuenow={initialValue}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={initialAriaValueText}
aria-label={label ? undefined : ariaLabel}
aria-labelledby={ariaLabelledby ?? labelId}
aria-describedby={ariaDescribedby}
aria-disabled={disabled || undefined}
aria-readonly={readOnly || undefined}
aria-invalid={ariaInvalid || undefined}
data-testid={dataTestid}
class="apg-spinbutton-input"
/>
{
showButtons && (
<button
type="button"
tabindex={-1}
aria-label="Increment"
disabled={disabled}
class="apg-spinbutton-button apg-spinbutton-increment"
>
+
</button>
)
}
</div>
{showButtons && <span role="status" class="apg-spinbutton-live" />}
</div>
</apg-spinbutton>
<script>
class ApgSpinbutton extends HTMLElement {
private input: HTMLInputElement | null = null;
private incrementBtn: HTMLButtonElement | null = null;
private decrementBtn: HTMLButtonElement | null = null;
private liveRegion: HTMLElement | null = null;
private announceTimer: ReturnType<typeof setTimeout> | undefined;
private isComposing = false;
private previousValidValue = 0;
// Bound handler references (to properly remove listeners)
private boundHandleKeyDown = this.handleKeyDown.bind(this);
private boundHandleInput = this.handleInput.bind(this);
private boundHandleBlur = this.handleBlur.bind(this);
private boundHandleCompositionStart = this.handleCompositionStart.bind(this);
private boundHandleCompositionEnd = this.handleCompositionEnd.bind(this);
private boundHandleIncrement = this.handleIncrement.bind(this);
private boundHandleDecrement = this.handleDecrement.bind(this);
private boundPreventMouseDown = this.preventMouseDown.bind(this);
connectedCallback() {
this.input = this.querySelector('[role="spinbutton"]');
this.incrementBtn = this.querySelector('.apg-spinbutton-increment');
this.decrementBtn = this.querySelector('.apg-spinbutton-decrement');
this.liveRegion = this.querySelector('.apg-spinbutton-live');
if (this.input) {
this.previousValidValue = this.currentValue;
this.input.addEventListener('keydown', this.boundHandleKeyDown);
this.input.addEventListener('input', this.boundHandleInput);
this.input.addEventListener('blur', this.boundHandleBlur);
this.input.addEventListener('compositionstart', this.boundHandleCompositionStart);
this.input.addEventListener('compositionend', this.boundHandleCompositionEnd);
}
if (this.incrementBtn) {
this.incrementBtn.addEventListener('mousedown', this.boundPreventMouseDown);
this.incrementBtn.addEventListener('click', this.boundHandleIncrement);
}
if (this.decrementBtn) {
this.decrementBtn.addEventListener('mousedown', this.boundPreventMouseDown);
this.decrementBtn.addEventListener('click', this.boundHandleDecrement);
}
}
disconnectedCallback() {
clearTimeout(this.announceTimer);
if (this.input) {
this.input.removeEventListener('keydown', this.boundHandleKeyDown);
this.input.removeEventListener('input', this.boundHandleInput);
this.input.removeEventListener('blur', this.boundHandleBlur);
this.input.removeEventListener('compositionstart', this.boundHandleCompositionStart);
this.input.removeEventListener('compositionend', this.boundHandleCompositionEnd);
}
if (this.incrementBtn) {
this.incrementBtn.removeEventListener('mousedown', this.boundPreventMouseDown);
this.incrementBtn.removeEventListener('click', this.boundHandleIncrement);
}
if (this.decrementBtn) {
this.decrementBtn.removeEventListener('mousedown', this.boundPreventMouseDown);
this.decrementBtn.removeEventListener('click', this.boundHandleDecrement);
}
}
private preventMouseDown(event: MouseEvent) {
event.preventDefault();
}
private get min(): number | undefined {
const val = this.dataset.min;
return val !== undefined && val !== '' ? Number(val) : undefined;
}
private get max(): number | undefined {
const val = this.dataset.max;
return val !== undefined && val !== '' ? Number(val) : undefined;
}
private get step(): number {
return Number(this.dataset.step) || 1;
}
private get largeStep(): number {
return Number(this.dataset.largeStep) || this.step * 10;
}
private get isDisabled(): boolean {
return this.dataset.disabled === 'true';
}
private get isReadOnly(): boolean {
return this.dataset.readonly === 'true';
}
private get format(): string | undefined {
return this.dataset.format;
}
private formatValue(value: number): string {
const fmt = this.format;
if (!fmt) return String(value);
return fmt
.replace('{value}', String(value))
.replace('{min}', this.min !== undefined ? String(this.min) : '')
.replace('{max}', this.max !== undefined ? String(this.max) : '');
}
private get currentValue(): number {
return Number(this.input?.getAttribute('aria-valuenow')) || 0;
}
private clamp(val: number): number {
let result = val;
if (this.min !== undefined) result = Math.max(this.min, result);
if (this.max !== undefined) result = Math.min(this.max, result);
return result;
}
private roundToStep(val: number): number {
const base = this.min ?? 0;
const steps = Math.round((val - base) / this.step);
const result = base + steps * this.step;
const decimalPlaces = (this.step.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
}
private updateValue(newValue: number, updateInput = true) {
if (!this.input || this.isDisabled) return;
const clampedValue = this.clamp(this.roundToStep(newValue));
const currentValue = this.currentValue;
if (clampedValue === currentValue) return;
// Update ARIA
this.input.setAttribute('aria-valuenow', String(clampedValue));
// Update aria-valuetext if format is provided
if (this.format) {
this.input.setAttribute('aria-valuetext', this.formatValue(clampedValue));
}
// Update input value
if (updateInput) {
this.input.value = String(clampedValue);
}
this.previousValidValue = clampedValue;
// Dispatch event
this.dispatchEvent(
new CustomEvent('valuechange', {
detail: { value: clampedValue },
bubbles: true,
})
);
}
private handleKeyDown(event: KeyboardEvent) {
if (this.isDisabled) return;
let newValue = this.currentValue;
let handled = false;
switch (event.key) {
case 'ArrowUp':
if (!this.isReadOnly) {
newValue = this.currentValue + this.step;
handled = true;
}
break;
case 'ArrowDown':
if (!this.isReadOnly) {
newValue = this.currentValue - this.step;
handled = true;
}
break;
case 'Home':
if (this.min !== undefined) {
newValue = this.min;
handled = true;
}
break;
case 'End':
if (this.max !== undefined) {
newValue = this.max;
handled = true;
}
break;
case 'PageUp':
if (!this.isReadOnly) {
newValue = this.currentValue + this.largeStep;
handled = true;
}
break;
case 'PageDown':
if (!this.isReadOnly) {
newValue = this.currentValue - this.largeStep;
handled = true;
}
break;
default:
return;
}
if (handled) {
event.preventDefault();
this.updateValue(newValue);
}
}
private handleInput() {
if (this.isComposing || !this.input) return;
const parsed = parseFloat(this.input.value);
if (!isNaN(parsed)) {
const clampedValue = this.clamp(this.roundToStep(parsed));
if (clampedValue !== this.previousValidValue) {
this.input.setAttribute('aria-valuenow', String(clampedValue));
if (this.format) {
this.input.setAttribute('aria-valuetext', this.formatValue(clampedValue));
}
this.previousValidValue = clampedValue;
this.dispatchEvent(
new CustomEvent('valuechange', {
detail: { value: clampedValue },
bubbles: true,
})
);
}
}
}
private handleBlur() {
if (!this.input) return;
const parsed = parseFloat(this.input.value);
if (isNaN(parsed)) {
// Revert to previous valid value
this.input.value = String(this.previousValidValue);
this.input.setAttribute('aria-valuenow', String(this.previousValidValue));
} else {
const newValue = this.clamp(this.roundToStep(parsed));
this.input.value = String(newValue);
this.input.setAttribute('aria-valuenow', String(newValue));
if (this.format) {
this.input.setAttribute('aria-valuetext', this.formatValue(newValue));
}
if (newValue !== this.previousValidValue) {
this.previousValidValue = newValue;
this.dispatchEvent(
new CustomEvent('valuechange', {
detail: { value: newValue },
bubbles: true,
})
);
}
}
}
private handleCompositionStart() {
this.isComposing = true;
}
private handleCompositionEnd() {
this.isComposing = false;
this.handleInput();
}
// Announce the value after a button click, since the value change itself
// may not be reported. Uses ariaNotify when available, otherwise a live region.
private announce() {
const input: (HTMLElement & { ariaNotify?: (m: string) => void }) | null = this.input;
if (!input) return;
const message = input.getAttribute('aria-valuetext') ?? String(this.currentValue);
if (typeof input.ariaNotify === 'function') {
input.ariaNotify(message);
return;
}
if (!this.liveRegion) return;
this.liveRegion.textContent = message;
clearTimeout(this.announceTimer);
this.announceTimer = setTimeout(() => {
if (this.liveRegion) this.liveRegion.textContent = '';
}, 1000);
}
private handleIncrement(event: MouseEvent) {
event.preventDefault();
if (this.isDisabled || this.isReadOnly) return;
this.updateValue(this.currentValue + this.step);
this.announce();
this.input?.focus();
}
private handleDecrement(event: MouseEvent) {
event.preventDefault();
if (this.isDisabled || this.isReadOnly) return;
this.updateValue(this.currentValue - this.step);
this.announce();
this.input?.focus();
}
// Public method to update value programmatically
setValue(newValue: number) {
this.updateValue(newValue);
}
}
if (!customElements.get('apg-spinbutton')) {
customElements.define('apg-spinbutton', ApgSpinbutton);
}
</script>Usage
---
import Spinbutton from './Spinbutton.astro';
---
<!-- 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"
/>
<!-- Listen to value changes (Web Component event) -->
<Spinbutton id="my-spinbutton" defaultValue={5} label="Value" />
<script>
const spinbutton = document.querySelector('#my-spinbutton');
spinbutton?.addEventListener('valuechange', (e) => {
console.log('Value:', e.detail.value);
});
</script>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}") |
label, aria-label, or aria-labelledby is required for accessibility. This component uses Web Components for client-side interactivity without requiring hydration.Custom Events
| Event | Detail | Description |
|---|---|---|
valuechange | {value: number} | Dispatched when value changes |
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 |
Value announcement | Button click calls ariaNotify() with the new value text when supported, otherwise writes it to the live region and clears it after 1 second |
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.
/**
* Spinbutton Astro Component Tests using Container API
*
* These tests verify the initial server-rendered HTML of Spinbutton.astro.
* Web Component behavior (keyboard, buttons, text input, IME composition,
* value announcement and the valuechange event) is covered by E2E tests
* in e2e/spinbutton.spec.ts.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Spinbutton from './Spinbutton.astro';
describe('Spinbutton (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
async function renderSpinbutton(props: Record<string, unknown> = {}): Promise<Document> {
const html = await container.renderToString(Spinbutton, { props });
return new JSDOM(html).window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-spinbutton custom element wrapper', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
expect(doc.querySelector('apg-spinbutton')).not.toBeNull();
});
it('renders a text input with role="spinbutton"', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('input[role="spinbutton"]');
expect(input?.getAttribute('type')).toBe('text');
});
it('has inputmode="numeric" for mobile keyboards', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('inputmode')).toBe('numeric');
});
it('renders the initial value in the input', async () => {
const doc = await renderSpinbutton({ defaultValue: 5, 'aria-label': 'Quantity' });
const input = doc.querySelector<HTMLInputElement>('[role="spinbutton"]');
expect(input?.value).toBe('5');
});
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has aria-valuenow="0" when no defaultValue is given', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuenow')).toBe('0');
});
it('has aria-valuenow set to defaultValue', async () => {
const doc = await renderSpinbutton({ defaultValue: 5, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuenow')).toBe('5');
});
it('has aria-valuemin when min is provided', async () => {
const doc = await renderSpinbutton({ min: 0, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuemin')).toBe('0');
});
it('does not have aria-valuemin when min is not provided', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.hasAttribute('aria-valuemin')).toBe(false);
});
it('has aria-valuemax when max is provided', async () => {
const doc = await renderSpinbutton({ max: 100, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuemax')).toBe('100');
});
it('does not have aria-valuemax when max is not provided', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.hasAttribute('aria-valuemax')).toBe(false);
});
it('has aria-valuetext when valueText is provided', async () => {
const doc = await renderSpinbutton({ valueText: '5 items', 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuetext')).toBe('5 items');
});
it('has aria-valuetext built from format with value, min and max', async () => {
const doc = await renderSpinbutton({
defaultValue: 3,
min: 1,
max: 10,
format: '{value} of {max}',
'aria-label': 'Rating',
});
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuetext')).toBe('3 of 10');
});
it('does not have aria-valuetext when neither valueText nor format is provided', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.hasAttribute('aria-valuetext')).toBe(false);
});
it('has aria-disabled="true" when disabled', async () => {
const doc = await renderSpinbutton({ disabled: true, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-disabled')).toBe('true');
});
it('does not have aria-disabled when not disabled', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.hasAttribute('aria-disabled')).toBe(false);
});
it('has aria-readonly="true" and the readonly attribute when read-only', async () => {
const doc = await renderSpinbutton({ readOnly: true, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-readonly')).toBe('true');
expect(input?.hasAttribute('readonly')).toBe(true);
});
it('does not have aria-readonly when not read-only', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.hasAttribute('aria-readonly')).toBe(false);
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has aria-label when provided', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-label')).toBe('Quantity');
});
it('has aria-labelledby when provided', async () => {
const doc = await renderSpinbutton({ 'aria-labelledby': 'external-label' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-labelledby')).toBe('external-label');
});
it('references the visible label via aria-labelledby when label is provided', async () => {
const doc = await renderSpinbutton({ label: 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
const labelId = input?.getAttribute('aria-labelledby') ?? '';
expect(doc.getElementById(labelId)?.textContent?.trim()).toBe('Quantity');
});
it('does not set aria-label when a visible label is provided', async () => {
const doc = await renderSpinbutton({ label: 'Quantity', 'aria-label': 'Ignored' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.hasAttribute('aria-label')).toBe(false);
});
it('has aria-describedby when provided', async () => {
const doc = await renderSpinbutton({
'aria-label': 'Quantity',
'aria-describedby': 'quantity-help',
});
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-describedby')).toBe('quantity-help');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on the input', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" on the input when disabled', async () => {
const doc = await renderSpinbutton({ disabled: true, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('tabindex')).toBe('-1');
});
it('has tabindex="-1" on the increment and decrement buttons', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
expect(doc.querySelector('.apg-spinbutton-increment')?.getAttribute('tabindex')).toBe('-1');
expect(doc.querySelector('.apg-spinbutton-decrement')?.getAttribute('tabindex')).toBe('-1');
});
});
// 🟡 Medium Priority: Buttons
describe('Buttons', () => {
it('renders increment and decrement buttons with accessible labels by default', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
expect(doc.querySelector('.apg-spinbutton-increment')?.getAttribute('aria-label')).toBe(
'Increment'
);
expect(doc.querySelector('.apg-spinbutton-decrement')?.getAttribute('aria-label')).toBe(
'Decrement'
);
});
it('does not render buttons when showButtons is false', async () => {
const doc = await renderSpinbutton({ showButtons: false, 'aria-label': 'Quantity' });
expect(doc.querySelector('.apg-spinbutton-increment')).toBeNull();
expect(doc.querySelector('.apg-spinbutton-decrement')).toBeNull();
});
it('renders disabled buttons when disabled', async () => {
const doc = await renderSpinbutton({ disabled: true, 'aria-label': 'Quantity' });
expect(doc.querySelector('.apg-spinbutton-increment')?.hasAttribute('disabled')).toBe(true);
expect(doc.querySelector('.apg-spinbutton-decrement')?.hasAttribute('disabled')).toBe(true);
});
});
// 🔴 High Priority: Live Region (fallback for ariaNotify)
describe('Live Region', () => {
it('renders an empty role="status" live region when buttons are shown', async () => {
const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
const liveRegion = doc.querySelector('[role="status"]');
expect(liveRegion?.textContent).toBe('');
});
it('does not render the live region when showButtons is false', async () => {
const doc = await renderSpinbutton({ showButtons: false, 'aria-label': 'Quantity' });
expect(doc.querySelector('[role="status"]')).toBeNull();
});
});
// 🟡 Medium Priority: Initial Value Normalization
describe('Initial Value Normalization', () => {
it('clamps defaultValue above max to max', async () => {
const doc = await renderSpinbutton({ defaultValue: 150, max: 100, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuenow')).toBe('100');
});
it('clamps defaultValue below min to min', async () => {
const doc = await renderSpinbutton({ defaultValue: -10, min: 0, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuenow')).toBe('0');
});
it('rounds defaultValue to the nearest step', async () => {
const doc = await renderSpinbutton({ defaultValue: 7, step: 5, 'aria-label': 'Quantity' });
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuenow')).toBe('5');
});
it('rounds a decimal defaultValue without floating-point error', async () => {
const doc = await renderSpinbutton({
defaultValue: 0.1 + 0.2,
step: 0.1,
'aria-label': 'Opacity',
});
const input = doc.querySelector('[role="spinbutton"]');
expect(input?.getAttribute('aria-valuenow')).toBe('0.3');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attributes', () => {
it('sets the id attribute on the input', async () => {
const doc = await renderSpinbutton({ id: 'quantity', 'aria-label': 'Quantity' });
expect(doc.querySelector('[role="spinbutton"]')?.id).toBe('quantity');
});
it('applies a custom class to the container', async () => {
const doc = await renderSpinbutton({ class: 'custom-class', 'aria-label': 'Quantity' });
expect(doc.querySelector('.apg-spinbutton')?.classList.contains('custom-class')).toBe(true);
});
it('sets data-testid on the input', async () => {
const doc = await renderSpinbutton({ 'data-testid': 'qty', 'aria-label': 'Quantity' });
expect(doc.querySelector('[role="spinbutton"]')?.getAttribute('data-testid')).toBe('qty');
});
});
});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