APG Patterns
English
English

スピンボタン

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

デモ

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 入力要素 ユーザーがインクリメント/デクリメントまたは直接入力によって、離散的なセットまたは範囲から値を選択できるスピンボタンとして要素を識別します。
status 視覚的に非表示のライブリージョン 増加/減少ボタンをクリックしたときに新しい値を読み上げさせます。Element.ariaNotify() に対応していないブラウザでのみ使う代替手段です。ボタンを表示しているときだけ描画します。

WAI-ARIA プロパティ

aria-valuenow

値が変更されたとき(キーボード、ボタンクリック、またはテキスト入力)、即座に更新する必要があります

値
数値(現在の値)
必須
はい

aria-valuemin

最小値が定義されている場合のみ設定します。最小制限が存在しない場合は、属性を完全に省略してください。

値
数値
必須
いいえ

aria-valuemax

最大値が定義されている場合のみ設定します。最大制限が存在しない場合は、属性を完全に省略してください。

値
数値
必須
いいえ

aria-valuetext

現在の値に対する人間が読めるテキストの代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。

値
文字列(例: 5 items, 3 of 10)
必須
いいえ

aria-disabled

スピンボタンが無効化されており、インタラクティブでないことを示します。

値
true | false
必須
いいえ

aria-readonly

スピンボタンが読み取り専用であることを示します。ユーザーはHome/Endキーでナビゲーションできますが、値を変更することはできません。

値
true | false
必須
いいえ

aria-label

スピンボタンに不可視のラベルを提供します

値
文字列
必須
条件付き(表示されるラベルがない場合は必須)

aria-labelledby

外部要素をラベルとして参照します

値
ID参照
必須
条件付き(表示されるラベルが存在する場合は必須)

キーボードサポート

キー アクション
ArrowUp 値を1ステップ増やします
ArrowDown 値を1ステップ減らします
Home 値を最小値に設定します(最小値が定義されている場合のみ)
End 値を最大値に設定します(最大値が定義されている場合のみ)
Page Up 値を大きなステップで増やします(デフォルト: step × 10)
Page Down 値を大きなステップで減らします(デフォルト: step × 10)
  • spinbuttonロールは、ユーザーがインクリメント/デクリメントボタン、矢印キー、または直接入力によって数値を選択できる入力コントロールに使用されます。テキスト入力と値の上下調整機能を組み合わせたものです。
  • スライダーパターンとは異なり、スピンボタンは上下矢印キーのみを使用します(左右矢印キーは使用しません)。これにより、ユーザーはテキスト入力を使用して直接数値を入力できます。
  • スピンボタンにはアクセシブルな名前が必要です。これはlabelプロパティを使用した表示されるラベル、見えないラベルのためのaria-label、または外部要素を参照するaria-labelledbyによって提供できます。

フォーカス管理

イベント 振る舞い
入力要素 tabindex="0"
無効化された入力 tabindex="-1"
増加/減少ボタン tabindex="-1"(タブ順序に含まれない)
ボタンクリック フォーカスはスピンボタンに留まります(ボタンには移動しません)

ボタンクリック時の値の読み上げ

増加/減少ボタンをクリックすると、新しい値(aria-valuetext があればその文言、なければ数値)を次のどちらかの方法で読み上げさせます。

  • ブラウザが Element.ariaNotify()(opens in new tab) に対応していれば、スピンボタン上で呼び出します。対応しているかを実行時に確かめてから使うプログレッシブエンハンスメントです。
  • 対応していなければ、視覚的に非表示の role="status" ライブリージョンに値を書き込みます。古い内容が文書に残らないよう、1秒後に空にします。

キーボードでの変更はフォーカス中のスピンボタン自身が値の変化を伝えるため、この方法では読み上げません。

ビジュアルデザイン

  • フォーカスインジケーター - コントロールコンテナ全体(ボタンを含む)に可視のフォーカスリングを表示
  • ボタンの状態 - ホバーおよびアクティブ状態での視覚的フィードバック
  • 無効化状態 - スピンボタンが無効化されているときの明確な視覚的表示
  • 読み取り専用状態 - 読み取り専用モードの明確な視覚的スタイル
  • 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用

参考資料

ソースコード

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>
    {showButtons && <span role="status" class="apg-spinbutton-live" />}
  </div>
</apg-spinbutton>

