APG Patterns
English
English

Spinbutton

インクリメント/デクリメントボタン、矢印キー、または直接入力を使用して、離散的なセットまたは範囲から値を選択できる入力ウィジェット。

デモ

Quantity
Rating
Opacity
Unbounded
Read-only
Disabled

ネイティブ 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.astro
---
/**
 * APG Spinbutton Pattern - Astro Implementation
 *
 * A control that allows users to select a value from a discrete set or range.
 * Uses Web Components for interactive behavior.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/
 */

export interface Props {
  /** Default value */
  defaultValue?: number;
  /** Minimum value (undefined = no limit) */
  min?: number;
  /** Maximum value (undefined = no limit) */
  max?: number;
  /** Step increment (default: 1) */
  step?: number;
  /** Large step for PageUp/PageDown */
  largeStep?: number;
  /** Whether spinbutton is disabled */
  disabled?: boolean;
  /** Whether spinbutton is read-only */
  readOnly?: boolean;
  /** Show increment/decrement buttons (default: true) */
  showButtons?: boolean;
  /** Visible label text */
  label?: string;
  /** Human-readable value text for aria-valuetext */
  valueText?: string;
  /** Format pattern for dynamic value display (e.g., "{value} items") */
  format?: string;
  /** Spinbutton id */
  id?: string;
  /** Additional CSS class */
  class?: string;
  /** Accessible label when no visible label */
  'aria-label'?: string;
  /** Reference to external label element */
  'aria-labelledby'?: string;
  /** Reference to description element */
  'aria-describedby'?: string;
  /** Whether the input value is invalid */
  'aria-invalid'?: boolean;
  /** Test ID */
  'data-testid'?: string;
}

const {
  defaultValue = 0,
  min,
  max,
  step = 1,
  largeStep,
  disabled = false,
  readOnly = false,
  showButtons = true,
  label,
  valueText,
  format,
  id,
  class: className = '',
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'aria-describedby': ariaDescribedby,
  'aria-invalid': ariaInvalid,
  'data-testid': dataTestid,
} = Astro.props;

// Utility functions
const clamp = (val: number, minVal?: number, maxVal?: number): number => {
  let result = val;
  if (minVal !== undefined) result = Math.max(minVal, result);
  if (maxVal !== undefined) result = Math.min(maxVal, result);
  return result;
};

const roundToStep = (val: number, stepVal: number, minVal?: number): number => {
  const base = minVal ?? 0;
  const steps = Math.round((val - base) / stepVal);
  const result = base + steps * stepVal;
  const decimalPlaces = (stepVal.toString().split('.')[1] || '').length;
  return Number(result.toFixed(decimalPlaces));
};

// Calculate initial value
const initialValue = clamp(roundToStep(defaultValue, step, min), min, max);

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

// Initial aria-valuetext
const getInitialAriaValueText = (): string | undefined => {
  if (valueText) return valueText;
  if (format) return formatValueText(initialValue, format);
  return undefined;
};
const initialAriaValueText = getInitialAriaValueText();

// Generate unique label ID
const labelId = label ? `spinbutton-label-${Math.random().toString(36).slice(2, 9)}` : undefined;

const effectiveLargeStep = largeStep ?? step * 10;
---

<apg-spinbutton
  data-min={min}
  data-max={max}
  data-step={step}
  data-large-step={effectiveLargeStep}
  data-disabled={disabled}
  data-readonly={readOnly}
  data-format={format}
>
  <div class={`apg-spinbutton ${disabled ? 'apg-spinbutton--disabled' : ''} ${className}`.trim()}>
    {
      label && (
        <span id={labelId} class="apg-spinbutton-label">
          {label}
        </span>
      )
    }
    <div class="apg-spinbutton-controls">
      {
        showButtons && (
          <button
            type="button"
            tabindex={-1}
            aria-label="Decrement"
            disabled={disabled}
            class="apg-spinbutton-button apg-spinbutton-decrement"
          >

          </button>
        )
      }
      <input
        type="text"
        role="spinbutton"
        id={id}
        tabindex={disabled ? -1 : 0}
        inputmode="numeric"
        value={String(initialValue)}
        readonly={readOnly}
        aria-valuenow={initialValue}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuetext={initialAriaValueText}
        aria-label={label ? undefined : ariaLabel}
        aria-labelledby={ariaLabelledby ?? labelId}
        aria-describedby={ariaDescribedby}
        aria-disabled={disabled || undefined}
        aria-readonly={readOnly || undefined}
        aria-invalid={ariaInvalid || undefined}
        data-testid={dataTestid}
        class="apg-spinbutton-input"
      />
      {
        showButtons && (
          <button
            type="button"
            tabindex={-1}
            aria-label="Increment"
            disabled={disabled}
            class="apg-spinbutton-button apg-spinbutton-increment"
          >
            +
          </button>
        )
      }
    </div>
  </div>
