Skip to content

Conversation

@OmerGronich
Copy link

@OmerGronich OmerGronich commented Jan 16, 2026

This PR implements the ARIA spinbutton pattern based on WAI-ARIA guidelines.

WAI-ARIA Pattern Reference:
https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/

Completed:

  • Four new directives: ngSpinButton, ngSpinButtonInput, ngSpinButtonIncrement, ngSpinButtonDecrement
  • Full keyboard navigation:
    • Arrow Up/Down: increment/decrement by step
    • Page Up/Down: increment/decrement by large step (configurable)
    • Home/End: jump to min/max values
  • Value wrapping support (max→min and min→max)
  • Proper ARIA attributes: aria-valuenow, aria-valuetext, aria-valuemin, aria-valuemax, aria-disabled, aria-readonly, aria-invalid
  • Support for both native input elements and span elements
  • Development mode validation warnings
  • Two example implementations:
    • Guest counter with increment/decrement buttons
    • Time field with hour/minute/period spinbuttons
  • Comprehensive test coverage

Future Enhancements:

  • Documentation guide
  • Additional examples (date picker fields, numeric stepper)
  • Indeterminate state support (undefined value)
  • aria-required attribute support

Implements a spinbutton ARIA primitive as a compound component following
the W3C APG spinbutton pattern. The implementation includes:

- SpinButtonPattern class with value management, keyboard handling,
  and wrap/clamp behavior
- SpinButton parent directive for container and state management
- SpinButtonInput directive for the focusable element (supports both
  input and span elements)
- SpinButtonIncrement/Decrement button directives
- Comprehensive test coverage
- Two dev-app examples: APG hotel guest counter and time field segments
@OmerGronich OmerGronich marked this pull request as ready for review January 20, 2026 18:11
@pullapprove pullapprove bot requested a review from crisbeto January 20, 2026 18:11
@angular angular deleted a comment from google-cla bot Jan 27, 2026
Comment on lines +107 to +114
afterRenderEffect(() => {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
const violations = this._pattern.validate();
for (const violation of violations) {
console.error(violation);
}
}
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In prod mode this would register an empty effect.
It's better to have it around the afterRenderEffect

Suggested change
afterRenderEffect(() => {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
const violations = this._pattern.validate();
for (const violation of violations) {
console.error(violation);
}
}
});
if (typeof ngDevMode === 'undefined' || ngDevMode) {
afterRenderEffect(() => {
const violations = this._pattern.validate();
for (const violation of violations) {
console.error(violation);
}
});
}

Comment on lines +117 to +119
if (!this._hasFocused()) {
this._pattern.setDefaultState();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of this ? setDefaultState is empty

private _hasFocused = signal(false);

/** The UI pattern instance for this spinbutton. */
readonly _pattern: SpinButtonPattern = new SpinButtonPattern({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's not private

Suggested change
readonly _pattern: SpinButtonPattern = new SpinButtonPattern({
readonly pattern: SpinButtonPattern = new SpinButtonPattern({

}

// @public
export class SpinButtonPattern {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it really belong in the public API?

}

/** Called when the input receives focus. */
_onFocus(): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this supposed to be private ?

}

/** Handles pointerdown events for the spinbutton. */
onPointerdown(_event: PointerEvent): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be protected ?

readonly spinButton = inject(SPINBUTTON);

/** Whether the increment button should be disabled. */
readonly _isDisabled = computed(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
readonly _isDisabled = computed(() => {
protected readonly _isDisabled = computed(() => {

});

/** Handles click events on the increment button. */
_onClick(): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_onClick(): void {
protected _onClick(): void {

readonly spinButton = inject(SPINBUTTON);

/** Whether the decrement button should be disabled. */
readonly _isDisabled = computed(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
readonly _isDisabled = computed(() => {
protected readonly _isDisabled = computed(() => {

});

/** Handles click events on the decrement button. */
_onClick(): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_onClick(): void {
protected _onClick(): void {

Comment on lines +29 to +30
'[attr.aria-controls]': 'spinButton.inputId()',
'[attr.aria-disabled]': '_isDisabled() || null',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now supported out of the box (since 20.2)

Suggested change
'[attr.aria-controls]': 'spinButton.inputId()',
'[attr.aria-disabled]': '_isDisabled() || null',
'[aria-controls]': 'spinButton.inputId()',
'[aria-disabled]': '_isDisabled() || null',

}

/** Sets the spinbutton to its default initial state. */
setDefaultState(): void {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is an implementation missing here ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

detected: feature PR contains a feature commit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants