APG Patterns
日本語 GitHub
日本語 GitHub

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.

🤖 AI Implementation Guide

Demo

Quantity
Rating
Opacity
Unbounded
Read-only
Disabled

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 Case Native HTML Custom Implementation
Basic numeric input Recommended Not needed
JavaScript disabled support Works natively Requires fallback
Built-in validation Native support Manual implementation
Custom button styling Limited (browser-dependent) Full control
Consistent cross-browser appearance Varies by browser Consistent
Custom step/large step behavior Basic step only PageUp/PageDown support
No min/max limits Requires omitting attributes Explicit 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 Role

Role 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.

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.

WAI-ARIA States and Properties

aria-valuenow (Required)

Indicates the current numeric value of the spinbutton. Updated dynamically as the user changes the value.

Type Number
Required Yes
Update Must be updated immediately when value changes (keyboard, button click, or text input)

aria-valuemin

Specifies the minimum allowed value. Only set when a minimum limit exists.

Type Number
Required No (only set when min is defined)
Note Omit attribute entirely when no minimum limit exists

aria-valuemax

Specifies the maximum allowed value. Only set when a maximum limit exists.

Type Number
Required No (only set when max is defined)
Note Omit attribute entirely when no maximum limit exists

aria-valuetext

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

Type String
Required No (recommended when value needs context)
Example "5 items", "3 of 10", "Tuesday"

aria-disabled

Indicates that the spinbutton is disabled and not interactive.

Type Boolean
Required No

aria-readonly

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

Type Boolean
Required No

Keyboard Support

Key Action
Up Arrow Increases the value by one step
Down Arrow 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)

Note: 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.

Accessible Naming

Spinbuttons must have an accessible name. This can be provided through:

  • Visible label - Using the label prop to display a visible label
  • aria-label - Provides an invisible label for the spinbutton
  • aria-labelledby - References an external element as the label

Text Input

This implementation supports direct text input:

  • Numeric keyboard - Uses inputmode="numeric" for optimal mobile experience
  • Real-time validation - Value is clamped and rounded to step on each input
  • Invalid input handling - Reverts to previous valid value on blur if input is invalid
  • IME support - Waits for composition to complete before updating value

Visual Design

This implementation follows WCAG guidelines for accessible visual design:

  • Focus indicator - Visible focus ring on the entire controls container (including buttons), providing clear visual feedback for the focused component
  • 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.vue
<template>
  <div :class="cn('apg-spinbutton', disabled && 'apg-spinbutton--disabled', $attrs.class)">
    <span v-if="label" :id="labelId" class="apg-spinbutton-label">
      {{ label }}
    </span>
    <div class="apg-spinbutton-controls">
      <button
        v-if="showButtons"
        type="button"
        :tabindex="-1"
        aria-label="Decrement"
        :disabled="disabled"
        class="apg-spinbutton-button apg-spinbutton-decrement"
        @mousedown.prevent
        @click="handleDecrement"
      >

      </button>
      <input
        ref="inputRef"
        type="text"
        role="spinbutton"
        :id="$attrs.id as string | undefined"
        :tabindex="disabled ? -1 : 0"
        inputmode="numeric"
        :value="inputValue"
        :readonly="readOnly"
        :aria-valuenow="value"
        :aria-valuemin="min"
        :aria-valuemax="max"
        :aria-valuetext="ariaValueText"
        :aria-label="label ? undefined : ($attrs['aria-label'] as string | undefined)"
        :aria-labelledby="ariaLabelledby"
        :aria-describedby="$attrs['aria-describedby'] as string | undefined"
        :aria-disabled="disabled || undefined"
        :aria-readonly="readOnly || undefined"
        :aria-invalid="$attrs['aria-invalid'] as boolean | undefined"
        :data-testid="$attrs['data-testid'] as string | undefined"
        class="apg-spinbutton-input"
        @input="handleInput"
        @keydown="handleKeyDown"
        @blur="handleBlur"
        @compositionstart="handleCompositionStart"
        @compositionend="handleCompositionEnd"
      />
      <button
        v-if="showButtons"
        type="button"
        :tabindex="-1"
        aria-label="Increment"
        :disabled="disabled"
        class="apg-spinbutton-button apg-spinbutton-increment"
        @mousedown.prevent
        @click="handleIncrement"
      >
        +
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, useId } from 'vue';
import { cn } from '@/lib/utils';

defineOptions({
  inheritAttrs: false,
});

