スピンボタン
増減ボタン、矢印キー、または直接入力を使用して、離散的なセットまたは範囲から値を選択できる入力ウィジェット。
デモ
ネイティブ 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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
---
/**
* 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>使い方
---
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}") |
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-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 | 無効または読み取り専用の場合、ボタンがブロックされる |
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 composition | IME変換中は値が更新されない |
On composition end | 変換完了時に値が更新される |
中優先度: エッジケース
| テスト | 説明 |
|---|---|
decimal values | 小数ステップ値を正しく処理する |
no min/max | 最小値/最大値がない場合、無制限の値を許可 |
clamp to min | 最小値を下回るdefaultValueが最小値にクランプされる |
clamp to max | 最大値を上回るdefaultValueが最大値にクランプされる |
中優先度: コールバック
| テスト | 説明 |
|---|---|
onValueChange | 値の変更時に新しい値でコールバックが呼ばれる |
低優先度: HTML属性の継承
| テスト | 説明 |
|---|---|
className | カスタムクラスがコンテナに適用される |
id | ID属性が正しく設定される |
data-* | データ属性が継承される |
テストツール
- Playwright(opens in new tab) - E2Eテスト(178件のクロスフレームワークテスト)
- Testing Library(opens in new tab) - フレームワーク固有のテストユーティリティ(React, Vue, Svelte)
- axe-core(opens in new tab) - 自動アクセシビリティテスト
詳細はテスト戦略ガイドを参照してください。
/**
* 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');
});
});
});リソース
- WAI-ARIA APG: Spinbutton パターン(opens in new tab)
- MDN: <input type="number"> 要素(opens in new tab)
- AI Implementation Guide (llm.md)(opens in new tab) - ARIA specs, keyboard support, test checklist