APG Patterns
日本語
日本語

Spinbutton

An input widget that allows users to select a value from a discrete set or range by using increment/decrement buttons, arrow keys, or typing directly.

Demo

Quantity
Rating
Opacity
Unbounded
Read-only
Disabled

Open demo only →

Native HTML

Use Native HTML First

Before using this custom component, consider using native <input type="number"> elements.They provide built-in semantics, work without JavaScript, and have native browser validation.

<label for="quantity">Quantity</label>
<input type="number" id="quantity" value="1" min="0" max="100" step="1">

Use custom implementations only when you need custom styling that native elements cannot provide, or when you need specific interaction patterns not available with native inputs.

Use CaseNative HTMLCustom Implementation
Basic numeric inputRecommendedNot needed
JavaScript disabled supportWorks nativelyRequires fallback
Built-in validationNative supportManual implementation
Custom button stylingLimited (browser-dependent)Full control
Consistent cross-browser appearanceVaries by browserConsistent
Custom step/large step behaviorBasic step onlyPageUp/PageDown support
No min/max limitsRequires omitting attributesExplicit undefined support

The native <input type="number"> element provides built-in browser validation, form submission support, and accessible semantics. However, its appearance and spinner button styling varies significantly across browsers, making custom implementations preferable when visual consistency is required.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
spinbutton Input element Identifies the element as a spin button that allows users to select a value from a discrete set or range by incrementing/decrementing or typing directly.
status Visually hidden live region Announces the new value when an increment/decrement button is clicked. Used only as a fallback in browsers that do not support Element.ariaNotify(). Rendered only when the buttons are shown.

WAI-ARIA Properties

aria-valuenow

Must be updated immediately when value changes (keyboard, button click, or text input)

Values
Number (current value)
Required
Yes

aria-valuemin

Only set when min is defined. Omit attribute entirely when no minimum limit exists.

Values
Number
Required
No

aria-valuemax

Only set when max is defined. Omit attribute entirely when no maximum limit exists.

Values
Number
Required
No

aria-valuetext

Provides a human-readable text alternative for the current value. Use when the numeric value alone doesn’t convey sufficient meaning.

Values
String (e.g., 5 items, 3 of 10)
Required
No

aria-disabled

Indicates that the spinbutton is disabled and not interactive.

Values
true | false
Required
No

aria-readonly

Indicates that the spinbutton is read-only. Users can navigate with Home/End but cannot change the value.

Values
true | false
Required
No

aria-label

Provides an invisible label for the spinbutton

Values
String
Required
Conditional (required if no visible label)

aria-labelledby

References an external element as the label

Values
ID reference
Required

Conditional (required if visible label exists)

Keyboard Support

Key Action
ArrowUp Increases the value by one step
ArrowDown Decreases the value by one step
Home Sets the value to its minimum (only when min is defined)
End Sets the value to its maximum (only when max is defined)
Page Up Increases the value by a large step (default: step * 10)
Page Down Decreases the value by a large step (default: step * 10)
  • The spinbutton role is used for input controls that let users select a numeric value by using increment/decrement buttons, arrow keys, or typing directly. It combines the functionality of a text input with up/down value adjustment.
  • Unlike the slider pattern, spinbutton uses Up/Down arrows only (not Left/Right). This allows users to type numeric values directly using the text input.
  • Spinbuttons must have an accessible name. This can be provided through a visible label using the label prop, aria-label for an invisible label, or aria-labelledby to reference an external element.

Focus Management

Event Behavior
Input element tabindex="0"
Disabled input tabindex="-1"
Increment/decrement buttons tabindex="-1" (not in tab order)
Button click Focus stays on spinbutton (does NOT move to button)

Value Announcement on Button Click

When an increment/decrement button is clicked, the new value (aria-valuetext if set, otherwise the number) is announced using one of these two techniques:

  • If the browser supports Element.ariaNotify()(opens in new tab), it is called on the spinbutton. This is a progressive enhancement applied only after confirming support at runtime.
  • Otherwise, the value is written to a visually hidden role="status" live region. The live region is emptied after 1 second to avoid leaving stale content in the document.

