スピンボタン
増減ボタン、矢印キー、または直接入力を使用して、離散的なセットまたは範囲から値を選択できる入力ウィジェット。
デモ
ネイティブ HTML
ネイティブ HTML を優先
このカスタムコンポーネントを使用する前に、ネイティブの <input type="number"> 要素の使用を検討してください。ネイティブ要素は組み込みのセマンティクスを提供し、JavaScript なしで動作し、ネイティブのブラウザバリデーションを備えています。
<label for="quantity">数量</label>
<input type="number" id="quantity" value="1" min="0" max="100" step="1">カスタム実装は、ネイティブ要素では提供できないカスタムスタイリングが必要な場合、またはネイティブ入力では利用できない特定のインタラクションパターンが必要な場合にのみ使用してください。
| ユースケース | ネイティブ HTML | カスタム実装 |
|---|---|---|
| 基本的な数値入力 | 推奨 | 不要 |
| JavaScript 無効時のサポート | ネイティブで動作 | フォールバックが必要 |
| 組み込みバリデーション | ネイティブサポート | 手動実装が必要 |
| カスタムボタンスタイリング | 制限あり(ブラウザ依存) | 完全に制御可能 |
| クロスブラウザで一貫した外観 | ブラウザにより異なる | 一貫性あり |
| カスタムステップ/大ステップの動作 | 基本的なステップのみ | PageUp/PageDown サポート |
| 最小/最大値制限なし | 属性の省略が必要 | 明示的な undefined サポート |
ネイティブの <input type="number"> 要素は、組み込みのブラウザバリデーション、フォーム送信サポート、アクセシブルなセマンティクスを提供します。ただし、その外観とスピナーボタンのスタイリングはブラウザ間で大きく異なるため、視覚的な一貫性が求められる場合はカスタム実装が望ましいです。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
spinbutton |
入力要素 | ユーザーがインクリメント/デクリメントまたは直接入力によって、離散的なセットまたは範囲から値を選択できるスピンボタンとして要素を識別します。 |
status |
視覚的に非表示のライブリージョン | 増加/減少ボタンをクリックしたときに新しい値を読み上げさせます。Element.ariaNotify() に対応していないブラウザでのみ使う代替手段です。ボタンを表示しているときだけ描画します。 |
WAI-ARIA プロパティ
aria-valuenow
値が変更されたとき(キーボード、ボタンクリック、またはテキスト入力)、即座に更新する必要があります
- 値
- 数値(現在の値)
- 必須
- はい
aria-valuemin
最小値が定義されている場合のみ設定します。最小制限が存在しない場合は、属性を完全に省略してください。
- 値
- 数値
- 必須
- いいえ
aria-valuemax
最大値が定義されている場合のみ設定します。最大制限が存在しない場合は、属性を完全に省略してください。
- 値
- 数値
- 必須
- いいえ
aria-valuetext
現在の値に対する人間が読めるテキストの代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。
- 値
- 文字列(例:
5 items,3 of 10) - 必須
- いいえ
aria-disabled
スピンボタンが無効化されており、インタラクティブでないことを示します。
- 値
- true | false
- 必須
- いいえ
aria-readonly
スピンボタンが読み取り専用であることを示します。ユーザーはHome/Endキーでナビゲーションできますが、値を変更することはできません。
- 値
- true | false
- 必須
- いいえ
aria-label
スピンボタンに不可視のラベルを提供します
- 値
- 文字列
- 必須
- 条件付き(表示されるラベルがない場合は必須)
aria-labelledby
外部要素をラベルとして参照します
- 値
- ID参照
- 必須
- 条件付き(表示されるラベルが存在する場合は必須)
キーボードサポート
| キー | アクション |
|---|---|
| ArrowUp | 値を1ステップ増やします |
| ArrowDown | 値を1ステップ減らします |
| Home | 値を最小値に設定します(最小値が定義されている場合のみ) |
| End | 値を最大値に設定します(最大値が定義されている場合のみ) |
| Page Up | 値を大きなステップで増やします(デフォルト: step × 10) |
| Page Down | 値を大きなステップで減らします(デフォルト: step × 10) |
- spinbuttonロールは、ユーザーがインクリメント/デクリメントボタン、矢印キー、または直接入力によって数値を選択できる入力コントロールに使用されます。テキスト入力と値の上下調整機能を組み合わせたものです。
- スライダーパターンとは異なり、スピンボタンは上下矢印キーのみを使用します(左右矢印キーは使用しません)。これにより、ユーザーはテキスト入力を使用して直接数値を入力できます。
- スピンボタンにはアクセシブルな名前が必要です。これはlabelプロパティを使用した表示されるラベル、見えないラベルのためのaria-label、または外部要素を参照するaria-labelledbyによって提供できます。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| 入力要素 | tabindex="0" |
| 無効化された入力 | tabindex="-1" |
| 増加/減少ボタン | tabindex="-1"(タブ順序に含まれない) |
| ボタンクリック | フォーカスはスピンボタンに留まります(ボタンには移動しません) |
ボタンクリック時の値の読み上げ
増加/減少ボタンをクリックすると、新しい値(aria-valuetext があればその文言、なければ数値)を次のどちらかの方法で読み上げさせます。
- ブラウザが
Element.ariaNotify()(opens in new tab) に対応していれば、スピンボタン上で呼び出します。対応しているかを実行時に確かめてから使うプログレッシブエンハンスメントです。 - 対応していなければ、視覚的に非表示の
role="status"ライブリージョンに値を書き込みます。古い内容が文書に残らないよう、1秒後に空にします。
キーボードでの変更はフォーカス中のスピンボタン自身が値の変化を伝えるため、この方法では読み上げません。
ビジュアルデザイン
- フォーカスインジケーター - コントロールコンテナ全体(ボタンを含む)に可視のフォーカスリングを表示
- ボタンの状態 - ホバーおよびアクティブ状態での視覚的フィードバック
- 無効化状態 - スピンボタンが無効化されているときの明確な視覚的表示
- 読み取り専用状態 - 読み取り専用モードの明確な視覚的スタイル
- 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
import { useEffect, 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): number => {
const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
if (clampedValue !== value) {
setValue(clampedValue);
setInputValue(String(clampedValue));
onValueChange?.(clampedValue);
}
return clampedValue;
};
// Announce the value after a button click, since the value change itself
// may not be reported. Uses ariaNotify when available, otherwise a live region.
const [announcement, setAnnouncement] = useState('');
const announceTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(announceTimerRef.current), []);
const announce = (newValue: number) => {
const message =
valueText ?? (format ? formatValueText(format, newValue, min, max) : String(newValue));
const input: (HTMLElement & { ariaNotify?: (m: string) => void }) | null = inputRef.current;
if (typeof input?.ariaNotify === 'function') {
input.ariaNotify(message);
return;
}
setAnnouncement(message);
clearTimeout(announceTimerRef.current);
announceTimerRef.current = setTimeout(() => setAnnouncement(''), 1000);
};
// 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;
announce(updateValue(value + step));
inputRef.current?.focus();
};
const handleDecrement = (event: React.MouseEvent) => {
event.preventDefault();
if (disabled || readOnly) return;
announce(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>
{showButtons && (
<span role="status" className="apg-spinbutton-live">
{announcement}
</span>
)}
</div>
);
}使い方
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
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
defaultValue | number | 0 | スピンボタンの初期値 |
min | number | undefined | 最小値(undefined = 制限なし) |
max | number | undefined | 最大値(undefined = 制限なし) |
step | number | 1 | キーボード/ボタンの増減単位 |
largeStep | number | step * 10 | PageUp/PageDown の大きな増減単位 |
disabled | boolean | false | スピンボタンを無効化するかどうか |
readOnly | boolean | false | スピンボタンを読み取り専用にするかどうか |
showButtons | boolean | true | 増減ボタンを表示するかどうか |
label | string | - | 表示ラベル(aria-labelledby としても使用) |
valueText | string | - | aria-valuetext 用の人間が読める値 |
format | string | - | aria-valuetext のフォーマットパターン(例:"{value} of {max}") |
onValueChange | (value: number) => void | - | 値が変更されたときのコールバック |
label、aria-label、または aria-labelledby のいずれかが必須です。テスト
APG準拠のARIA属性、キーボード操作、テキスト入力処理、およびアクセシビリティ要件を検証するテストです。
テストカテゴリ
高優先度: ARIA属性
| テスト | 説明 |
|---|---|
role="spinbutton" | 要素がspinbuttonロールを持つ |
aria-valuenow | 現在の値が正しく設定され、更新される |
aria-valuemin | 最小値が定義されている場合のみ設定される |
aria-valuemax | 最大値が定義されている場合のみ設定される |
aria-valuetext | 人間が読めるテキストが提供された場合に設定される |
aria-disabled | 無効状態が設定された場合に反映される |
aria-readonly | 読み取り専用状態が設定された場合に反映される |
高優先度: アクセシブル名
| テスト | 説明 |
|---|---|
aria-label | aria-label属性によるアクセシブル名 |
aria-labelledby | 外部要素参照によるアクセシブル名 |
visible label | 視覚的なラベルがアクセシブル名を提供 |
高優先度: キーボード操作
| テスト | 説明 |
|---|---|
Arrow Up | 値を1ステップ増加させる |
Arrow Down | 値を1ステップ減少させる |
Home | 最小値に設定(最小値が定義されている場合のみ) |
End | 最大値に設定(最大値が定義されている場合のみ) |
Page Up/Down | 大きなステップで値を増加/減少させる |
Boundary clamping | 値が最小値/最大値の範囲を超えない |
Disabled state | 無効状態の場合、キーボード操作が無効になる |
Read-only state | 矢印キーはブロック、Home/Endは許可 |
高優先度: ボタン操作
| テスト | 説明 |
|---|---|
Increment click | 増加ボタンのクリックで値が増加する |
Decrement click | 減少ボタンのクリックで値が減少する |
Button labels | ボタンにアクセシブルなラベルがある |
Disabled/read-only | 無効または読み取り専用の場合、ボタンがブロックされる |
Value announcement | ボタンのクリックで、ariaNotify() 対応時は新しい値の文言で呼び出し、非対応時はライブリージョンに書き込んで1秒後に空にする |
高優先度: フォーカス管理
| テスト | 説明 |
|---|---|
tabindex="0" | 入力欄がフォーカス可能である |
tabindex="-1" | 無効状態の場合、入力欄がフォーカス不可になる |
Button tabindex | ボタンがtabindex="-1"を持つ(タブ順序に含まれない) |
中優先度: テキスト入力
| テスト | 説明 |
|---|---|
inputmode="numeric" | モバイルで数値キーボードを使用 |
Valid input | 有効なテキスト入力時にaria-valuenowが更新される |
Invalid input | 無効な入力でフォーカスを失った際に前の値に戻る |
Clamp on blur | フォーカスを失った際にステップと最小値/最大値に正規化される |
中優先度: IME変換
| テスト | 説明 |
|---|---|
During composition | IME変換中は値が更新されない |
On composition end | 変換完了時に値が更新される |
中優先度: エッジケース
| テスト | 説明 |
|---|---|
decimal values | 小数ステップ値を正しく処理する |
no min/max | 最小値/最大値がない場合、無制限の値を許可 |
clamp to min | 最小値を下回るdefaultValueが最小値にクランプされる |
clamp to max | 最大値を上回るdefaultValueが最大値にクランプされる |
中優先度: コールバック
| テスト | 説明 |
|---|---|
onValueChange | 値の変更時に新しい値でコールバックが呼ばれる |
低優先度: HTML属性の継承
| テスト | 説明 |
|---|---|
className | カスタムクラスがコンテナに適用される |
id | ID属性が正しく設定される |
data-* | データ属性が継承される |
テストツール
- Playwright(opens in new tab) - E2Eテスト(178件のクロスフレームワークテスト)
- Testing Library(opens in new tab) - フレームワーク固有のテストユーティリティ(React, Vue, Svelte)
- axe-core(opens in new tab) - 自動アクセシビリティテスト
詳細はテスト戦略ガイドを参照してください。
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { afterEach, 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');
});
});
// 🔴 High Priority: Value announcement on button click
describe('Button Click Announcement', () => {
afterEach(() => {
vi.useRealTimers();
// @ts-expect-error -- ariaNotify is not in lib.dom yet
delete HTMLElement.prototype.ariaNotify;
});
it('announces the new value via ariaNotify on increment click when ariaNotify is supported', async () => {
const ariaNotify = vi.fn();
// @ts-expect-error -- ariaNotify is not in lib.dom yet
HTMLElement.prototype.ariaNotify = ariaNotify;
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
await user.click(screen.getByLabelText(/increment/i));
expect(ariaNotify).toHaveBeenCalledWith('6');
expect(screen.getByRole('status')).toHaveTextContent('');
});
it('announces the formatted value text on decrement click when format is set', async () => {
const ariaNotify = vi.fn();
// @ts-expect-error -- ariaNotify is not in lib.dom yet
HTMLElement.prototype.ariaNotify = ariaNotify;
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} format="{value} items" aria-label="Quantity" />);
await user.click(screen.getByLabelText(/decrement/i));
expect(ariaNotify).toHaveBeenCalledWith('4 items');
});
it('announces the max value again on increment click at max', async () => {
const ariaNotify = vi.fn();
// @ts-expect-error -- ariaNotify is not in lib.dom yet
HTMLElement.prototype.ariaNotify = ariaNotify;
const user = userEvent.setup();
render(<Spinbutton defaultValue={10} max={10} aria-label="Quantity" />);
await user.click(screen.getByLabelText(/increment/i));
expect(ariaNotify).toHaveBeenCalledWith('10');
});
it('does not call ariaNotify on ArrowUp key', async () => {
const ariaNotify = vi.fn();
// @ts-expect-error -- ariaNotify is not in lib.dom yet
HTMLElement.prototype.ariaNotify = ariaNotify;
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
await user.click(screen.getByRole('spinbutton'));
await user.keyboard('{ArrowUp}');
expect(ariaNotify).not.toHaveBeenCalled();
});
it('writes the new value to the live region on increment click when ariaNotify is unsupported', async () => {
const user = userEvent.setup();
render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
await user.click(screen.getByLabelText(/increment/i));
expect(screen.getByRole('status')).toHaveTextContent('6');
});
it('clears the live region 1 second after the announcement', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
await user.click(screen.getByLabelText(/increment/i));
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByRole('status')).toHaveTextContent('');
});
it('does not render the live region when showButtons is false', () => {
render(<Spinbutton aria-label="Quantity" showButtons={false} />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
// 🟡 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');
});
});
});リソース
- WAI-ARIA APG: Spinbutton パターン(opens in new tab)
- MDN: <input type="number"> 要素(opens in new tab)
- AI Implementation Guide (llm.md)(opens in new tab) - ARIA specs, keyboard support, test checklist