Skip to content

Commit 8a1a79f

Browse files
committed
feat(aria/spinbutton): add spinbutton primitive
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
1 parent 454ff0f commit 8a1a79f

32 files changed

+1819
-0
lines changed

.ng-dev/commit-message.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const commitMessage: CommitMessageConfig = {
1414
'aria/grid',
1515
'aria/listbox',
1616
'aria/menu',
17+
'aria/spinbutton',
1718
'aria/tabs',
1819
'aria/toolbar',
1920
'aria/tree',

goldens/aria/private/index.api.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,43 @@ export function signal<T>(initialValue: T): WritableSignalLike<T>;
679679
// @public (undocumented)
680680
export type SignalLike<T> = () => T;
681681

682+
// @public
683+
export interface SpinButtonInputs {
684+
disabled: SignalLike<boolean>;
685+
id: SignalLike<string>;
686+
inputElement: SignalLike<HTMLElement | undefined>;
687+
max: SignalLike<number | undefined>;
688+
min: SignalLike<number | undefined>;
689+
pageStep: SignalLike<number | undefined>;
690+
readonly: SignalLike<boolean>;
691+
step: SignalLike<number>;
692+
value: WritableSignalLike<number>;
693+
valueText: SignalLike<string | undefined>;
694+
wrap: SignalLike<boolean>;
695+
}
696+
697+
// @public
698+
export class SpinButtonPattern {
699+
constructor(inputs: SpinButtonInputs);
700+
readonly ariaValueNow: SignalLike<number>;
701+
readonly atMax: SignalLike<boolean>;
702+
readonly atMin: SignalLike<boolean>;
703+
decrement(): void;
704+
decrementByPage(): void;
705+
goToMax(): void;
706+
goToMin(): void;
707+
increment(): void;
708+
incrementByPage(): void;
709+
readonly inputs: SpinButtonInputs;
710+
readonly invalid: SignalLike<boolean>;
711+
readonly keydown: SignalLike<KeyboardEventManager<KeyboardEvent>>;
712+
onKeydown(event: KeyboardEvent): void;
713+
onPointerdown(_event: PointerEvent): void;
714+
setDefaultState(): void;
715+
readonly tabIndex: SignalLike<-1 | 0>;
716+
validate(): string[];
717+
}
718+
682719
// @public
683720
export interface TabInputs extends Omit<ListNavigationItem, 'index'>, Omit<ExpansionItem, 'expandable'> {
684721
tablist: SignalLike<TabListPattern>;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
## API Report File for "@angular/aria_spinbutton"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
import * as _angular_core from '@angular/core';
8+
9+
// @public
10+
export class SpinButton {
11+
constructor();
12+
decrement(): void;
13+
decrementByPage(): void;
14+
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
15+
readonly element: HTMLElement;
16+
goToMax(): void;
17+
goToMin(): void;
18+
increment(): void;
19+
incrementByPage(): void;
20+
readonly inputId: _angular_core.InputSignal<string>;
21+
readonly max: _angular_core.InputSignal<number | undefined>;
22+
readonly min: _angular_core.InputSignal<number | undefined>;
23+
_onFocus(): void;
24+
readonly pageStep: _angular_core.InputSignal<number | undefined>;
25+
readonly _pattern: SpinButtonPattern;
26+
readonly readonly: _angular_core.InputSignalWithTransform<boolean, unknown>;
27+
readonly step: _angular_core.InputSignal<number>;
28+
readonly value: _angular_core.ModelSignal<number>;
29+
readonly valueText: _angular_core.InputSignal<string | undefined>;
30+
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
31+
// (undocumented)
32+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<SpinButton, "[ngSpinButton]", ["ngSpinButton"], { "inputId": { "alias": "inputId"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "min": { "alias": "min"; "required": false; "isSignal": true; }; "max": { "alias": "max"; "required": false; "isSignal": true; }; "step": { "alias": "step"; "required": false; "isSignal": true; }; "pageStep": { "alias": "pageStep"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "valueText": { "alias": "valueText"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_inputChild"], never, true, never>;
33+
// (undocumented)
34+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButton, never>;
35+
}
36+
37+
// @public
38+
export class SpinButtonDecrement {
39+
readonly _isDisabled: _angular_core.Signal<boolean>;
40+
_onClick(): void;
41+
readonly spinButton: SpinButton;
42+
// (undocumented)
43+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<SpinButtonDecrement, "[ngSpinButtonDecrement]", ["ngSpinButtonDecrement"], {}, {}, never, never, true, never>;
44+
// (undocumented)
45+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButtonDecrement, never>;
46+
}
47+
48+
// @public
49+
export class SpinButtonIncrement {
50+
readonly _isDisabled: _angular_core.Signal<boolean>;
51+
_onClick(): void;
52+
readonly spinButton: SpinButton;
53+
// (undocumented)
54+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<SpinButtonIncrement, "[ngSpinButtonIncrement]", ["ngSpinButtonIncrement"], {}, {}, never, never, true, never>;
55+
// (undocumented)
56+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButtonIncrement, never>;
57+
}
58+
59+
// @public
60+
export class SpinButtonInput {
61+
constructor();
62+
// (undocumented)
63+
readonly element: HTMLElement;
64+
// (undocumented)
65+
readonly inputmode: _angular_core.InputSignal<string | null>;
66+
// (undocumented)
67+
readonly _isNativeInput: boolean;
68+
// (undocumented)
69+
_onChange(event: Event): void;
70+
// (undocumented)
71+
_onInput(event: Event): void;
72+
// (undocumented)
73+
_onKeydown(event: KeyboardEvent): void;
74+
// (undocumented)
75+
readonly spinButton: SpinButton;
76+
// (undocumented)
77+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<SpinButtonInput, "[ngSpinButtonInput]", ["ngSpinButtonInput"], { "inputmode": { "alias": "inputmode"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
78+
// (undocumented)
79+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButtonInput, never>;
80+
}
81+
82+
// (No @packageDocumentation comment for this package)
83+
84+
```

src/aria/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ARIA_ENTRYPOINTS = [
55
"grid",
66
"listbox",
77
"menu",
8+
"spinbutton",
89
"tabs",
910
"toolbar",
1011
"tree",

src/aria/private/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ts_project(
1717
"//src/aria/private/grid",
1818
"//src/aria/private/listbox",
1919
"//src/aria/private/menu",
20+
"//src/aria/private/spinbutton",
2021
"//src/aria/private/tabs",
2122
"//src/aria/private/toolbar",
2223
"//src/aria/private/tree",

src/aria/private/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export * from './grid/row';
2525
export * from './grid/cell';
2626
export * from './grid/widget';
2727
export * from './deferred-content';
28+
export * from './spinbutton/spinbutton';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
load("//tools:defaults.bzl", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "spinbutton",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//src/aria/private/behaviors/event-manager",
13+
"//src/aria/private/behaviors/signal-like",
14+
],
15+
)
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {KeyboardEventManager} from '../behaviors/event-manager';
10+
import {SignalLike, WritableSignalLike, computed} from '../behaviors/signal-like/signal-like';
11+
12+
/** Represents the required inputs for a spinbutton. */
13+
export interface SpinButtonInputs {
14+
/** A unique identifier for the spinbutton input element. */
15+
id: SignalLike<string>;
16+
17+
/** The current numeric value of the spinbutton. */
18+
value: WritableSignalLike<number>;
19+
20+
/** The minimum allowed value. */
21+
min: SignalLike<number | undefined>;
22+
23+
/** The maximum allowed value. */
24+
max: SignalLike<number | undefined>;
25+
26+
/** The amount to increment or decrement by. */
27+
step: SignalLike<number>;
28+
29+
/** The amount to increment or decrement by for page up/down. */
30+
pageStep: SignalLike<number | undefined>;
31+
32+
/** Whether the spinbutton is disabled. */
33+
disabled: SignalLike<boolean>;
34+
35+
/** Whether the spinbutton is readonly. */
36+
readonly: SignalLike<boolean>;
37+
38+
/** Whether to wrap the value at boundaries. */
39+
wrap: SignalLike<boolean>;
40+
41+
/** Human-readable value text for aria-valuetext. */
42+
valueText: SignalLike<string | undefined>;
43+
44+
/** Reference to the input element. */
45+
inputElement: SignalLike<HTMLElement | undefined>;
46+
}
47+
48+
/** Controls the state of a spinbutton. */
49+
export class SpinButtonPattern {
50+
/** The inputs for this spinbutton pattern. */
51+
readonly inputs: SpinButtonInputs;
52+
53+
/** The tab index of the spinbutton input. */
54+
readonly tabIndex = computed(() => (this.inputs.disabled() ? -1 : 0));
55+
56+
/** The current numeric value for aria-valuenow. */
57+
readonly ariaValueNow = computed(() => this.inputs.value());
58+
59+
/** Whether the current value is invalid (outside min/max bounds). */
60+
readonly invalid = computed(() => {
61+
const value = this.inputs.value();
62+
const min = this.inputs.min();
63+
const max = this.inputs.max();
64+
return (min !== undefined && value < min) || (max !== undefined && value > max);
65+
});
66+
67+
/** Whether the value is at the minimum. */
68+
readonly atMin = computed(() => {
69+
const min = this.inputs.min();
70+
return min !== undefined && this.inputs.value() <= min;
71+
});
72+
73+
/** Whether the value is at the maximum. */
74+
readonly atMax = computed(() => {
75+
const max = this.inputs.max();
76+
return max !== undefined && this.inputs.value() >= max;
77+
});
78+
79+
/** The keydown event manager for the spinbutton. */
80+
readonly keydown = computed(() => {
81+
return new KeyboardEventManager()
82+
.on('ArrowUp', () => this.increment())
83+
.on('ArrowDown', () => this.decrement())
84+
.on('Home', () => this.goToMin())
85+
.on('End', () => this.goToMax())
86+
.on('PageUp', () => this.incrementByPage())
87+
.on('PageDown', () => this.decrementByPage());
88+
});
89+
90+
constructor(inputs: SpinButtonInputs) {
91+
this.inputs = inputs;
92+
}
93+
94+
/** Whether the spinbutton value can be modified. */
95+
private _canModify(): boolean {
96+
return !this.inputs.disabled() && !this.inputs.readonly();
97+
}
98+
99+
/** Validates the spinbutton configuration and returns a list of violations. */
100+
validate(): string[] {
101+
const min = this.inputs.min();
102+
const max = this.inputs.max();
103+
if (min !== undefined && max !== undefined && min > max) {
104+
return [`Spinbutton has invalid bounds: min (${min}) is greater than max (${max}).`];
105+
}
106+
return [];
107+
}
108+
109+
/** Sets the spinbutton to its default initial state. */
110+
setDefaultState(): void {}
111+
112+
/** Handles keydown events for the spinbutton. */
113+
onKeydown(event: KeyboardEvent): void {
114+
if (this._canModify()) {
115+
this.keydown().handle(event);
116+
}
117+
}
118+
119+
/** Handles pointerdown events for the spinbutton. */
120+
onPointerdown(_event: PointerEvent): void {
121+
const element = this.inputs.inputElement();
122+
if (element && !this.inputs.disabled()) {
123+
element.focus();
124+
}
125+
}
126+
127+
/** Increments the value by the step amount. */
128+
increment(): void {
129+
if (this._canModify()) {
130+
this._adjustValue(this.inputs.step());
131+
}
132+
}
133+
134+
/** Decrements the value by the step amount. */
135+
decrement(): void {
136+
if (this._canModify()) {
137+
this._adjustValue(-this.inputs.step());
138+
}
139+
}
140+
141+
/** Increments the value by the page step amount. */
142+
incrementByPage(): void {
143+
if (this._canModify()) {
144+
this._adjustValue(this.inputs.pageStep() ?? this.inputs.step() * 10);
145+
}
146+
}
147+
148+
/** Decrements the value by the page step amount. */
149+
decrementByPage(): void {
150+
if (this._canModify()) {
151+
this._adjustValue(-(this.inputs.pageStep() ?? this.inputs.step() * 10));
152+
}
153+
}
154+
155+
/** Sets the value to the minimum. */
156+
goToMin(): void {
157+
const min = this.inputs.min();
158+
if (this._canModify() && min !== undefined) {
159+
this.inputs.value.set(min);
160+
}
161+
}
162+
163+
/** Sets the value to the maximum. */
164+
goToMax(): void {
165+
const max = this.inputs.max();
166+
if (this._canModify() && max !== undefined) {
167+
this.inputs.value.set(max);
168+
}
169+
}
170+
171+
/** Adjusts the value by the given delta, respecting bounds and wrap behavior. */
172+
private _adjustValue(delta: number): void {
173+
const min = this.inputs.min();
174+
const max = this.inputs.max();
175+
let newValue = this.inputs.value() + delta;
176+
177+
if (this.inputs.wrap() && min !== undefined && max !== undefined) {
178+
const range = max - min + 1;
179+
newValue = min + ((((newValue - min) % range) + range) % range);
180+
} else {
181+
if (min !== undefined) newValue = Math.max(min, newValue);
182+
if (max !== undefined) newValue = Math.min(max, newValue);
183+
}
184+
185+
this.inputs.value.set(newValue);
186+
}
187+
}

0 commit comments

Comments
 (0)