Skip to content

Commit 8f419d2

Browse files
committed
feat(aria/spinbutton): add aria spinbutton component
Introduces the aria spinbutton primitive with SpinButtonGroup for automatic button connection using contentChildren, eliminating the need for template reference variables. Usage: ```html <div ngSpinButtonGroup> <button ngSpinButtonDecrement>−</button> <input ngSpinButton [(value)]="value"> <button ngSpinButtonIncrement>+</button> </div> ``` - Add SpinButton directive with full ARIA spinbutton pattern - Add SpinButtonGroup directive with contentChildren discovery - Add SpinButtonIncrement and SpinButtonDecrement directives - Add incrementDisabled/decrementDisabled computed signals to pattern - Add public increment/decrement methods to pattern
1 parent 1cef533 commit 8f419d2

29 files changed

Lines changed: 1725 additions & 1 deletion

.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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ ARIA_ENTRYPOINTS = [
55
"grid",
66
"listbox",
77
"menu",
8+
"private",
9+
"spinbutton",
810
"tabs",
911
"toolbar",
1012
"tree",
11-
"private",
1213
]
1314

1415
# List of all entry-point targets of the Angular Aria package.

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
"//:node_modules/@angular/core",
13+
"//src/aria/private/behaviors/event-manager",
14+
"//src/aria/private/behaviors/signal-like",
15+
],
16+
)
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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 {computed, SignalLike, WritableSignalLike} 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. */
15+
id: SignalLike<string>;
16+
17+
/** The current value. Undefined represents an indeterminate state. */
18+
value: WritableSignalLike<number | undefined>;
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 step increment for arrow keys. */
27+
step: SignalLike<number>;
28+
29+
/** The large step increment for Page Up/Down. Undefined means Page Up/Down are ignored. */
30+
largeStep: SignalLike<number | undefined>;
31+
32+
/** Whether to wrap from max→min and min→max. */
33+
wrap: SignalLike<boolean>;
34+
35+
/** Whether the spinbutton is disabled. */
36+
disabled: SignalLike<boolean>;
37+
38+
/** Whether the spinbutton is readonly. */
39+
readonly: SignalLike<boolean>;
40+
41+
/** Whether the spinbutton value is invalid. */
42+
invalid: SignalLike<boolean>;
43+
44+
/** Whether the spinbutton is required. */
45+
required: SignalLike<boolean>;
46+
47+
/**
48+
* Human-readable text alternative to the value.
49+
* When provided, this is used for aria-valuetext.
50+
* When not provided, aria-valuetext uses the numeric value with proper formatting.
51+
*/
52+
valueText: SignalLike<string | undefined>;
53+
54+
/** Reference to the host element. */
55+
element: SignalLike<HTMLElement>;
56+
}
57+
58+
/** Controls the state of a spinbutton. */
59+
export class SpinButtonPattern {
60+
/** The tabindex of the spinbutton. */
61+
tabIndex = computed(() => (this.inputs.disabled() ? -1 : 0));
62+
63+
/** Whether the spinbutton is disabled. */
64+
disabled = computed(() => this.inputs.disabled() || null);
65+
66+
/** Whether the spinbutton is readonly. */
67+
readonly = computed(() => this.inputs.readonly() || null);
68+
69+
/** Whether the spinbutton value is invalid. */
70+
invalid = computed(() => this.inputs.invalid() || null);
71+
72+
/** Whether the spinbutton is required. */
73+
required = computed(() => this.inputs.required() || null);
74+
75+
/** The current value for aria-valuenow. */
76+
valueNow = computed(() => this.inputs.value() ?? null);
77+
78+
/** The minimum value for aria-valuemin. */
79+
valueMin = computed(() => this.inputs.min() ?? null);
80+
81+
/** The maximum value for aria-valuemax. */
82+
valueMax = computed(() => this.inputs.max() ?? null);
83+
84+
/** Whether increment is disabled (spinbutton disabled or at max without wrap). */
85+
incrementDisabled = computed(() => {
86+
if (this.inputs.disabled()) return true;
87+
const value = this.inputs.value();
88+
const max = this.inputs.max();
89+
if (max === undefined || this.inputs.wrap()) return false;
90+
return value !== undefined && value >= max;
91+
});
92+
93+
/** Whether decrement is disabled (spinbutton disabled or at min without wrap). */
94+
decrementDisabled = computed(() => {
95+
if (this.inputs.disabled()) return true;
96+
const value = this.inputs.value();
97+
const min = this.inputs.min();
98+
if (min === undefined || this.inputs.wrap()) return false;
99+
return value !== undefined && value <= min;
100+
});
101+
102+
/** The human-readable text for aria-valuetext. */
103+
valueText = computed(() => {
104+
const customText = this.inputs.valueText();
105+
if (customText !== undefined) {
106+
return customText;
107+
}
108+
109+
const value = this.inputs.value();
110+
if (value === undefined) {
111+
return null;
112+
}
113+
114+
// Replace hyphen-minus (U+002D) with Unicode minus sign (U+2212)
115+
// for proper screen reader announcement on macOS VoiceOver
116+
return String(value).replace('-', '\u2212');
117+
});
118+
119+
/** The keydown event manager for the spinbutton. */
120+
keydown = computed(() => {
121+
const manager = new KeyboardEventManager();
122+
123+
if (this.inputs.disabled() || this.inputs.readonly()) {
124+
return manager;
125+
}
126+
127+
return manager
128+
.on('ArrowUp', () => this._increment(this.inputs.step()))
129+
.on('ArrowDown', () => this._decrement(this.inputs.step()))
130+
.on('PageUp', () => {
131+
const largeStep = this.inputs.largeStep();
132+
if (largeStep !== undefined) {
133+
this._increment(largeStep);
134+
}
135+
})
136+
.on('PageDown', () => {
137+
const largeStep = this.inputs.largeStep();
138+
if (largeStep !== undefined) {
139+
this._decrement(largeStep);
140+
}
141+
})
142+
.on('Home', () => {
143+
const min = this.inputs.min();
144+
if (min !== undefined) {
145+
this.inputs.value.set(min);
146+
}
147+
})
148+
.on('End', () => {
149+
const max = this.inputs.max();
150+
if (max !== undefined) {
151+
this.inputs.value.set(max);
152+
}
153+
});
154+
});
155+
156+
constructor(readonly inputs: SpinButtonInputs) {}
157+
158+
/** Returns a set of violations. */
159+
validate(): string[] {
160+
const violations: string[] = [];
161+
const min = this.inputs.min();
162+
const max = this.inputs.max();
163+
const value = this.inputs.value();
164+
const wrap = this.inputs.wrap();
165+
166+
if (min !== undefined && max !== undefined && min > max) {
167+
violations.push(`SpinButton: min (${min}) cannot be greater than max (${max}).`);
168+
}
169+
170+
if (wrap && (min === undefined || max === undefined)) {
171+
violations.push(`SpinButton: wrap requires both min and max to be defined.`);
172+
}
173+
174+
if (value !== undefined) {
175+
if (min !== undefined && value < min) {
176+
violations.push(`SpinButton: value (${value}) is less than min (${min}).`);
177+
}
178+
if (max !== undefined && value > max) {
179+
violations.push(`SpinButton: value (${value}) is greater than max (${max}).`);
180+
}
181+
}
182+
183+
return violations;
184+
}
185+
186+
/** Handles keydown events for the spinbutton. */
187+
onKeydown(event: KeyboardEvent): void {
188+
this.keydown().handle(event);
189+
}
190+
191+
/** Increments the value by the configured step. */
192+
increment(): void {
193+
this._increment(this.inputs.step());
194+
}
195+
196+
/** Decrements the value by the configured step. */
197+
decrement(): void {
198+
this._decrement(this.inputs.step());
199+
}
200+
201+
/** Increments the value by the given step amount. */
202+
private _increment(step: number): void {
203+
const currentValue = this._getInitializedValue();
204+
const max = this.inputs.max();
205+
const min = this.inputs.min();
206+
const wrap = this.inputs.wrap();
207+
208+
let newValue = currentValue + step;
209+
210+
if (max !== undefined && newValue > max) {
211+
if (wrap && min !== undefined) {
212+
newValue = min;
213+
} else {
214+
newValue = max;
215+
}
216+
}
217+
218+
this.inputs.value.set(newValue);
219+
}
220+
221+
/** Decrements the value by the given step amount. */
222+
private _decrement(step: number): void {
223+
const currentValue = this._getInitializedValue();
224+
const min = this.inputs.min();
225+
const max = this.inputs.max();
226+
const wrap = this.inputs.wrap();
227+
228+
let newValue = currentValue - step;
229+
230+
if (min !== undefined && newValue < min) {
231+
if (wrap && max !== undefined) {
232+
newValue = max;
233+
} else {
234+
newValue = min;
235+
}
236+
}
237+
238+
this.inputs.value.set(newValue);
239+
}
240+
241+
/** Gets the current value, initializing to min ?? 0 if undefined. */
242+
private _getInitializedValue(): number {
243+
const value = this.inputs.value();
244+
if (value === undefined) {
245+
const initialValue = this.inputs.min() ?? 0;
246+
this.inputs.value.set(initialValue);
247+
return initialValue;
248+
}
249+
return value;
250+
}
251+
}

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 {SpinButtonIncrement} from './spinbutton-increment';
11+
export {SpinButtonDecrement} from './spinbutton-decrement';
12+
export {SpinButtonGroup} from './spinbutton-group';

0 commit comments

Comments
 (0)