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.tsx
import { useId, useRef, useState } from 'react';
import { cn } from '@/lib/utils';

// Label: one of these required (exclusive)
type LabelProps =
  | { label: string; 'aria-label'?: never; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label': string; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label'?: never; 'aria-labelledby': string };

// ValueText: exclusive with format
type ValueTextProps =
  | { valueText: string; format?: never }
  | { valueText?: never; format?: string }
  | { valueText?: never; format?: never };

type SpinbuttonBaseProps = {
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  largeStep?: number;
  disabled?: boolean;
  readOnly?: boolean;
  showButtons?: boolean;
  onValueChange?: (value: number) => void;
  className?: string;
  id?: string;
  'aria-describedby'?: string;
  'aria-invalid'?: boolean;
  'data-testid'?: string;
};

export type SpinbuttonProps = SpinbuttonBaseProps & LabelProps & ValueTextProps;

// Clamp value to range (only if min/max defined)
const clamp = (value: number, min?: number, max?: number): number => {
  let result = value;
  if (min !== undefined) result = Math.max(min, result);
  if (max !== undefined) result = Math.min(max, result);
  return result;
};

// Ensure step is valid (positive number)
const ensureValidStep = (step: number): number => {
  return step > 0 ? step : 1;
};

// Round to step with floating-point precision handling
const roundToStep = (value: number, step: number, min?: number): number => {
  const validStep = ensureValidStep(step);
  const base = min ?? 0;
  const steps = Math.round((value - base) / validStep);
  const result = base + steps * validStep;
  // Handle floating-point precision (e.g., 0.1 + 0.2 = 0.30000000000000004)
  const decimals = (validStep.toString().split('.')[1] || '').length;
  return Number(result.toFixed(decimals));
};

// Format value text
const formatValueText = (format: string, value: number, min?: number, max?: number): string => {
  return format
    .replace('{value}', String(value))
    .replace('{min}', min !== undefined ? String(min) : '')
    .replace('{max}', max !== undefined ? String(max) : '');
};

