Spinbutton
増減ボタン、矢印キー、または直接入力を使用して、離散的なセットまたは範囲から値を選択できる入力ウィジェット。
デモ
Native 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 | 入力要素 | ユーザーがインクリメント/デクリメントまたは直接入力によって、離散的なセットまたは範囲から値を選択できるスピンボタンとして要素を識別します。 |
spinbuttonロールは、ユーザーがインクリメント/デクリメントボタン、矢印キー、または直接入力によって数値を選択できる入力コントロールに使用されます。テキスト入力と値の上下調整機能を組み合わせたものです。
WAI-ARIA ステートとプロパティ
aria-valuenow (必須)
値が変更されたとき(キーボード、ボタンクリック、またはテキスト入力)、即座に更新する必要があります
| 型 | 数値(現在の値) |
| 必須 | はい |
| 更新 | 値が変更されたとき(キーボード、ボタンクリック、またはテキスト入力)、即座に更新する必要があります |
aria-valuemin
最小値が定義されている場合のみ設定します。最小制限が存在しない場合は、属性を完全に省略してください。
| 型 | 数値 |
| 必須 | いいえ |
| 注意 | 最小値が定義されている場合のみ設定します。最小制限が存在しない場合は、属性を完全に省略してください。 |
aria-valuemax
最大値が定義されている場合のみ設定します。最大制限が存在しない場合は、属性を完全に省略してください。
| 型 | 数値 |
| 必須 | いいえ |
| 注意 | 最大値が定義されている場合のみ設定します。最大制限が存在しない場合は、属性を完全に省略してください。 |
aria-valuetext
現在の値に対する人間が読めるテキストの代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。
| 型 | 文字列(例: "5 items", "3 of 10") |
| 必須 | いいえ |
| 例 | "5 items", "3 of 10", "Tuesday" |
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) |
注意: スライダーパターンとは異なり、スピンボタンは上下矢印キーのみを使用します(左右矢印キーは使用しません)。これにより、ユーザーはテキスト入力を使用して直接数値を入力できます。
アクセシブルな名前
スピンボタンにはアクセシブルな名前が必要です。これは以下の方法で提供できます:
- 可視ラベル -
labelプロップを使用して可視ラベルを表示 -
aria-label- スピンボタンに不可視のラベルを提供 -
aria-labelledby- 外部要素をラベルとして参照
テキスト入力
この実装では直接テキスト入力をサポートしています:
- 数値キーボード - 最適なモバイル体験のために
inputmode="numeric"を使用 - リアルタイムバリデーション - 各入力時に値をクランプし、ステップに丸めます
- 無効な入力の処理 - 入力が無効な場合、フォーカスを外したときに以前の有効な値に戻します
- IMEサポート - 変換が完了するまで待ってから値を更新します
ビジュアルデザイン
この実装はアクセシブルなビジュアルデザインのためのWCAGガイドラインに従っています:
- フォーカスインジケーター - コントロールコンテナ全体(ボタンを含む)に可視のフォーカスリングを表示
- ボタンの状態 - ホバーおよびアクティブ状態での視覚的フィードバック
- 無効化状態 - スピンボタンが無効化されているときの明確な視覚的表示
- 読み取り専用状態 - 読み取り専用モードの明確な視覚的スタイル
- 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
<template>
<div :class="cn('apg-spinbutton', disabled && 'apg-spinbutton--disabled', $attrs.class)">
<span v-if="label" :id="labelId" class="apg-spinbutton-label">
{{ label }}
</span>
<div class="apg-spinbutton-controls">
<button
v-if="showButtons"
type="button"
:tabindex="-1"
aria-label="Decrement"
:disabled="disabled"
class="apg-spinbutton-button apg-spinbutton-decrement"
@mousedown.prevent
@click="handleDecrement"
>
−
</button>
<input
ref="inputRef"
type="text"
role="spinbutton"
:id="$attrs.id as string | undefined"
:tabindex="disabled ? -1 : 0"
inputmode="numeric"
:value="inputValue"
:readonly="readOnly"
:aria-valuenow="value"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuetext="ariaValueText"
:aria-label="label ? undefined : ($attrs['aria-label'] as string | undefined)"
:aria-labelledby="ariaLabelledby"
:aria-describedby="$attrs['aria-describedby'] as string | undefined"
:aria-disabled="disabled || undefined"
:aria-readonly="readOnly || undefined"
:aria-invalid="$attrs['aria-invalid'] as boolean | undefined"
:data-testid="$attrs['data-testid'] as string | undefined"
class="apg-spinbutton-input"
@input="handleInput"
@keydown="handleKeyDown"
@blur="handleBlur"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
<button
v-if="showButtons"
type="button"
:tabindex="-1"
aria-label="Increment"
:disabled="disabled"
class="apg-spinbutton-button apg-spinbutton-increment"
@mousedown.prevent
@click="handleIncrement"
>
+
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { cn } from '@/lib/utils';
defineOptions({
inheritAttrs: false,
});
export interface SpinbuttonProps {
/** 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;
}
const props = withDefaults(defineProps<SpinbuttonProps>(), {
defaultValue: 0,
min: undefined,
max: undefined,
step: 1,
largeStep: undefined,
disabled: false,
readOnly: false,
showButtons: true,
label: undefined,
valueText: undefined,
format: undefined,
});
const emit = defineEmits<{
valueChange: [value: number];
}>();
// 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;
};
// Ensure step is valid (positive number)
const ensureValidStep = (stepVal: number): number => {
return stepVal > 0 ? stepVal : 1;
};
const roundToStep = (val: number, stepVal: number, minVal?: number): number => {
const validStep = ensureValidStep(stepVal);
const base = minVal ?? 0;
const steps = Math.round((val - base) / validStep);
const result = base + steps * validStep;
const decimalPlaces = (validStep.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
};
// Format value helper
const formatValueText = (val: number, formatStr: string | undefined): string => {
if (!formatStr) return String(val);
return formatStr
.replace('{value}', String(val))
.replace('{min}', props.min !== undefined ? String(props.min) : '')
.replace('{max}', props.max !== undefined ? String(props.max) : '');
};
// Refs
const inputRef = ref<HTMLInputElement | null>(null);
// Use crypto.randomUUID() for unique IDs in Astro Islands (Vue's useId() returns same value for all instances)
const labelId = `spinbutton-label-${crypto.randomUUID().slice(0, 8)}`;
const isComposing = ref(false);
// State
const initialValue = clamp(
roundToStep(props.defaultValue, props.step, props.min),
props.min,
props.max
);
const value = ref(initialValue);
const inputValue = ref(String(initialValue));
// Computed
const effectiveLargeStep = computed(() => props.largeStep ?? props.step * 10);
const ariaValueText = computed(() => {
if (props.valueText) return props.valueText;
if (props.format) return formatValueText(value.value, props.format);
return undefined;
});
const ariaLabelledby = computed(() => {
const attrLabelledby = (
getCurrentInstance()?.attrs as { 'aria-labelledby'?: string } | undefined
)?.['aria-labelledby'];
if (attrLabelledby) return attrLabelledby;
if (props.label) return labelId;
return undefined;
});
// Helper to get current instance for attrs
import { getCurrentInstance } from 'vue';
// Update value and emit
const updateValue = (newValue: number) => {
const clampedValue = clamp(roundToStep(newValue, props.step, props.min), props.min, props.max);
if (clampedValue !== value.value) {
value.value = clampedValue;
inputValue.value = String(clampedValue);
emit('valueChange', clampedValue);
}
};
// Keyboard handler
const handleKeyDown = (event: KeyboardEvent) => {
if (props.disabled) return;
let newValue = value.value;
let handled = false;
switch (event.key) {
case 'ArrowUp':
if (!props.readOnly) {
newValue = value.value + props.step;
handled = true;
}
break;
case 'ArrowDown':
if (!props.readOnly) {
newValue = value.value - props.step;
handled = true;
}
break;
case 'Home':
if (props.min !== undefined) {
newValue = props.min;
handled = true;
}
break;
case 'End':
if (props.max !== undefined) {
newValue = props.max;
handled = true;
}
break;
case 'PageUp':
if (!props.readOnly) {
newValue = value.value + effectiveLargeStep.value;
handled = true;
}
break;
case 'PageDown':
if (!props.readOnly) {
newValue = value.value - effectiveLargeStep.value;
handled = true;
}
break;
default:
return;
}
if (handled) {
event.preventDefault();
updateValue(newValue);
}
};
// Text input handler
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement;
inputValue.value = target.value;
if (!isComposing.value) {
const parsed = parseFloat(target.value);
if (!isNaN(parsed)) {
const clampedValue = clamp(roundToStep(parsed, props.step, props.min), props.min, props.max);
if (clampedValue !== value.value) {
value.value = clampedValue;
emit('valueChange', clampedValue);
}
}
}
};
// Blur handler
const handleBlur = () => {
const parsed = parseFloat(inputValue.value);
if (isNaN(parsed)) {
// Revert to previous valid value
inputValue.value = String(value.value);
} else {
const newValue = clamp(roundToStep(parsed, props.step, props.min), props.min, props.max);
if (newValue !== value.value) {
value.value = newValue;
emit('valueChange', newValue);
}
inputValue.value = String(newValue);
}
};
// IME composition handlers
const handleCompositionStart = () => {
isComposing.value = true;
};
const handleCompositionEnd = () => {
isComposing.value = false;
const parsed = parseFloat(inputValue.value);
if (!isNaN(parsed)) {
const clampedValue = clamp(roundToStep(parsed, props.step, props.min), props.min, props.max);
value.value = clampedValue;
emit('valueChange', clampedValue);
}
};
// Button handlers
const handleIncrement = (event: MouseEvent) => {
event.preventDefault();
if (props.disabled || props.readOnly) return;
updateValue(value.value + props.step);
inputRef.value?.focus();
};
const handleDecrement = (event: MouseEvent) => {
event.preventDefault();
if (props.disabled || props.readOnly) return;
updateValue(value.value - props.step);
inputRef.value?.focus();
};
</script> 使い方
<script setup>
import Spinbutton from './Spinbutton.vue';
function handleChange(value) {
console.log(value);
}
</script>
<template>
<!-- Basic usage with aria-label -->
<Spinbutton aria-label="Quantity" />
<!-- With visible label and min/max -->
<Spinbutton
:default-value="5"
:min="0"
:max="100"
label="Quantity"
/>
<!-- With format for display and aria-valuetext -->
<Spinbutton
:default-value="3"
:min="1"
:max="10"
label="Rating"
format="{value} of {max}"
/>
<!-- Decimal step values -->
<Spinbutton
:default-value="0.5"
:min="0"
:max="1"
:step="0.1"
label="Opacity"
/>
<!-- Unbounded (no min/max limits) -->
<Spinbutton
:default-value="0"
label="Counter"
/>
<!-- With callback -->
<Spinbutton
:default-value="5"
:min="0"
:max="100"
label="Value"
@valuechange="handleChange"
/>
</template> 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}") |
イベント
| イベント | ペイロード | 説明 |
|---|---|---|
@valuechange | number | 値が変更されたときに発行される |
アクセシビリティのため、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 | 無効または読み取り専用の場合、ボタンがブロックされる |
高優先度 : フォーカス管理
| テスト | 説明 |
|---|---|
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 { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Spinbutton from './Spinbutton.vue';
describe('Spinbutton (Vue)', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="spinbutton"', () => {
render(Spinbutton, {
attrs: { 'aria-label': 'Quantity' },
});
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
});
it('has aria-valuenow set to current value', () => {
render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { '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, {
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-valuemin when min is defined', () => {
render(Spinbutton, {
props: { min: 0 },
attrs: { '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, {
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuemin');
});
it('has aria-valuemax when max is defined', () => {
render(Spinbutton, {
props: { max: 100 },
attrs: { '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, {
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuemax');
});
it('has aria-valuetext when valueText provided', () => {
render(Spinbutton, {
props: { defaultValue: 5, valueText: '5 items' },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuetext', '5 items');
});
it('does not have aria-valuetext when not provided', () => {
render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-valuetext');
});
it('uses format for aria-valuetext', () => {
render(Spinbutton, {
props: { defaultValue: 5, format: '{value} items' },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuetext', '5 items');
});
it('has aria-disabled="true" when disabled', () => {
render(Spinbutton, {
props: { disabled: true },
attrs: { '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, {
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).not.toHaveAttribute('aria-disabled');
});
it('has aria-readonly="true" when readOnly', () => {
render(Spinbutton, {
props: { readOnly: true },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-readonly', 'true');
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(Spinbutton, {
attrs: { 'aria-label': 'Quantity' },
});
expect(screen.getByRole('spinbutton', { name: 'Quantity' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(Spinbutton, {
attrs: {
'aria-labelledby': 'spinbutton-label',
},
global: {
stubs: {
teleport: true,
},
},
});
// Note: aria-labelledby test requires the label element in the DOM
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-labelledby', 'spinbutton-label');
});
it('has accessible name via visible label', () => {
render(Spinbutton, {
props: { label: 'Quantity' },
});
expect(screen.getByRole('spinbutton', { name: 'Quantity' })).toBeInTheDocument();
});
});
// 🔴 High Priority: Keyboard Interaction
describe('Keyboard Interaction', () => {
it('increases value by step on ArrowUp', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, step: 1 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
});
it('decreases value by step on ArrowDown', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, step: 1 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowDown}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '4');
});
it('sets min value on Home when min is defined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, min: 0 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{Home}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('Home key has no effect when min is undefined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{Home}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '50');
});
it('sets max value on End when max is defined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, max: 100 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{End}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
});
it('End key has no effect when max is undefined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{End}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '50');
});
it('increases value by large step on PageUp', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, step: 1 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{PageUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '60');
});
it('decreases value by large step on PageDown', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 50, step: 1 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{PageDown}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '40');
});
it('does not exceed max on ArrowUp', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 100, max: 100 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
});
it('does not go below min on ArrowDown', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 0, min: 0 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowDown}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
});
it('does not change value when disabled', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, disabled: true },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
spinbutton.focus();
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
});
it('does not change value on ArrowUp/Down when readOnly', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, readOnly: true },
attrs: { '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, {
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(Spinbutton, {
props: { disabled: true },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('tabindex', '-1');
});
it('buttons have tabindex="-1"', () => {
render(Spinbutton, {
props: { showButtons: true },
attrs: { 'aria-label': 'Quantity' },
});
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveAttribute('tabindex', '-1');
});
});
it('focus stays on spinbutton after increment button click', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(spinbutton);
await user.click(incrementButton);
expect(spinbutton).toHaveFocus();
});
});
// 🟡 Medium Priority: Button Interaction
describe('Button Interaction', () => {
it('increases value on increment button click', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(incrementButton);
expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
});
it('decreases value on decrement button click', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
const decrementButton = screen.getByLabelText(/decrement/i);
await user.click(decrementButton);
expect(spinbutton).toHaveAttribute('aria-valuenow', '4');
});
it('hides buttons when showButtons is false', () => {
render(Spinbutton, {
props: { showButtons: false },
attrs: { 'aria-label': 'Quantity' },
});
expect(screen.queryByLabelText(/increment/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/decrement/i)).not.toBeInTheDocument();
});
});
// 🟡 Medium Priority: Text Input
describe('Text Input', () => {
it('accepts direct text input', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '42');
await user.tab();
expect(spinbutton).toHaveAttribute('aria-valuenow', '42');
});
it('reverts to previous value on invalid input', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, 'abc');
await user.tab();
expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
});
it('clamps value to max on valid input exceeding max', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 5, max: 10 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.clear(spinbutton);
await user.type(spinbutton, '999');
await user.tab();
expect(spinbutton).toHaveAttribute('aria-valuenow', '10');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(Spinbutton, {
attrs: { 'aria-label': 'Quantity' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with visible label', async () => {
const { container } = render(Spinbutton, {
props: { label: 'Quantity' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(Spinbutton, {
props: { disabled: true },
attrs: { 'aria-label': 'Quantity' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('emits valueChange on keyboard interaction', async () => {
const user = userEvent.setup();
const { emitted } = render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(emitted().valueChange).toBeTruthy();
expect(emitted().valueChange[0]).toEqual([6]);
});
it('emits valueChange on button click', async () => {
const user = userEvent.setup();
const { emitted } = render(Spinbutton, {
props: { defaultValue: 5 },
attrs: { 'aria-label': 'Quantity' },
});
const incrementButton = screen.getByLabelText(/increment/i);
await user.click(incrementButton);
expect(emitted().valueChange).toBeTruthy();
expect(emitted().valueChange[0]).toEqual([6]);
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('handles decimal step values correctly', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 0.5, step: 0.1 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '0.6');
});
it('handles negative values', () => {
render(Spinbutton, {
props: { defaultValue: -5 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('aria-valuenow', '-5');
});
it('allows value beyond range when min/max undefined', async () => {
const user = userEvent.setup();
render(Spinbutton, {
props: { defaultValue: 1000 },
attrs: { 'aria-label': 'Quantity' },
});
const spinbutton = screen.getByRole('spinbutton');
await user.click(spinbutton);
await user.keyboard('{ArrowUp}');
expect(spinbutton).toHaveAttribute('aria-valuenow', '1001');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('displays visible label when label provided', () => {
render(Spinbutton, {
props: { label: 'Quantity' },
});
expect(screen.getByText('Quantity')).toBeInTheDocument();
});
it('has inputmode="numeric"', () => {
render(Spinbutton, {
attrs: { '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, {
attrs: {
'aria-label': 'Quantity',
class: 'custom-spinbutton',
},
});
const container = screen.getByRole('spinbutton').closest('.apg-spinbutton');
expect(container).toHaveClass('custom-spinbutton');
});
it('sets id attribute on spinbutton element', () => {
render(Spinbutton, {
attrs: {
'aria-label': 'Quantity',
id: 'my-spinbutton',
},
});
const spinbutton = screen.getByRole('spinbutton');
expect(spinbutton).toHaveAttribute('id', 'my-spinbutton');
});
it('passes through data-testid', () => {
render(Spinbutton, {
attrs: {
'aria-label': 'Quantity',
'data-testid': 'custom-spinbutton',
},
});
expect(screen.getByTestId('custom-spinbutton')).toBeInTheDocument();
});
});
}); リソース
- 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