Keyboard changes are not announced this way because the focused spinbutton already reports its own value changes.

Visual Design

  • Focus indicator - Visible focus ring on the entire controls container (including buttons)
  • Button states - Visual feedback on hover and active states
  • Disabled state - Clear visual indication when spinbutton is disabled
  • Read-only state - Distinct visual style for read-only mode
  • Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode

References

Source Code

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>

Usage

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

PropTypeDefaultDescription
defaultValuenumber0Initial value of the spinbutton
minnumberundefinedMinimum value (undefined = no limit)
maxnumberundefinedMaximum value (undefined = no limit)
stepnumber1Step increment for keyboard/button
largeStepnumberstep * 10Large step for PageUp/PageDown
disabledbooleanfalseWhether the spinbutton is disabled
readOnlybooleanfalseWhether the spinbutton is read-only
showButtonsbooleantrueWhether to show increment/decrement buttons
labelstring-Visible label (also used as aria-labelledby)
valueTextstring-Human-readable value for aria-valuetext
formatstring-Format pattern for aria-valuetext (e.g., "{value} of {max}")
One of label, aria-label, or aria-labelledby is required for accessibility. This component uses Web Components for client-side interactivity without requiring hydration.

Custom Events

EventDetailDescription
valuechange{value: number}Dispatched when value changes

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, text input handling, and accessibility requirements.

Test Categories

High Priority: ARIA Attributes

TestDescription
role="spinbutton"Element has the spinbutton role
aria-valuenowCurrent value is correctly set and updated
aria-valueminMinimum value is set only when min is defined
aria-valuemaxMaximum value is set only when max is defined
aria-valuetextHuman-readable text is set when provided
aria-disabledDisabled state is reflected when set
aria-readonlyRead-only state is reflected when set

High Priority: Accessible Name

TestDescription
aria-labelAccessible name via aria-label attribute
aria-labelledbyAccessible name via external element reference
visible labelVisible label provides accessible name

High Priority: Keyboard Interaction

TestDescription
Arrow UpIncreases value by one step
Arrow DownDecreases value by one step
HomeSets value to minimum (only when min defined)
EndSets value to maximum (only when max defined)
Page Up/DownIncreases/decreases value by large step
Boundary clampingValue does not exceed min/max limits
Disabled stateKeyboard has no effect when disabled
Read-only stateArrow keys blocked, Home/End allowed

High Priority: Button Interaction

TestDescription
Increment clickClicking increment button increases value
Decrement clickClicking decrement button decreases value
Button labelsButtons have accessible labels
Disabled/read-onlyButtons blocked when disabled or read-only
Value announcementButton click calls ariaNotify() with the new value text when supported, otherwise writes it to the live region and clears it after 1 second

High Priority: Focus Management

TestDescription
tabindex="0"Input is focusable
tabindex="-1"Input is not focusable when disabled
Button tabindexButtons have tabindex="-1" (not in tab order)

Medium Priority: Text Input

TestDescription
inputmode="numeric"Uses numeric keyboard on mobile
Valid inputaria-valuenow updates on valid text input
Invalid inputReverts to previous value on blur with invalid input
Clamp on blurValue normalized to step and min/max on blur

Medium Priority: IME Composition

TestDescription
During compositionValue not updated during IME composition
On composition endValue updates when composition completes

Medium Priority: Edge Cases

TestDescription
decimal valuesHandles decimal step values correctly
no min/maxAllows unbounded values when no min/max
clamp to mindefaultValue below min is clamped to min
clamp to maxdefaultValue above max is clamped to max

Medium Priority: Callbacks

TestDescription
onValueChangeCallback is called with new value on change

Low Priority: HTML Attribute Inheritance

TestDescription
classNameCustom class is applied to container
idID attribute is set correctly
data-*Data attributes are passed through

Testing Tools

See the Testing Strategy guide for details.

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

Resources