APG Patterns
English
English

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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用

参考資料

ソースコード

Spinbutton.vue
<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>

使い方

Example
<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 値が変更されたときに発行される

アクセシビリティのため、labelaria-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-* データ属性が継承される

テストツール

Spinbutton.test.vue.ts
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();
    });
  });
});

リソース