export function Spinbutton(props: SpinbuttonProps) {
  const {
    defaultValue = 0,
    min,
    max,
    step = 1,
    largeStep,
    disabled = false,
    readOnly = false,
    showButtons = true,
    onValueChange,
    className,
    id,
    'aria-describedby': ariaDescribedby,
    'aria-invalid': ariaInvalid,
    'data-testid': dataTestId,
    ...labelProps
  } = props;

  const generatedId = useId();
  const labelId = `${generatedId}-label`;
  const inputRef = useRef<HTMLInputElement>(null);

  // Get label-related props
  const label = 'label' in labelProps ? labelProps.label : undefined;
  const ariaLabel = 'aria-label' in labelProps ? labelProps['aria-label'] : undefined;
  const ariaLabelledby =
    'aria-labelledby' in labelProps ? labelProps['aria-labelledby'] : undefined;

  // Get valueText-related props
  const valueText = 'valueText' in props ? props.valueText : undefined;
  const format = 'format' in props ? props.format : undefined;

  // Initialize value with clamping and rounding
  const initialValue = clamp(roundToStep(defaultValue, step, min), min, max);
  const [value, setValue] = useState(initialValue);
  const [inputValue, setInputValue] = useState(String(initialValue));
  const [isComposing, setIsComposing] = useState(false);

  const effectiveLargeStep = largeStep ?? step * 10;

  // Compute aria-valuetext
  const getAriaValueText = (): string | undefined => {
    if (valueText) return valueText;
    if (format) return formatValueText(format, value, min, max);
    return undefined;
  };
  const computedValueText = getAriaValueText();

  // Update value and call callback
  const updateValue = (newValue: number) => {
    const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
    if (clampedValue !== value) {
      setValue(clampedValue);
      setInputValue(String(clampedValue));
      onValueChange?.(clampedValue);
    }
  };

  // Handle keyboard events
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (disabled) return;

    let newValue = value;
    let handled = false;

    switch (event.key) {
      case 'ArrowUp':
        if (!readOnly) {
          newValue = value + step;
          handled = true;
        }
        break;
      case 'ArrowDown':
        if (!readOnly) {
          newValue = value - step;
          handled = true;
        }
        break;
      case 'Home':
        if (min !== undefined) {
          newValue = min;
          handled = true;
        }
        break;
      case 'End':
        if (max !== undefined) {
          newValue = max;
          handled = true;
        }
        break;
      case 'PageUp':
        if (!readOnly) {
          newValue = value + effectiveLargeStep;
          handled = true;
        }
        break;
      case 'PageDown':
        if (!readOnly) {
          newValue = value - effectiveLargeStep;
          handled = true;
        }
        break;
      default:
        return;
    }

    if (handled) {
      event.preventDefault();
      updateValue(newValue);
    }
  };

  // Handle text input change
  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const newInputValue = event.target.value;
    setInputValue(newInputValue);

    if (!isComposing) {
      const parsed = parseFloat(newInputValue);
      if (!isNaN(parsed)) {
        const clampedValue = clamp(roundToStep(parsed, step, min), min, max);
        if (clampedValue !== value) {
          setValue(clampedValue);
          onValueChange?.(clampedValue);
        }
      }
    }
  };

  // Handle blur - validate and finalize input
  const handleBlur = () => {
    const parsed = parseFloat(inputValue);

    if (isNaN(parsed)) {
      // Revert to previous valid value
      setInputValue(String(value));
    } else {
      // Clamp and round the value
      const newValue = clamp(roundToStep(parsed, step, min), min, max);
      if (newValue !== value) {
        setValue(newValue);
        onValueChange?.(newValue);
      }
      setInputValue(String(newValue));
    }
  };

  // IME composition handlers
  const handleCompositionStart = () => setIsComposing(true);
  const handleCompositionEnd = () => {
    setIsComposing(false);
    // Validate and update after composition ends
    const parsed = parseFloat(inputValue);
    if (!isNaN(parsed)) {
      const clampedValue = clamp(roundToStep(parsed, step, min), min, max);
      setValue(clampedValue);
      onValueChange?.(clampedValue);
    }
  };

  // Button handlers
  const handleIncrement = (event: React.MouseEvent) => {
    event.preventDefault();
    if (disabled || readOnly) return;
    updateValue(value + step);
    inputRef.current?.focus();
  };

  const handleDecrement = (event: React.MouseEvent) => {
    event.preventDefault();
    if (disabled || readOnly) return;
    updateValue(value - step);
    inputRef.current?.focus();
  };

  return (
    <div className={cn('apg-spinbutton', disabled && 'apg-spinbutton--disabled', className)}>
      {label && (
        <span className="apg-spinbutton-label" id={labelId}>
          {label}
        </span>
      )}
      <div className="apg-spinbutton-controls">
        {showButtons && (
          <button
            type="button"
            tabIndex={-1}
            aria-label="Decrement"
            onMouseDown={(e) => e.preventDefault()}
            onClick={handleDecrement}
            disabled={disabled}
            className="apg-spinbutton-button apg-spinbutton-decrement"
          >

          </button>
        )}
        <input
          ref={inputRef}
          type="text"
          role="spinbutton"
          id={id}
          tabIndex={disabled ? -1 : 0}
          inputMode="numeric"
          value={inputValue}
          readOnly={readOnly}
          aria-valuenow={value}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuetext={computedValueText}
          aria-label={ariaLabel}
          aria-labelledby={label ? labelId : ariaLabelledby}
          aria-describedby={ariaDescribedby}
          aria-disabled={disabled || undefined}
          aria-readonly={readOnly || undefined}
          aria-invalid={ariaInvalid}
          data-testid={dataTestId}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          onBlur={handleBlur}
          onCompositionStart={handleCompositionStart}
          onCompositionEnd={handleCompositionEnd}
          className="apg-spinbutton-input"
        />
        {showButtons && (
          <button
            type="button"
            tabIndex={-1}
            aria-label="Increment"
            onMouseDown={(e) => e.preventDefault()}
            onClick={handleIncrement}
            disabled={disabled}
            className="apg-spinbutton-button apg-spinbutton-increment"
          >
            +
          </button>
        )}
      </div>
    </div>
  );
}