</apg-spinbutton>

<script>
  class ApgSpinbutton extends HTMLElement {
    private input: HTMLInputElement | null = null;
    private incrementBtn: HTMLButtonElement | null = null;
    private decrementBtn: HTMLButtonElement | null = null;
    private isComposing = false;
    private previousValidValue = 0;

    // Bound handler references (to properly remove listeners)
    private boundHandleKeyDown = this.handleKeyDown.bind(this);
    private boundHandleInput = this.handleInput.bind(this);
    private boundHandleBlur = this.handleBlur.bind(this);
    private boundHandleCompositionStart = this.handleCompositionStart.bind(this);
    private boundHandleCompositionEnd = this.handleCompositionEnd.bind(this);
    private boundHandleIncrement = this.handleIncrement.bind(this);
    private boundHandleDecrement = this.handleDecrement.bind(this);
    private boundPreventMouseDown = this.preventMouseDown.bind(this);

    connectedCallback() {
      this.input = this.querySelector('[role="spinbutton"]');
      this.incrementBtn = this.querySelector('.apg-spinbutton-increment');
      this.decrementBtn = this.querySelector('.apg-spinbutton-decrement');

      if (this.input) {
        this.previousValidValue = this.currentValue;
        this.input.addEventListener('keydown', this.boundHandleKeyDown);
        this.input.addEventListener('input', this.boundHandleInput);
        this.input.addEventListener('blur', this.boundHandleBlur);
        this.input.addEventListener('compositionstart', this.boundHandleCompositionStart);
        this.input.addEventListener('compositionend', this.boundHandleCompositionEnd);
      }

      if (this.incrementBtn) {
        this.incrementBtn.addEventListener('mousedown', this.boundPreventMouseDown);
        this.incrementBtn.addEventListener('click', this.boundHandleIncrement);
      }

      if (this.decrementBtn) {
        this.decrementBtn.addEventListener('mousedown', this.boundPreventMouseDown);
        this.decrementBtn.addEventListener('click', this.boundHandleDecrement);
      }
    }

    disconnectedCallback() {
      if (this.input) {
        this.input.removeEventListener('keydown', this.boundHandleKeyDown);
        this.input.removeEventListener('input', this.boundHandleInput);
        this.input.removeEventListener('blur', this.boundHandleBlur);
        this.input.removeEventListener('compositionstart', this.boundHandleCompositionStart);
        this.input.removeEventListener('compositionend', this.boundHandleCompositionEnd);
      }

      if (this.incrementBtn) {
        this.incrementBtn.removeEventListener('mousedown', this.boundPreventMouseDown);
        this.incrementBtn.removeEventListener('click', this.boundHandleIncrement);
      }

      if (this.decrementBtn) {
        this.decrementBtn.removeEventListener('mousedown', this.boundPreventMouseDown);
        this.decrementBtn.removeEventListener('click', this.boundHandleDecrement);
      }
    }

    private preventMouseDown(event: MouseEvent) {
      event.preventDefault();
    }

    private get min(): number | undefined {
      const val = this.dataset.min;
      return val !== undefined && val !== '' ? Number(val) : undefined;
    }

    private get max(): number | undefined {
      const val = this.dataset.max;
      return val !== undefined && val !== '' ? Number(val) : undefined;
    }

    private get step(): number {
      return Number(this.dataset.step) || 1;
    }

    private get largeStep(): number {
      return Number(this.dataset.largeStep) || this.step * 10;
    }

    private get isDisabled(): boolean {
      return this.dataset.disabled === 'true';
    }

    private get isReadOnly(): boolean {
      return this.dataset.readonly === 'true';
    }

    private get format(): string | undefined {
      return this.dataset.format;
    }

    private formatValue(value: number): string {
      const fmt = this.format;
      if (!fmt) return String(value);
      return fmt
        .replace('{value}', String(value))
        .replace('{min}', this.min !== undefined ? String(this.min) : '')
        .replace('{max}', this.max !== undefined ? String(this.max) : '');
    }

    private get currentValue(): number {
      return Number(this.input?.getAttribute('aria-valuenow')) || 0;
    }

    private clamp(val: number): number {
      let result = val;
      if (this.min !== undefined) result = Math.max(this.min, result);
      if (this.max !== undefined) result = Math.min(this.max, result);
      return result;
    }

    private roundToStep(val: number): number {
      const base = this.min ?? 0;
      const steps = Math.round((val - base) / this.step);
      const result = base + steps * this.step;
      const decimalPlaces = (this.step.toString().split('.')[1] || '').length;
      return Number(result.toFixed(decimalPlaces));
    }

    private updateValue(newValue: number, updateInput = true) {
      if (!this.input || this.isDisabled) return;

      const clampedValue = this.clamp(this.roundToStep(newValue));
      const currentValue = this.currentValue;

      if (clampedValue === currentValue) return;

      // Update ARIA
      this.input.setAttribute('aria-valuenow', String(clampedValue));

      // Update aria-valuetext if format is provided
      if (this.format) {
        this.input.setAttribute('aria-valuetext', this.formatValue(clampedValue));
      }

      // Update input value
      if (updateInput) {
        this.input.value = String(clampedValue);
      }

      this.previousValidValue = clampedValue;

      // Dispatch event
      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: clampedValue },
          bubbles: true,
        })
      );
    }

    private handleKeyDown(event: KeyboardEvent) {
      if (this.isDisabled) return;

      let newValue = this.currentValue;
      let handled = false;

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

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

    private handleInput() {
      if (this.isComposing || !this.input) return;

      const parsed = parseFloat(this.input.value);
      if (!isNaN(parsed)) {
        const clampedValue = this.clamp(this.roundToStep(parsed));
        if (clampedValue !== this.previousValidValue) {
          this.input.setAttribute('aria-valuenow', String(clampedValue));
          if (this.format) {
            this.input.setAttribute('aria-valuetext', this.formatValue(clampedValue));
          }
          this.previousValidValue = clampedValue;
          this.dispatchEvent(
            new CustomEvent('valuechange', {
              detail: { value: clampedValue },
              bubbles: true,
            })
          );
        }
      }
    }

    private handleBlur() {
      if (!this.input) return;

      const parsed = parseFloat(this.input.value);

      if (isNaN(parsed)) {
        // Revert to previous valid value
        this.input.value = String(this.previousValidValue);
        this.input.setAttribute('aria-valuenow', String(this.previousValidValue));
      } else {
        const newValue = this.clamp(this.roundToStep(parsed));
        this.input.value = String(newValue);
        this.input.setAttribute('aria-valuenow', String(newValue));
        if (this.format) {
          this.input.setAttribute('aria-valuetext', this.formatValue(newValue));
        }
        if (newValue !== this.previousValidValue) {
          this.previousValidValue = newValue;
          this.dispatchEvent(
            new CustomEvent('valuechange', {
              detail: { value: newValue },
              bubbles: true,
            })
          );
        }
      }
    }

    private handleCompositionStart() {
      this.isComposing = true;
    }

    private handleCompositionEnd() {
      this.isComposing = false;
      this.handleInput();
    }

    private handleIncrement(event: MouseEvent) {
      event.preventDefault();
      if (this.isDisabled || this.isReadOnly) return;
      this.updateValue(this.currentValue + this.step);
      this.input?.focus();
    }

    private handleDecrement(event: MouseEvent) {
      event.preventDefault();
      if (this.isDisabled || this.isReadOnly) return;
      this.updateValue(this.currentValue - this.step);
      this.input?.focus();
    }

    // Public method to update value programmatically
    setValue(newValue: number) {
      this.updateValue(newValue);
    }
  }

  if (!customElements.get('apg-spinbutton')) {
    customElements.define('apg-spinbutton', ApgSpinbutton);
  }
