Skip to content

Commit 249c6f2

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 249c6f2

30 files changed

Lines changed: 1683 additions & 0 deletions

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

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+
}

src/aria/spinbutton/BUILD.bazel

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
load("//tools:defaults.bzl", "extract_api_to_json", "ng_project", "ng_web_test_suite")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "spinbutton",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/aria/private",
14+
"//src/cdk/a11y",
15+
],
16+
)
17+
18+
ng_project(
19+
name = "unit_test_sources",
20+
testonly = True,
21+
srcs = glob(
22+
["**/*.spec.ts"],
23+
exclude = ["**/*.e2e.spec.ts"],
24+
),
25+
deps = [
26+
":spinbutton",
27+
"//:node_modules/@angular/core",
28+
"//:node_modules/@angular/platform-browser",
29+
"//:node_modules/axe-core",
30+
"//src/cdk/testing/private",
31+
],
32+
)
33+
34+
ng_web_test_suite(
35+
name = "unit_tests",
36+
deps = [":unit_test_sources"],
37+
)
38+
39+
filegroup(
40+
name = "source-files",
41+
srcs = glob(
42+
["**/*.ts"],
43+
exclude = ["**/*.spec.ts"],
44+
),
45+
)
46+
47+
extract_api_to_json(
48+
name = "json_api",
49+
srcs = [
50+
":source-files",
51+
],
52+
entry_point = ":index.ts",
53+
module_name = "@angular/aria/spinbutton",
54+
output_name = "aria-spinbutton.json",
55+
private_modules = [""],
56+
repo = "angular/components",
57+
)

src/aria/spinbutton/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
export * from './public-api';

src/aria/spinbutton/public-api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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+
export {SpinButton} from './spinbutton';
10+
export {SpinButtonInput} from './spinbutton-input';
11+
export {SpinButtonIncrement} from './spinbutton-increment';
12+
export {SpinButtonDecrement} from './spinbutton-decrement';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 {computed, Directive, inject} from '@angular/core';
10+
import {SPINBUTTON} from './spinbutton-tokens';
11+
12+
/**
13+
* A button that decrements the value of a spinbutton.
14+
*
15+
* This directive should be applied to a button element within an `ngSpinButton` container.
16+
* It automatically manages the `aria-controls` attribute and disables the button when
17+
* the value is at the minimum (unless wrap is enabled).
18+
*
19+
* ```html
20+
* <button ngSpinButtonDecrement>-</button>
21+
* ```
22+
*
23+
* @developerPreview 21.0
24+
*/
25+
@Directive({
26+
selector: '[ngSpinButtonDecrement]',
27+
exportAs: 'ngSpinButtonDecrement',
28+
host: {
29+
'[attr.aria-controls]': 'spinButton.inputId()',
30+
'[attr.aria-disabled]': '_isDisabled() || null',
31+
'[attr.tabindex]': '-1',
32+
'(click)': '_onClick()',
33+
},
34+
})
35+
export class SpinButtonDecrement {
36+
/** The parent spinbutton container. */
37+
readonly spinButton = inject(SPINBUTTON);
38+
39+
/** Whether the decrement button should be disabled. */
40+
readonly _isDisabled = computed(() => {
41+
if (this.spinButton.disabled() || this.spinButton.readonly()) return true;
42+
if (this.spinButton.wrap()) return false;
43+
return this.spinButton._pattern.atMin();
44+
});
45+
46+
/** Handles click events on the decrement button. */
47+
_onClick(): void {
48+
if (!this._isDisabled()) {
49+
this.spinButton.decrement();
50+
}
51+
}
52+
}

0 commit comments

Comments
 (0)