export interface SpinbuttonProps {
  /** 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;
}

const props = withDefaults(defineProps<SpinbuttonProps>(), {
  defaultValue: 0,
  min: undefined,
  max: undefined,
  step: 1,
  largeStep: undefined,
  disabled: false,
  readOnly: false,
  showButtons: true,
  label: undefined,
  valueText: undefined,
  format: undefined,
});

const emit = defineEmits<{
  valueChange: [value: number];
}>();

// 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;
};

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

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

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

// Refs
const inputRef = ref<HTMLInputElement | null>(null);
const labelId = useId();
const isComposing = ref(false);

// State
const initialValue = clamp(
  roundToStep(props.defaultValue, props.step, props.min),
  props.min,
  props.max
);
const value = ref(initialValue);
const inputValue = ref(String(initialValue));

// Computed
const effectiveLargeStep = computed(() => props.largeStep ?? props.step * 10);

const ariaValueText = computed(() => {
  if (props.valueText) return props.valueText;
  if (props.format) return formatValueText(value.value, props.format);
  return undefined;
});

const ariaLabelledby = computed(() => {
  const attrLabelledby = (
    getCurrentInstance()?.attrs as { 'aria-labelledby'?: string } | undefined
  )?.['aria-labelledby'];
  if (attrLabelledby) return attrLabelledby;
  if (props.label) return labelId;
  return undefined;
});

// Helper to get current instance for attrs
import { getCurrentInstance } from 'vue';

// Update value and emit
const updateValue = (newValue: number) => {
  const clampedValue = clamp(roundToStep(newValue, props.step, props.min), props.min, props.max);
  if (clampedValue !== value.value) {
    value.value = clampedValue;
    inputValue.value = String(clampedValue);
    emit('valueChange', clampedValue);
  }
};

// Keyboard handler
const handleKeyDown = (event: KeyboardEvent) => {
  if (props.disabled) return;

  let newValue = value.value;
  let handled = false;

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

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

// Text input handler
const handleInput = (event: Event) => {
  const target = event.target as HTMLInputElement;
  inputValue.value = target.value;

  if (!isComposing.value) {
    const parsed = parseFloat(target.value);
    if (!isNaN(parsed)) {
      const clampedValue = clamp(roundToStep(parsed, props.step, props.min), props.min, props.max);
      if (clampedValue !== value.value) {
        value.value = clampedValue;
        emit('valueChange', clampedValue);
      }
    }
  }
};

// Blur handler
const handleBlur = () => {
  const parsed = parseFloat(inputValue.value);

  if (isNaN(parsed)) {
    // Revert to previous valid value
    inputValue.value = String(value.value);
  } else {
    const newValue = clamp(roundToStep(parsed, props.step, props.min), props.min, props.max);
    if (newValue !== value.value) {
      value.value = newValue;
      emit('valueChange', newValue);
    }
    inputValue.value = String(newValue);
  }
};

// IME composition handlers
const handleCompositionStart = () => {
  isComposing.value = true;
};

const handleCompositionEnd = () => {
  isComposing.value = false;
  const parsed = parseFloat(inputValue.value);
  if (!isNaN(parsed)) {
    const clampedValue = clamp(roundToStep(parsed, props.step, props.min), props.min, props.max);
    value.value = clampedValue;
    emit('valueChange', clampedValue);
  }
};

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

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

Usage

Example
<script setup>
import Spinbutton from './Spinbutton.vue';

function handleChange(value) {
  console.log(value);
}
</script>

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

  <!-- With visible label and min/max -->
  <Spinbutton
    :default-value="5"
    :min="0"
    :max="100"
    label="Quantity"
  />

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

  <!-- Decimal step values -->
  <Spinbutton
    :default-value="0.5"
    :min="0"
    :max="1"
    :step="0.1"
    label="Opacity"
  />

  <!-- Unbounded (no min/max limits) -->
  <Spinbutton
    :default-value="0"
    label="Counter"
  />

  <!-- With callback -->
  <Spinbutton
    :default-value="5"
    :min="0"
    :max="100"
    label="Value"
    @valuechange="handleChange"
  />
</template>

API

Prop Type Default Description
defaultValue number 0 Initial value of the spinbutton
min number undefined Minimum value (undefined = no limit)
max number undefined Maximum value (undefined = no limit)
step number 1 Step increment for keyboard/button
largeStep number step * 10 Large step for PageUp/PageDown
disabled boolean false Whether the spinbutton is disabled
readOnly boolean false Whether the spinbutton is read-only
showButtons boolean true Whether to show increment/decrement buttons
label string - Visible label (also used as aria-labelledby)
valueText string - Human-readable value for aria-valuetext
format string - Format pattern for aria-valuetext (e.g., "{value} of {max}")

Events

Event Payload Description
@valuechange number Emitted when value changes

One of label, aria-label, or aria-labelledby is required for accessibility.

Testing

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

Test Categories

High Priority: ARIA Attributes

Test Description
role="spinbutton" Element has the spinbutton role
aria-valuenow Current value is correctly set and updated
aria-valuemin Minimum value is set only when min is defined
aria-valuemax Maximum value is set only when max is defined
aria-valuetext Human-readable text is set when provided
aria-disabled Disabled state is reflected when set
aria-readonly Read-only state is reflected when set

High Priority: Accessible Name

Test Description
aria-label Accessible name via aria-label attribute
aria-labelledby Accessible name via external element reference
visible label Visible label provides accessible name

High Priority: Keyboard Interaction

Test Description
Arrow Up Increases value by one step
Arrow Down Decreases value by one step
Home Sets value to minimum (only when min defined)
End Sets value to maximum (only when max defined)
Page Up/Down Increases/decreases value by large step
Boundary clamping Value does not exceed min/max limits
Disabled state Keyboard has no effect when disabled
Read-only state Arrow keys blocked, Home/End allowed

High Priority: Button Interaction

Test Description
Increment click Clicking increment button increases value
Decrement click Clicking decrement button decreases value
Button labels Buttons have accessible labels
Disabled/read-only Buttons blocked when disabled or read-only

High Priority: Focus Management

Test Description
tabindex="0" Input is focusable
tabindex="-1" Input is not focusable when disabled
Button tabindex Buttons have tabindex="-1" (not in tab order)

Medium Priority: Text Input

Test Description
inputmode="numeric" Uses numeric keyboard on mobile
Valid input aria-valuenow updates on valid text input
Invalid input Reverts to previous value on blur with invalid input
Clamp on blur Value normalized to step and min/max on blur

Medium Priority: IME Composition

Test Description
During composition Value not updated during IME composition
On composition end Value updates when composition completes

Medium Priority: Edge Cases

Test Description
decimal values Handles decimal step values correctly
no min/max Allows unbounded values when no min/max
clamp to min defaultValue below min is clamped to min
clamp to max defaultValue above max is clamped to max

Medium Priority: Callbacks

Test Description
onValueChange Callback is called with new value on change

Low Priority: HTML Attribute Inheritance

Test Description
className Custom class is applied to container
id ID attribute is set correctly
data-* Data attributes are passed through

Testing Tools

Spinbutton.test.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Spinbutton from './Spinbutton.vue';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    it('has accessible name via aria-labelledby', () => {
      render(Spinbutton, {
        attrs: {
          'aria-labelledby': 'spinbutton-label',
        },
        global: {
          stubs: {
            teleport: true,
          },
        },
      });
      // Note: aria-labelledby test requires the label element in the DOM
      const spinbutton = screen.getByRole('spinbutton');
      expect(spinbutton).toHaveAttribute('aria-labelledby', 'spinbutton-label');
    });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      await user.click(incrementButton);

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

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

      await user.click(decrementButton);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('emits valueChange on keyboard interaction', async () => {
      const user = userEvent.setup();
      const { emitted } = render(Spinbutton, {
        props: { defaultValue: 5 },
        attrs: { 'aria-label': 'Quantity' },
      });
      const spinbutton = screen.getByRole('spinbutton');

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

      expect(emitted().valueChange).toBeTruthy();
      expect(emitted().valueChange[0]).toEqual([6]);
    });

    it('emits valueChange on button click', async () => {
      const user = userEvent.setup();
      const { emitted } = render(Spinbutton, {
        props: { defaultValue: 5 },
        attrs: { 'aria-label': 'Quantity' },
      });
      const incrementButton = screen.getByLabelText(/increment/i);

      await user.click(incrementButton);

      expect(emitted().valueChange).toBeTruthy();
      expect(emitted().valueChange[0]).toEqual([6]);
    });
  });

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

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

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

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

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

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

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

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

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

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

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

    it('passes through data-testid', () => {
      render(Spinbutton, {
        attrs: {
          'aria-label': 'Quantity',
          'data-testid': 'custom-spinbutton',
        },
      });
      expect(screen.getByTestId('custom-spinbutton')).toBeInTheDocument();
    });
  });
});

Resources