</script>

使い方

使用例
---
import Spinbutton from './Spinbutton.astro';
---

<!-- Basic usage with aria-label -->
<Spinbutton aria-label="Quantity" />

<!-- With visible label and min/max -->
<Spinbutton
  defaultValue={5}
  min={0}
  max={100}
  label="Quantity"
/>

<!-- With format for display and aria-valuetext -->
<Spinbutton
  defaultValue={3}
  min={1}
  max={10}
  label="Rating"
  format="{value} of {max}"
/>

<!-- Decimal step values -->
<Spinbutton
  defaultValue={0.5}
  min={0}
  max={1}
  step={0.1}
  label="Opacity"
/>

<!-- Unbounded (no min/max limits) -->
<Spinbutton
  defaultValue={0}
  label="Counter"
/>

<!-- Listen to value changes (Web Component event) -->
<Spinbutton id="my-spinbutton" defaultValue={5} label="Value" />

<script>
  const spinbutton = document.querySelector('#my-spinbutton');
  spinbutton?.addEventListener('valuechange', (e) => {
    console.log('Value:', e.detail.value);
  });
</script>

API

プロパティ デフォルト 説明
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}")

Web Componentイベント

イベント 詳細 説明
valuechange {value: number} 値が変更されたときに発火

アクセシビリティのために、labelaria-label、またはaria-labelledbyのいずれかが必要です。このコンポーネントは、ハイドレーションを必要とせずにクライアント側のインタラクティビティのためにWeb Componentsを使用しています。

