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.

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)

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.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 } 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);
// Use crypto.randomUUID() for unique IDs in Astro Islands (Vue's useId() returns same value for all instances)
const labelId = `spinbutton-label-${crypto.randomUUID().slice(0, 8)}`;
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

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.

Custom Events

EventDetailDescription
@valuechangenumberEmitted 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

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