<script>
  class ApgSpinbutton extends HTMLElement {
    private input: HTMLInputElement | null = null;
    private incrementBtn: HTMLButtonElement | null = null;
    private decrementBtn: HTMLButtonElement | null = null;
    private liveRegion: HTMLElement | null = null;
    private announceTimer: ReturnType<typeof setTimeout> | undefined;
    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');
      this.liveRegion = this.querySelector('.apg-spinbutton-live');

      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() {
      clearTimeout(this.announceTimer);

      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();
    }

    // Announce the value after a button click, since the value change itself
    // may not be reported. Uses ariaNotify when available, otherwise a live region.
    private announce() {
      const input: (HTMLElement & { ariaNotify?: (m: string) => void }) | null = this.input;
      if (!input) return;
      const message = input.getAttribute('aria-valuetext') ?? String(this.currentValue);
      if (typeof input.ariaNotify === 'function') {
        input.ariaNotify(message);
        return;
      }
      if (!this.liveRegion) return;
      this.liveRegion.textContent = message;
      clearTimeout(this.announceTimer);
      this.announceTimer = setTimeout(() => {
        if (this.liveRegion) this.liveRegion.textContent = '';
      }, 1000);
    }

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

    private handleDecrement(event: MouseEvent) {
      event.preventDefault();
      if (this.isDisabled || this.isReadOnly) return;
      this.updateValue(this.currentValue - this.step);
      this.announce();
      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>

使い方

Example
---
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

プロパティ型デフォルト説明
defaultValuenumber0スピンボタンの初期値
minnumberundefined最小値(undefined = 制限なし)
maxnumberundefined最大値(undefined = 制限なし)
stepnumber1キーボード/ボタンのステップ増分
largeStepnumberstep * 10PageUp/PageDownの大きなステップ
disabledbooleanfalseスピンボタンが無効かどうか
readOnlybooleanfalseスピンボタンが読み取り専用かどうか
showButtonsbooleantrueインクリメント/デクリメントボタンを表示するかどうか
labelstring-表示ラベル(aria-labelledbyとしても使用)
valueTextstring-aria-valuetextの人間が読める値
formatstring-aria-valuetextのフォーマットパターン(例: "{value} of {max}")
アクセシビリティのために、label、aria-label、またはaria-labelledbyのいずれかが必要です。このコンポーネントは、ハイドレーションを必要とせずにクライアント側のインタラクティビティのためにWeb Componentsを使用しています。

Custom Events

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

テスト

APG準拠のARIA属性、キーボード操作、テキスト入力処理、およびアクセシビリティ要件を検証するテストです。

テストカテゴリ

高優先度: ARIA属性

テスト説明
role="spinbutton"要素がspinbuttonロールを持つ
aria-valuenow現在の値が正しく設定され、更新される
aria-valuemin最小値が定義されている場合のみ設定される
aria-valuemax最大値が定義されている場合のみ設定される
aria-valuetext人間が読めるテキストが提供された場合に設定される
aria-disabled無効状態が設定された場合に反映される
aria-readonly読み取り専用状態が設定された場合に反映される

高優先度: アクセシブル名

テスト説明
aria-labelaria-label属性によるアクセシブル名
aria-labelledby外部要素参照によるアクセシブル名
visible label視覚的なラベルがアクセシブル名を提供

高優先度: キーボード操作

テスト説明
Arrow Up値を1ステップ増加させる
Arrow Down値を1ステップ減少させる
Home最小値に設定(最小値が定義されている場合のみ)
End最大値に設定(最大値が定義されている場合のみ)
Page Up/Down大きなステップで値を増加/減少させる
Boundary clamping値が最小値/最大値の範囲を超えない
Disabled state無効状態の場合、キーボード操作が無効になる
Read-only state矢印キーはブロック、Home/Endは許可

高優先度: ボタン操作

テスト説明
Increment click増加ボタンのクリックで値が増加する
Decrement click減少ボタンのクリックで値が減少する
Button labelsボタンにアクセシブルなラベルがある
Disabled/read-only無効または読み取り専用の場合、ボタンがブロックされる
Value announcementボタンのクリックで、ariaNotify() 対応時は新しい値の文言で呼び出し、非対応時はライブリージョンに書き込んで1秒後に空にする

高優先度: フォーカス管理

テスト説明
tabindex="0"入力欄がフォーカス可能である
tabindex="-1"無効状態の場合、入力欄がフォーカス不可になる
Button tabindexボタンがtabindex="-1"を持つ(タブ順序に含まれない)

中優先度: テキスト入力

テスト説明
inputmode="numeric"モバイルで数値キーボードを使用
Valid input有効なテキスト入力時にaria-valuenowが更新される
Invalid input無効な入力でフォーカスを失った際に前の値に戻る
Clamp on blurフォーカスを失った際にステップと最小値/最大値に正規化される

中優先度: IME変換

テスト説明
During compositionIME変換中は値が更新されない
On composition end変換完了時に値が更新される

中優先度: エッジケース

テスト説明
decimal values小数ステップ値を正しく処理する
no min/max最小値/最大値がない場合、無制限の値を許可
clamp to min最小値を下回るdefaultValueが最小値にクランプされる
clamp to max最大値を上回るdefaultValueが最大値にクランプされる

中優先度: コールバック

テスト説明
onValueChange値の変更時に新しい値でコールバックが呼ばれる

低優先度: HTML属性の継承

テスト説明
classNameカスタムクラスがコンテナに適用される
idID属性が正しく設定される
data-*データ属性が継承される

テストツール

詳細はテスト戦略ガイドを参照してください。

Spinbutton.test.astro.ts
/**
 * Spinbutton Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of Spinbutton.astro.
 * Web Component behavior (keyboard, buttons, text input, IME composition,
 * value announcement and the valuechange event) is covered by E2E tests
 * in e2e/spinbutton.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Spinbutton from './Spinbutton.astro';

describe('Spinbutton (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  async function renderSpinbutton(props: Record<string, unknown> = {}): Promise<Document> {
    const html = await container.renderToString(Spinbutton, { props });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-spinbutton custom element wrapper', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      expect(doc.querySelector('apg-spinbutton')).not.toBeNull();
    });

    it('renders a text input with role="spinbutton"', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('input[role="spinbutton"]');
      expect(input?.getAttribute('type')).toBe('text');
    });

    it('has inputmode="numeric" for mobile keyboards', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('inputmode')).toBe('numeric');
    });

    it('renders the initial value in the input', async () => {
      const doc = await renderSpinbutton({ defaultValue: 5, 'aria-label': 'Quantity' });
      const input = doc.querySelector<HTMLInputElement>('[role="spinbutton"]');
      expect(input?.value).toBe('5');
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has aria-valuenow="0" when no defaultValue is given', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('has aria-valuenow set to defaultValue', async () => {
      const doc = await renderSpinbutton({ defaultValue: 5, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuenow')).toBe('5');
    });

    it('has aria-valuemin when min is provided', async () => {
      const doc = await renderSpinbutton({ min: 0, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuemin')).toBe('0');
    });

    it('does not have aria-valuemin when min is not provided', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.hasAttribute('aria-valuemin')).toBe(false);
    });

    it('has aria-valuemax when max is provided', async () => {
      const doc = await renderSpinbutton({ max: 100, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuemax')).toBe('100');
    });

    it('does not have aria-valuemax when max is not provided', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.hasAttribute('aria-valuemax')).toBe(false);
    });

    it('has aria-valuetext when valueText is provided', async () => {
      const doc = await renderSpinbutton({ valueText: '5 items', 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuetext')).toBe('5 items');
    });

    it('has aria-valuetext built from format with value, min and max', async () => {
      const doc = await renderSpinbutton({
        defaultValue: 3,
        min: 1,
        max: 10,
        format: '{value} of {max}',
        'aria-label': 'Rating',
      });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuetext')).toBe('3 of 10');
    });

    it('does not have aria-valuetext when neither valueText nor format is provided', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.hasAttribute('aria-valuetext')).toBe(false);
    });

    it('has aria-disabled="true" when disabled', async () => {
      const doc = await renderSpinbutton({ disabled: true, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-disabled when not disabled', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.hasAttribute('aria-disabled')).toBe(false);
    });

    it('has aria-readonly="true" and the readonly attribute when read-only', async () => {
      const doc = await renderSpinbutton({ readOnly: true, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-readonly')).toBe('true');
      expect(input?.hasAttribute('readonly')).toBe(true);
    });

    it('does not have aria-readonly when not read-only', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.hasAttribute('aria-readonly')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has aria-label when provided', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-label')).toBe('Quantity');
    });

    it('has aria-labelledby when provided', async () => {
      const doc = await renderSpinbutton({ 'aria-labelledby': 'external-label' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-labelledby')).toBe('external-label');
    });

    it('references the visible label via aria-labelledby when label is provided', async () => {
      const doc = await renderSpinbutton({ label: 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      const labelId = input?.getAttribute('aria-labelledby') ?? '';
      expect(doc.getElementById(labelId)?.textContent?.trim()).toBe('Quantity');
    });

    it('does not set aria-label when a visible label is provided', async () => {
      const doc = await renderSpinbutton({ label: 'Quantity', 'aria-label': 'Ignored' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.hasAttribute('aria-label')).toBe(false);
    });

    it('has aria-describedby when provided', async () => {
      const doc = await renderSpinbutton({
        'aria-label': 'Quantity',
        'aria-describedby': 'quantity-help',
      });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-describedby')).toBe('quantity-help');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on the input', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" on the input when disabled', async () => {
      const doc = await renderSpinbutton({ disabled: true, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('tabindex')).toBe('-1');
    });

    it('has tabindex="-1" on the increment and decrement buttons', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      expect(doc.querySelector('.apg-spinbutton-increment')?.getAttribute('tabindex')).toBe('-1');
      expect(doc.querySelector('.apg-spinbutton-decrement')?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🟡 Medium Priority: Buttons
  describe('Buttons', () => {
    it('renders increment and decrement buttons with accessible labels by default', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      expect(doc.querySelector('.apg-spinbutton-increment')?.getAttribute('aria-label')).toBe(
        'Increment'
      );
      expect(doc.querySelector('.apg-spinbutton-decrement')?.getAttribute('aria-label')).toBe(
        'Decrement'
      );
    });

    it('does not render buttons when showButtons is false', async () => {
      const doc = await renderSpinbutton({ showButtons: false, 'aria-label': 'Quantity' });
      expect(doc.querySelector('.apg-spinbutton-increment')).toBeNull();
      expect(doc.querySelector('.apg-spinbutton-decrement')).toBeNull();
    });

    it('renders disabled buttons when disabled', async () => {
      const doc = await renderSpinbutton({ disabled: true, 'aria-label': 'Quantity' });
      expect(doc.querySelector('.apg-spinbutton-increment')?.hasAttribute('disabled')).toBe(true);
      expect(doc.querySelector('.apg-spinbutton-decrement')?.hasAttribute('disabled')).toBe(true);
    });
  });

  // 🔴 High Priority: Live Region (fallback for ariaNotify)
  describe('Live Region', () => {
    it('renders an empty role="status" live region when buttons are shown', async () => {
      const doc = await renderSpinbutton({ 'aria-label': 'Quantity' });
      const liveRegion = doc.querySelector('[role="status"]');
      expect(liveRegion?.textContent).toBe('');
    });

    it('does not render the live region when showButtons is false', async () => {
      const doc = await renderSpinbutton({ showButtons: false, 'aria-label': 'Quantity' });
      expect(doc.querySelector('[role="status"]')).toBeNull();
    });
  });

  // 🟡 Medium Priority: Initial Value Normalization
  describe('Initial Value Normalization', () => {
    it('clamps defaultValue above max to max', async () => {
      const doc = await renderSpinbutton({ defaultValue: 150, max: 100, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuenow')).toBe('100');
    });

    it('clamps defaultValue below min to min', async () => {
      const doc = await renderSpinbutton({ defaultValue: -10, min: 0, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('rounds defaultValue to the nearest step', async () => {
      const doc = await renderSpinbutton({ defaultValue: 7, step: 5, 'aria-label': 'Quantity' });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuenow')).toBe('5');
    });

    it('rounds a decimal defaultValue without floating-point error', async () => {
      const doc = await renderSpinbutton({
        defaultValue: 0.1 + 0.2,
        step: 0.1,
        'aria-label': 'Opacity',
      });
      const input = doc.querySelector('[role="spinbutton"]');
      expect(input?.getAttribute('aria-valuenow')).toBe('0.3');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('sets the id attribute on the input', async () => {
      const doc = await renderSpinbutton({ id: 'quantity', 'aria-label': 'Quantity' });
      expect(doc.querySelector('[role="spinbutton"]')?.id).toBe('quantity');
    });

    it('applies a custom class to the container', async () => {
      const doc = await renderSpinbutton({ class: 'custom-class', 'aria-label': 'Quantity' });
      expect(doc.querySelector('.apg-spinbutton')?.classList.contains('custom-class')).toBe(true);
    });

    it('sets data-testid on the input', async () => {
      const doc = await renderSpinbutton({ 'data-testid': 'qty', 'aria-label': 'Quantity' });
      expect(doc.querySelector('[role="spinbutton"]')?.getAttribute('data-testid')).toBe('qty');
    });
  });
});

リソース