テスト

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.astro.ts
/**
 * Spinbutton Web Component Tests
 *
 * Unit tests for the Web Component class.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('Spinbutton (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgSpinbutton extends HTMLElement {
    private input: HTMLInputElement | null = null;
    private incrementBtn: HTMLButtonElement | null = null;
    private decrementBtn: HTMLButtonElement | null = null;
    private isComposing = false;
    private previousValidValue = 0;

    connectedCallback() {
      this.input = this.querySelector('[role="spinbutton"]');
      this.incrementBtn = this.querySelector('.apg-spinbutton-increment');
      this.decrementBtn = this.querySelector('.apg-spinbutton-decrement');

      if (this.input) {
        this.previousValidValue = this.currentValue;
        this.input.addEventListener('keydown', this.handleKeyDown.bind(this));
        this.input.addEventListener('input', this.handleInput.bind(this));
        this.input.addEventListener('blur', this.handleBlur.bind(this));
        this.input.addEventListener('compositionstart', this.handleCompositionStart.bind(this));
        this.input.addEventListener('compositionend', this.handleCompositionEnd.bind(this));
      }

      if (this.incrementBtn) {
        this.incrementBtn.addEventListener('click', this.handleIncrement.bind(this));
      }

      if (this.decrementBtn) {
        this.decrementBtn.addEventListener('click', this.handleDecrement.bind(this));
      }
    }

    private get min(): number | undefined {
      const val = this.dataset.min;
      return val !== undefined && val !== '' ? Number(val) : undefined;
    }

    private get max(): number | undefined {
      const val = this.dataset.max;
      return val !== undefined && val !== '' ? Number(val) : undefined;
    }

    private get step(): number {
      return Number(this.dataset.step) || 1;
    }

    private get largeStep(): number {
      return Number(this.dataset.largeStep) || this.step * 10;
    }

    private get isDisabled(): boolean {
      return this.dataset.disabled === 'true';
    }

    private get isReadOnly(): boolean {
      return this.dataset.readonly === 'true';
    }

    private get format(): string | undefined {
      return this.dataset.format;
    }

    private formatValue(value: number): string {
      const fmt = this.format;
      if (!fmt) return String(value);
      return fmt
        .replace('{value}', String(value))
        .replace('{min}', this.min !== undefined ? String(this.min) : '')
        .replace('{max}', this.max !== undefined ? String(this.max) : '');
    }

    private get currentValue(): number {
      return Number(this.input?.getAttribute('aria-valuenow')) || 0;
    }

    private clamp(val: number): number {
      let result = val;
      if (this.min !== undefined) result = Math.max(this.min, result);
      if (this.max !== undefined) result = Math.min(this.max, result);
      return result;
    }

    private roundToStep(val: number): number {
      const base = this.min ?? 0;
      const steps = Math.round((val - base) / this.step);
      const result = base + steps * this.step;
      const decimalPlaces = (this.step.toString().split('.')[1] || '').length;
      return Number(result.toFixed(decimalPlaces));
    }

    private updateValue(newValue: number, updateInput = true) {
      if (!this.input || this.isDisabled) return;

      const clampedValue = this.clamp(this.roundToStep(newValue));
      const currentValue = this.currentValue;

      if (clampedValue === currentValue) return;

      this.input.setAttribute('aria-valuenow', String(clampedValue));

      if (this.format) {
        this.input.setAttribute('aria-valuetext', this.formatValue(clampedValue));
      }

      if (updateInput) {
        this.input.value = String(clampedValue);
      }

      this.previousValidValue = clampedValue;

      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: clampedValue },
          bubbles: true,
        })
      );
    }

    private handleKeyDown(event: KeyboardEvent) {
      if (this.isDisabled) return;

      let newValue = this.currentValue;
      let handled = false;

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

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

    private handleInput() {
      if (this.isComposing || !this.input) return;

      const parsed = parseFloat(this.input.value);
      if (!isNaN(parsed)) {
        const clampedValue = this.clamp(this.roundToStep(parsed));
        this.input.setAttribute('aria-valuenow', String(clampedValue));
        if (this.format) {
          this.input.setAttribute('aria-valuetext', this.formatValue(clampedValue));
        }
        this.previousValidValue = clampedValue;
        this.dispatchEvent(
          new CustomEvent('valuechange', {
            detail: { value: clampedValue },
            bubbles: true,
          })
        );
      }
    }

    private handleBlur() {
      if (!this.input) return;

      const parsed = parseFloat(this.input.value);

      if (isNaN(parsed)) {
        this.input.value = String(this.previousValidValue);
        this.input.setAttribute('aria-valuenow', String(this.previousValidValue));
      } else {
        const newValue = this.clamp(this.roundToStep(parsed));
        this.input.value = String(newValue);
        this.input.setAttribute('aria-valuenow', String(newValue));
        if (this.format) {
          this.input.setAttribute('aria-valuetext', this.formatValue(newValue));
        }
        if (newValue !== this.previousValidValue) {
          this.previousValidValue = newValue;
          this.dispatchEvent(
            new CustomEvent('valuechange', {
              detail: { value: newValue },
              bubbles: true,
            })
          );
        }
      }
    }

    private handleCompositionStart() {
      this.isComposing = true;
    }

    private handleCompositionEnd() {
      this.isComposing = false;
      this.handleInput();
    }

    private handleIncrement(event: MouseEvent) {
      event.preventDefault();
      if (this.isDisabled || this.isReadOnly) return;
      this.updateValue(this.currentValue + this.step);
      this.input?.focus();
    }

    private handleDecrement(event: MouseEvent) {
      event.preventDefault();
      if (this.isDisabled || this.isReadOnly) return;
      this.updateValue(this.currentValue - this.step);
      this.input?.focus();
    }

    setValue(newValue: number) {
      this.updateValue(newValue);
    }

    // Expose for testing
    get _input() {
      return this.input;
    }
    get _isComposing() {
      return this.isComposing;
    }
  }

  function createSpinbuttonHTML(
    options: {
      defaultValue?: number;
      min?: number;
      max?: number;
      step?: number;
      largeStep?: number;
      disabled?: boolean;
      readOnly?: boolean;
      showButtons?: boolean;
      label?: string;
      valueText?: string;
      format?: string;
      id?: string;
      ariaLabel?: string;
      ariaLabelledby?: string;
      ariaDescribedby?: string;
    } = {}
  ) {
    const {
      defaultValue = 0,
      min,
      max,
      step = 1,
      largeStep,
      disabled = false,
      readOnly = false,
      showButtons = true,
      label,
      valueText,
      format,
      id,
      ariaLabel,
      ariaLabelledby,
      ariaDescribedby,
    } = options;

    // Utility functions
    const clamp = (val: number, minVal?: number, maxVal?: number): number => {
      let result = val;
      if (minVal !== undefined) result = Math.max(minVal, result);
      if (maxVal !== undefined) result = Math.min(maxVal, result);
      return result;
    };

    const roundToStep = (val: number, stepVal: number, minVal?: number): number => {
      const base = minVal ?? 0;
      const steps = Math.round((val - base) / stepVal);
      const result = base + steps * stepVal;
      const decimalPlaces = (stepVal.toString().split('.')[1] || '').length;
      return Number(result.toFixed(decimalPlaces));
    };

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

    const initialValue = clamp(roundToStep(defaultValue, step, min), min, max);
    const effectiveLargeStep = largeStep ?? step * 10;
    const labelId = label
      ? `spinbutton-label-${Math.random().toString(36).slice(2, 9)}`
      : undefined;
    const initialAriaValueText =
      valueText ?? (format ? formatValueText(initialValue, format) : undefined);

    return `
      <apg-spinbutton
        ${min !== undefined ? `data-min="${min}"` : ''}
        ${max !== undefined ? `data-max="${max}"` : ''}
        data-step="${step}"
        data-large-step="${effectiveLargeStep}"
        data-disabled="${disabled}"
        data-readonly="${readOnly}"
        ${format ? `data-format="${format}"` : ''}
      >
        <div class="apg-spinbutton ${disabled ? 'apg-spinbutton--disabled' : ''}">
          ${label ? `<span id="${labelId}" class="apg-spinbutton-label">${label}</span>` : ''}
          <div class="apg-spinbutton-controls">
            ${
              showButtons
                ? `
              <button
                type="button"
                tabindex="-1"
                aria-label="Decrement"
                ${disabled ? 'disabled' : ''}
                class="apg-spinbutton-button apg-spinbutton-decrement"
              >

              </button>
            `
                : ''
            }
            <input
              type="text"
              role="spinbutton"
              ${id ? `id="${id}"` : ''}
              tabindex="${disabled ? -1 : 0}"
              inputmode="numeric"
              value="${initialValue}"
              ${readOnly ? 'readonly' : ''}
              aria-valuenow="${initialValue}"
              ${min !== undefined ? `aria-valuemin="${min}"` : ''}
              ${max !== undefined ? `aria-valuemax="${max}"` : ''}
              ${initialAriaValueText ? `aria-valuetext="${initialAriaValueText}"` : ''}
              ${!label && ariaLabel ? `aria-label="${ariaLabel}"` : ''}
              ${ariaLabelledby ? `aria-labelledby="${ariaLabelledby}"` : label ? `aria-labelledby="${labelId}"` : ''}
              ${ariaDescribedby ? `aria-describedby="${ariaDescribedby}"` : ''}
              ${disabled ? 'aria-disabled="true"' : ''}
              ${readOnly ? 'aria-readonly="true"' : ''}
              class="apg-spinbutton-input"
            />
            ${
              showButtons
                ? `
              <button
                type="button"
                tabindex="-1"
                aria-label="Increment"
                ${disabled ? 'disabled' : ''}
                class="apg-spinbutton-button apg-spinbutton-increment"
              >
                +
              </button>
            `
                : ''
            }
          </div>
        </div>
      </apg-spinbutton>
    `;
  }

  beforeEach(() => {
    if (!customElements.get('apg-spinbutton')) {
      customElements.define('apg-spinbutton', TestApgSpinbutton);
    }

    container = document.createElement('div');
    document.body.appendChild(container);
  });

  afterEach(() => {
    document.body.removeChild(container);
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="spinbutton"', () => {
      container.innerHTML = createSpinbuttonHTML();
      expect(container.querySelector('[role="spinbutton"]')).not.toBeNull();
    });

    it('has aria-valuenow set to current value', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('50');
    });

    it('has aria-valuenow set to 0 when no defaultValue', () => {
      container.innerHTML = createSpinbuttonHTML();
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('has aria-valuemin set when min is provided', () => {
      container.innerHTML = createSpinbuttonHTML({ min: 0 });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuemin')).toBe('0');
    });

    it('does not have aria-valuemin when min is not provided', () => {
      container.innerHTML = createSpinbuttonHTML();
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.hasAttribute('aria-valuemin')).toBe(false);
    });

    it('has aria-valuemax set when max is provided', () => {
      container.innerHTML = createSpinbuttonHTML({ max: 100 });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuemax')).toBe('100');
    });

    it('does not have aria-valuemax when max is not provided', () => {
      container.innerHTML = createSpinbuttonHTML();
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.hasAttribute('aria-valuemax')).toBe(false);
    });

    it('has aria-valuetext when valueText provided', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 5, valueText: '5 items' });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuetext')).toBe('5 items');
    });

    it('has aria-valuetext when format provided', () => {
      container.innerHTML = createSpinbuttonHTML({
        defaultValue: 5,
        min: 0,
        max: 10,
        format: '{value} of {max}',
      });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuetext')).toBe('5 of 10');
    });

    it('does not have aria-valuetext when not provided', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.hasAttribute('aria-valuetext')).toBe(false);
    });

    it('has aria-disabled="true" when disabled', () => {
      container.innerHTML = createSpinbuttonHTML({ disabled: true });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-disabled when not disabled', () => {
      container.innerHTML = createSpinbuttonHTML({ disabled: false });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.hasAttribute('aria-disabled')).toBe(false);
    });

    it('has aria-readonly="true" when read-only', () => {
      container.innerHTML = createSpinbuttonHTML({ readOnly: true });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-readonly')).toBe('true');
    });

    it('does not have aria-readonly when not read-only', () => {
      container.innerHTML = createSpinbuttonHTML({ readOnly: false });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.hasAttribute('aria-readonly')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      container.innerHTML = createSpinbuttonHTML({ ariaLabel: 'Quantity' });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-label')).toBe('Quantity');
    });

    it('has accessible name via aria-labelledby', () => {
      container.innerHTML = createSpinbuttonHTML({ ariaLabelledby: 'external-label' });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-labelledby')).toBe('external-label');
    });

    it('has accessible name via visible label', () => {
      container.innerHTML = createSpinbuttonHTML({ label: 'Item Count' });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.hasAttribute('aria-labelledby')).toBe(true);
      expect(container.querySelector('.apg-spinbutton-label')?.textContent).toBe('Item Count');
    });

    it('has aria-describedby when provided', () => {
      container.innerHTML = createSpinbuttonHTML({ ariaDescribedby: 'help-text' });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-describedby')).toBe('help-text');
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('Keyboard Interaction', () => {
    it('increases value by step on ArrowUp', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, step: 1 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('51');
    });

    it('decreases value by step on ArrowDown', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, step: 1 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('49');
    });

    it('sets min value on Home when min is defined', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, min: 0 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'Home', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('0');
    });

    it('does not change value on Home when min is not defined', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'Home', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('50');
    });

    it('sets max value on End when max is defined', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, max: 100 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'End', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('100');
    });

    it('does not change value on End when max is not defined', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'End', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('50');
    });

    it('increases value by large step on PageUp', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, step: 1 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('60');
    });

    it('decreases value by large step on PageDown', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, step: 1 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('40');
    });

    it('does not exceed max on ArrowUp', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 100, max: 100 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('100');
    });

    it('does not go below min on ArrowDown', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 0, min: 0 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('0');
    });

    it('allows values beyond default range when no min/max', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 0 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      // Can go negative
      const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
      spinbutton.dispatchEvent(downEvent);
      expect(spinbutton.getAttribute('aria-valuenow')).toBe('-1');

      // Can go positive without limit
      for (let i = 0; i < 200; i++) {
        const upEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
        spinbutton.dispatchEvent(upEvent);
      }
      expect(spinbutton.getAttribute('aria-valuenow')).toBe('199');
    });

    it('does not change value when disabled', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, disabled: true });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('50');
    });

    it('does not change value with arrow keys when read-only', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, readOnly: true });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('50');
    });

    it('allows Home/End navigation when read-only', () => {
      container.innerHTML = createSpinbuttonHTML({
        defaultValue: 50,
        min: 0,
        max: 100,
        readOnly: true,
      });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const homeEvent = new KeyboardEvent('keydown', { key: 'Home', bubbles: true });
      spinbutton.dispatchEvent(homeEvent);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('0');

      const endEvent = new KeyboardEvent('keydown', { key: 'End', bubbles: true });
      spinbutton.dispatchEvent(endEvent);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('100');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on input', () => {
      container.innerHTML = createSpinbuttonHTML();
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" when disabled', () => {
      container.innerHTML = createSpinbuttonHTML({ disabled: true });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="-1" on buttons', () => {
      container.innerHTML = createSpinbuttonHTML({ showButtons: true });
      const incrementBtn = container.querySelector('.apg-spinbutton-increment');
      const decrementBtn = container.querySelector('.apg-spinbutton-decrement');
      expect(incrementBtn?.getAttribute('tabindex')).toBe('-1');
      expect(decrementBtn?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🔴 High Priority: Button Interaction
  describe('Button Interaction', () => {
    it('shows increment and decrement buttons by default', () => {
      container.innerHTML = createSpinbuttonHTML();
      expect(container.querySelector('.apg-spinbutton-increment')).not.toBeNull();
      expect(container.querySelector('.apg-spinbutton-decrement')).not.toBeNull();
    });

    it('hides buttons when showButtons is false', () => {
      container.innerHTML = createSpinbuttonHTML({ showButtons: false });
      expect(container.querySelector('.apg-spinbutton-increment')).toBeNull();
      expect(container.querySelector('.apg-spinbutton-decrement')).toBeNull();
    });

    it('increments value on increment button click', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, step: 1 });
      const incrementBtn = container.querySelector('.apg-spinbutton-increment') as HTMLElement;
      const spinbutton = container.querySelector('[role="spinbutton"]');

      incrementBtn.click();

      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('51');
    });

    it('decrements value on decrement button click', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, step: 1 });
      const decrementBtn = container.querySelector('.apg-spinbutton-decrement') as HTMLElement;
      const spinbutton = container.querySelector('[role="spinbutton"]');

      decrementBtn.click();

      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('49');
    });

    it('does not change value on button click when disabled', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, disabled: true });
      const incrementBtn = container.querySelector('.apg-spinbutton-increment') as HTMLElement;
      const spinbutton = container.querySelector('[role="spinbutton"]');

      incrementBtn.click();

      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('50');
    });

    it('does not change value on button click when read-only', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, readOnly: true });
      const incrementBtn = container.querySelector('.apg-spinbutton-increment') as HTMLElement;
      const spinbutton = container.querySelector('[role="spinbutton"]');

      incrementBtn.click();

      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('50');
    });

    it('has accessible label on increment button', () => {
      container.innerHTML = createSpinbuttonHTML();
      const incrementBtn = container.querySelector('.apg-spinbutton-increment');
      expect(incrementBtn?.getAttribute('aria-label')).toBe('Increment');
    });

    it('has accessible label on decrement button', () => {
      container.innerHTML = createSpinbuttonHTML();
      const decrementBtn = container.querySelector('.apg-spinbutton-decrement');
      expect(decrementBtn?.getAttribute('aria-label')).toBe('Decrement');
    });
  });

  // 🟡 Medium Priority: Text Input
  describe('Text Input', () => {
    it('has inputmode="numeric" for mobile keyboard', () => {
      container.innerHTML = createSpinbuttonHTML();
      const input = container.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('inputmode')).toBe('numeric');
    });

    it('updates aria-valuenow on valid text input', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 0 });
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;

      input.value = '42';
      input.dispatchEvent(new Event('input', { bubbles: true }));

      expect(input.getAttribute('aria-valuenow')).toBe('42');
    });

    it('clamps input value to min/max', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50, min: 0, max: 100 });
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;

      input.value = '150';
      input.dispatchEvent(new Event('input', { bubbles: true }));

      expect(input.getAttribute('aria-valuenow')).toBe('100');
    });

    it('reverts to previous value on blur with invalid input', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;

      input.value = 'abc';
      input.dispatchEvent(new Event('blur', { bubbles: true }));

      expect(input.value).toBe('50');
      expect(input.getAttribute('aria-valuenow')).toBe('50');
    });

    it('normalizes value on blur', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 0, min: 0, max: 100, step: 5 });
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;

      input.value = '53';
      input.dispatchEvent(new Event('blur', { bubbles: true }));

      expect(input.value).toBe('55');
      expect(input.getAttribute('aria-valuenow')).toBe('55');
    });
  });

  // 🟡 Medium Priority: IME Composition
  describe('IME Composition', () => {
    it('does not update value during IME composition', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;

      input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
      input.value = '123';
      input.dispatchEvent(new Event('input', { bubbles: true }));

      expect(input.getAttribute('aria-valuenow')).toBe('50');
    });

    it('updates value on composition end', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;

      input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
      input.value = '75';
      input.dispatchEvent(new Event('input', { bubbles: true }));

      expect(input.getAttribute('aria-valuenow')).toBe('50');

      input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));

      expect(input.getAttribute('aria-valuenow')).toBe('75');
    });
  });

  // 🟡 Medium Priority: Events
  describe('Events', () => {
    it('dispatches valuechange event on value change', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const component = container.querySelector('apg-spinbutton') as TestApgSpinbutton;
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const eventHandler = vi.fn();
      component.addEventListener('valuechange', eventHandler);

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(eventHandler).toHaveBeenCalled();
      expect(eventHandler.mock.calls[0][0].detail.value).toBe(51);
    });

    it('does not dispatch event when value does not change', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 100, max: 100 });
      const component = container.querySelector('apg-spinbutton') as TestApgSpinbutton;
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const eventHandler = vi.fn();
      component.addEventListener('valuechange', eventHandler);

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

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

    it('dispatches valuechange on text input', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 0 });
      const component = container.querySelector('apg-spinbutton') as TestApgSpinbutton;
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;

      const eventHandler = vi.fn();
      component.addEventListener('valuechange', eventHandler);

      input.value = '25';
      input.dispatchEvent(new Event('input', { bubbles: true }));

      expect(eventHandler).toHaveBeenCalled();
      expect(eventHandler.mock.calls[0][0].detail.value).toBe(25);
    });

    it('dispatches valuechange on button click', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 50 });
      const component = container.querySelector('apg-spinbutton') as TestApgSpinbutton;
      const incrementBtn = container.querySelector('.apg-spinbutton-increment') as HTMLElement;

      const eventHandler = vi.fn();
      component.addEventListener('valuechange', eventHandler);

      incrementBtn.click();

      expect(eventHandler).toHaveBeenCalled();
      expect(eventHandler.mock.calls[0][0].detail.value).toBe(51);
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal step values correctly', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 0.5, min: 0, max: 1, step: 0.1 });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('0.6');
    });

    it('clamps value to min/max on init', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: -10, min: 0, max: 100 });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('rounds value to step on init', () => {
      container.innerHTML = createSpinbuttonHTML({ defaultValue: 53, min: 0, max: 100, step: 5 });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('aria-valuenow')).toBe('55');
    });

    it('uses custom large step when provided', () => {
      container.innerHTML = createSpinbuttonHTML({
        defaultValue: 50,
        step: 1,
        largeStep: 20,
      });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuenow')).toBe('70');
    });
  });

  // 🟡 Medium Priority: Format
  describe('Format', () => {
    it('updates aria-valuetext with format on value change', () => {
      container.innerHTML = createSpinbuttonHTML({
        defaultValue: 5,
        min: 0,
        max: 10,
        format: '{value} of {max}',
      });
      const spinbutton = container.querySelector('[role="spinbutton"]') as HTMLElement;

      expect(spinbutton.getAttribute('aria-valuetext')).toBe('5 of 10');

      const event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
      spinbutton.dispatchEvent(event);

      expect(spinbutton.getAttribute('aria-valuetext')).toBe('6 of 10');
    });
  });

  // 🟢 Low Priority: HTML Attributes
  describe('HTML Attributes', () => {
    it('sets id attribute', () => {
      container.innerHTML = createSpinbuttonHTML({ id: 'my-spinbutton' });
      const spinbutton = container.querySelector('[role="spinbutton"]');
      expect(spinbutton?.getAttribute('id')).toBe('my-spinbutton');
    });

    it('has readonly attribute when read-only', () => {
      container.innerHTML = createSpinbuttonHTML({ readOnly: true });
      const input = container.querySelector('[role="spinbutton"]') as HTMLInputElement;
      expect(input.readOnly).toBe(true);
    });

    it('displays visible label when label provided', () => {
      container.innerHTML = createSpinbuttonHTML({ label: 'Quantity' });
      expect(container.querySelector('.apg-spinbutton-label')?.textContent).toBe('Quantity');
    });
  });
});

リソース