使い方

Example
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 - 値が変更されたときのコールバック

アクセシビリティのため、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.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Spinbutton } from './Spinbutton';

describe('Spinbutton', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="spinbutton"', () => {
      render(<Spinbutton aria-label="Quantity" />);
      expect(screen.getByRole('spinbutton')).toBeInTheDocument();
    });

    it('has aria-valuenow set to current value', () => {
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
    });

    it('has aria-valuenow set to 0 when no defaultValue', () => {
      render(<Spinbutton aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
    });

    it('has aria-valuemin when min is defined', () => {
      render(<Spinbutton min={0} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuemin', '0');
    });

    it('does not have aria-valuemin when min is undefined', () => {
      render(<Spinbutton aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).not.toHaveAttribute('aria-valuemin');
    });

    it('has aria-valuemax when max is defined', () => {
      render(<Spinbutton max={100} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuemax', '100');
    });

    it('does not have aria-valuemax when max is undefined', () => {
      render(<Spinbutton aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).not.toHaveAttribute('aria-valuemax');
    });

    it('has aria-valuetext when valueText provided', () => {
      render(<Spinbutton defaultValue={5} valueText="5 items" aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuetext', '5 items');
    });

    it('does not have aria-valuetext when not provided', () => {
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).not.toHaveAttribute('aria-valuetext');
    });

    it('uses format for aria-valuetext', () => {
      render(<Spinbutton defaultValue={5} format="{value} items" aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuetext', '5 items');
    });

    it('updates aria-valuetext on value change', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} format="{value} items" aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuetext', '6 items');
    });

    it('has aria-disabled="true" when disabled', () => {
      render(<Spinbutton disabled aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-disabled', 'true');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(<Spinbutton aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).not.toHaveAttribute('aria-disabled');
    });

    it('has aria-readonly="true" when readOnly', () => {
      render(<Spinbutton readOnly aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-readonly', 'true');
    });

    it('does not have aria-readonly when not readOnly', () => {
      render(<Spinbutton aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).not.toHaveAttribute('aria-readonly');
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(<Spinbutton aria-label="Quantity" />);
      expect(screen.getByRole('spinbutton', { name: 'Quantity' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <span id="spinbutton-label">Item Count</span>
          <Spinbutton aria-labelledby="spinbutton-label" />
        </>
      );
      expect(screen.getByRole('spinbutton', { name: 'Item Count' })).toBeInTheDocument();
    });

    it('has accessible name via visible label', () => {
      render(<Spinbutton label="Quantity" />);
      expect(screen.getByRole('spinbutton', { name: 'Quantity' })).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('Keyboard Interaction', () => {
    it('increases value by step on ArrowUp', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} step={1} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
    });

    it('decreases value by step on ArrowDown', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} step={1} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowDown}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '4');
    });

    it('sets min value on Home when min is defined', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} min={0} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{Home}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
    });

    it('Home key has no effect when min is undefined', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{Home}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '50');
    });

    it('sets max value on End when max is defined', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} max={100} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{End}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
    });

    it('End key has no effect when max is undefined', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{End}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '50');
    });

    it('increases value by large step on PageUp', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} step={1} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{PageUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '60'); // default largeStep = step * 10
    });

    it('decreases value by large step on PageDown', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} step={1} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{PageDown}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '40');
    });

    it('uses custom largeStep when provided', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} step={1} largeStep={20} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{PageUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '70');
    });

    it('respects custom step value', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={50} step={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '55');
    });

    it('does not exceed max on ArrowUp', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={100} max={100} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
    });

    it('does not go below min on ArrowDown', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={0} min={0} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowDown}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
    });

    it('does not change value when disabled', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} disabled aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      spinbutton.focus();
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
    });

    it('does not change value on ArrowUp/Down when readOnly', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} readOnly aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on input', () => {
      render(<Spinbutton aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('tabindex', '0');
    });

    it('has tabindex="-1" when disabled', () => {
      render(<Spinbutton disabled aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('tabindex', '-1');
    });

    it('is focusable via Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Spinbutton aria-label="Quantity" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus spinbutton

      expect(screen.getByRole('spinbutton')).toHaveFocus();
    });

    it('is not focusable via Tab when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Spinbutton disabled aria-label="Quantity" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus "After" button (skip spinbutton)

      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });

    it('buttons have tabindex="-1"', () => {
      render(<Spinbutton aria-label="Quantity" showButtons />);
      const buttons = screen.getAllByRole('button');
      buttons.forEach((button) => {
        expect(button).toHaveAttribute('tabindex', '-1');
      });
    });

    it('focus stays on spinbutton after increment button click', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      const incrementButton = screen.getByLabelText(/increment/i);

      await user.click(spinbutton);
      await user.click(incrementButton);

      expect(spinbutton).toHaveFocus();
    });

    it('focus stays on spinbutton after decrement button click', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      const decrementButton = screen.getByLabelText(/decrement/i);

      await user.click(spinbutton);
      await user.click(decrementButton);

      expect(spinbutton).toHaveFocus();
    });
  });

  // 🟡 Medium Priority: Button Interaction
  describe('Button Interaction', () => {
    it('increases value on increment button click', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      const incrementButton = screen.getByLabelText(/increment/i);

      await user.click(incrementButton);

      expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
    });

    it('decreases value on decrement button click', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      const decrementButton = screen.getByLabelText(/decrement/i);

      await user.click(decrementButton);

      expect(spinbutton).toHaveAttribute('aria-valuenow', '4');
    });

    it('increment button does not exceed max', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={100} max={100} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      const incrementButton = screen.getByLabelText(/increment/i);

      await user.click(incrementButton);

      expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
    });

    it('decrement button does not go below min', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={0} min={0} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      const decrementButton = screen.getByLabelText(/decrement/i);

      await user.click(decrementButton);

      expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
    });

    it('buttons are disabled when component is disabled', async () => {
      render(<Spinbutton defaultValue={5} disabled aria-label="Quantity" />);
      const incrementButton = screen.getByLabelText(/increment/i);
      const decrementButton = screen.getByLabelText(/decrement/i);

      expect(incrementButton).toBeDisabled();
      expect(decrementButton).toBeDisabled();
    });

    it('hides buttons when showButtons is false', () => {
      render(<Spinbutton aria-label="Quantity" showButtons={false} />);
      expect(screen.queryByLabelText(/increment/i)).not.toBeInTheDocument();
      expect(screen.queryByLabelText(/decrement/i)).not.toBeInTheDocument();
    });

    it('keyboard still works when showButtons is false', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} aria-label="Quantity" showButtons={false} />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '6');
    });
  });

  // 🟡 Medium Priority: Text Input
  describe('Text Input', () => {
    it('accepts direct text input', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.clear(spinbutton);
      await user.type(spinbutton, '42');
      await user.tab(); // blur to confirm

      expect(spinbutton).toHaveAttribute('aria-valuenow', '42');
    });

    it('reverts to previous value on invalid input', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.clear(spinbutton);
      await user.type(spinbutton, 'abc');
      await user.tab(); // blur to confirm

      expect(spinbutton).toHaveAttribute('aria-valuenow', '5');
    });

    it('clamps value to max on valid input exceeding max', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} max={10} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.clear(spinbutton);
      await user.type(spinbutton, '999');
      await user.tab(); // blur to confirm

      expect(spinbutton).toHaveAttribute('aria-valuenow', '10');
    });

    it('clamps value to min on valid input below min', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} min={0} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.clear(spinbutton);
      await user.type(spinbutton, '-10');
      await user.tab(); // blur to confirm

      expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
    });

    it('does not allow text input when readOnly', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} readOnly aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.type(spinbutton, '42');

      expect(spinbutton).toHaveValue('5');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<Spinbutton aria-label="Quantity" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with visible label', async () => {
      const { container } = render(<Spinbutton label="Quantity" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <span id="label">Quantity</span>
          <Spinbutton aria-labelledby="label" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(<Spinbutton disabled aria-label="Quantity" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when readOnly', async () => {
      const { container } = render(<Spinbutton readOnly aria-label="Quantity" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with valueText', async () => {
      const { container } = render(
        <Spinbutton defaultValue={5} valueText="5 items" aria-label="Quantity" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onValueChange on keyboard interaction', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} onValueChange={handleChange} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(handleChange).toHaveBeenCalledWith(6);
    });

    it('calls onValueChange on button click', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} onValueChange={handleChange} aria-label="Quantity" />);
      const incrementButton = screen.getByLabelText(/increment/i);

      await user.click(incrementButton);

      expect(handleChange).toHaveBeenCalledWith(6);
    });

    it('calls onValueChange on text input', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={5} onValueChange={handleChange} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.clear(spinbutton);
      await user.type(spinbutton, '42');
      await user.tab();

      expect(handleChange).toHaveBeenCalledWith(42);
    });

    it('does not call onValueChange when disabled', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Spinbutton defaultValue={5} disabled onValueChange={handleChange} aria-label="Quantity" />
      );
      const spinbutton = screen.getByRole('spinbutton');

      spinbutton.focus();
      await user.keyboard('{ArrowUp}');

      expect(handleChange).not.toHaveBeenCalled();
    });

    it('does not call onValueChange when value does not change', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Spinbutton
          defaultValue={100}
          max={100}
          onValueChange={handleChange}
          aria-label="Quantity"
        />
      );
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal step values correctly', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={0.5} step={0.1} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '0.6');
    });

    it('handles negative values', () => {
      render(<Spinbutton defaultValue={-5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuenow', '-5');
    });

    it('handles negative min/max range', () => {
      render(<Spinbutton defaultValue={0} min={-50} max={50} aria-label="Temperature" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
      expect(spinbutton).toHaveAttribute('aria-valuemin', '-50');
      expect(spinbutton).toHaveAttribute('aria-valuemax', '50');
    });

    it('clamps defaultValue to max when exceeding', () => {
      render(<Spinbutton defaultValue={150} max={100} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuenow', '100');
    });

    it('clamps defaultValue to min when below', () => {
      render(<Spinbutton defaultValue={-10} min={0} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuenow', '0');
    });

    it('rounds value to step', () => {
      render(<Spinbutton defaultValue={53} step={5} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-valuenow', '55');
    });

    it('allows value beyond range when min/max undefined', async () => {
      const user = userEvent.setup();
      render(<Spinbutton defaultValue={1000} aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');

      await user.click(spinbutton);
      await user.keyboard('{ArrowUp}');

      expect(spinbutton).toHaveAttribute('aria-valuenow', '1001');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('displays visible label when label provided', () => {
      render(<Spinbutton label="Quantity" />);
      expect(screen.getByText('Quantity')).toBeInTheDocument();
    });

    it('has inputmode="numeric"', () => {
      render(<Spinbutton aria-label="Quantity" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('inputmode', 'numeric');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      render(<Spinbutton aria-label="Quantity" className="custom-spinbutton" />);
      const container = screen.getByRole('spinbutton').closest('.apg-spinbutton');
      expect(container).toHaveClass('custom-spinbutton');
    });

    it('sets id attribute on spinbutton element', () => {
      render(<Spinbutton aria-label="Quantity" id="my-spinbutton" />);
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('id', 'my-spinbutton');
    });

    it('passes through data-* attributes', () => {
      render(<Spinbutton aria-label="Quantity" data-testid="custom-spinbutton" />);
      expect(screen.getByTestId('custom-spinbutton')).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render(
        <>
          <Spinbutton aria-label="Quantity" aria-describedby="desc" />
          <p id="desc">Enter the number of items</p>
        </>
      );
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-describedby', 'desc');
    });
  });
});

リソース