diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index c868f0e0b1d4..c57f663bcc8e 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -14,6 +14,7 @@ export const commitMessage: CommitMessageConfig = { 'aria/grid', 'aria/listbox', 'aria/menu', + 'aria/spinbutton', 'aria/tabs', 'aria/toolbar', 'aria/tree', diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 9483cc1d0c39..93a14eba1070 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -679,6 +679,43 @@ export function signal(initialValue: T): WritableSignalLike; // @public (undocumented) export type SignalLike = () => T; +// @public +export interface SpinButtonInputs { + disabled: SignalLike; + id: SignalLike; + inputElement: SignalLike; + max: SignalLike; + min: SignalLike; + pageStep: SignalLike; + readonly: SignalLike; + step: SignalLike; + value: WritableSignalLike; + valueText: SignalLike; + wrap: SignalLike; +} + +// @public +export class SpinButtonPattern { + constructor(inputs: SpinButtonInputs); + readonly ariaValueNow: SignalLike; + readonly atMax: SignalLike; + readonly atMin: SignalLike; + decrement(): void; + decrementByPage(): void; + goToMax(): void; + goToMin(): void; + increment(): void; + incrementByPage(): void; + readonly inputs: SpinButtonInputs; + readonly invalid: SignalLike; + readonly keydown: SignalLike>; + onKeydown(event: KeyboardEvent): void; + onPointerdown(_event: PointerEvent): void; + setDefaultState(): void; + readonly tabIndex: SignalLike<-1 | 0>; + validate(): string[]; +} + // @public export interface TabInputs extends Omit, Omit { tablist: SignalLike; diff --git a/goldens/aria/spinbutton/index.api.md b/goldens/aria/spinbutton/index.api.md new file mode 100644 index 000000000000..a211f48722b1 --- /dev/null +++ b/goldens/aria/spinbutton/index.api.md @@ -0,0 +1,84 @@ +## API Report File for "@angular/aria_spinbutton" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as _angular_core from '@angular/core'; + +// @public +export class SpinButton { + constructor(); + decrement(): void; + decrementByPage(): void; + readonly disabled: _angular_core.InputSignalWithTransform; + readonly element: HTMLElement; + goToMax(): void; + goToMin(): void; + increment(): void; + incrementByPage(): void; + readonly inputId: _angular_core.InputSignal; + readonly max: _angular_core.InputSignal; + readonly min: _angular_core.InputSignal; + _onFocus(): void; + readonly pageStep: _angular_core.InputSignal; + readonly _pattern: SpinButtonPattern; + readonly readonly: _angular_core.InputSignalWithTransform; + readonly step: _angular_core.InputSignal; + readonly value: _angular_core.ModelSignal; + readonly valueText: _angular_core.InputSignal; + readonly wrap: _angular_core.InputSignalWithTransform; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SpinButtonDecrement { + readonly _isDisabled: _angular_core.Signal; + _onClick(): void; + readonly spinButton: SpinButton; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SpinButtonIncrement { + readonly _isDisabled: _angular_core.Signal; + _onClick(): void; + readonly spinButton: SpinButton; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SpinButtonInput { + constructor(); + // (undocumented) + readonly element: HTMLElement; + // (undocumented) + readonly inputmode: _angular_core.InputSignal; + // (undocumented) + readonly _isNativeInput: boolean; + // (undocumented) + _onChange(event: Event): void; + // (undocumented) + _onInput(event: Event): void; + // (undocumented) + _onKeydown(event: KeyboardEvent): void; + // (undocumented) + readonly spinButton: SpinButton; + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 291412b5a3fb..01733a953834 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -5,6 +5,7 @@ ARIA_ENTRYPOINTS = [ "grid", "listbox", "menu", + "spinbutton", "tabs", "toolbar", "tree", diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index f688ab1b20e1..59b393825bea 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -17,6 +17,7 @@ ts_project( "//src/aria/private/grid", "//src/aria/private/listbox", "//src/aria/private/menu", + "//src/aria/private/spinbutton", "//src/aria/private/tabs", "//src/aria/private/toolbar", "//src/aria/private/tree", diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index ed8716c7b67b..9df0c55e28e0 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -25,3 +25,4 @@ export * from './grid/row'; export * from './grid/cell'; export * from './grid/widget'; export * from './deferred-content'; +export * from './spinbutton/spinbutton'; diff --git a/src/aria/private/spinbutton/BUILD.bazel b/src/aria/private/spinbutton/BUILD.bazel new file mode 100644 index 000000000000..dcc7db57b50e --- /dev/null +++ b/src/aria/private/spinbutton/BUILD.bazel @@ -0,0 +1,15 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "spinbutton", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/aria/private/behaviors/event-manager", + "//src/aria/private/behaviors/signal-like", + ], +) diff --git a/src/aria/private/spinbutton/spinbutton.ts b/src/aria/private/spinbutton/spinbutton.ts new file mode 100644 index 000000000000..10d51ebbeb82 --- /dev/null +++ b/src/aria/private/spinbutton/spinbutton.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {KeyboardEventManager} from '../behaviors/event-manager'; +import {SignalLike, WritableSignalLike, computed} from '../behaviors/signal-like/signal-like'; + +/** Represents the required inputs for a spinbutton. */ +export interface SpinButtonInputs { + /** A unique identifier for the spinbutton input element. */ + id: SignalLike; + + /** The current numeric value of the spinbutton. */ + value: WritableSignalLike; + + /** The minimum allowed value. */ + min: SignalLike; + + /** The maximum allowed value. */ + max: SignalLike; + + /** The amount to increment or decrement by. */ + step: SignalLike; + + /** The amount to increment or decrement by for page up/down. */ + pageStep: SignalLike; + + /** Whether the spinbutton is disabled. */ + disabled: SignalLike; + + /** Whether the spinbutton is readonly. */ + readonly: SignalLike; + + /** Whether to wrap the value at boundaries. */ + wrap: SignalLike; + + /** Human-readable value text for aria-valuetext. */ + valueText: SignalLike; + + /** Reference to the input element. */ + inputElement: SignalLike; +} + +/** Controls the state of a spinbutton. */ +export class SpinButtonPattern { + /** The inputs for this spinbutton pattern. */ + readonly inputs: SpinButtonInputs; + + /** The tab index of the spinbutton input. */ + readonly tabIndex = computed(() => (this.inputs.disabled() ? -1 : 0)); + + /** The current numeric value for aria-valuenow. */ + readonly ariaValueNow = computed(() => this.inputs.value()); + + /** Whether the current value is invalid (outside min/max bounds). */ + readonly invalid = computed(() => { + const value = this.inputs.value(); + const min = this.inputs.min(); + const max = this.inputs.max(); + return (min !== undefined && value < min) || (max !== undefined && value > max); + }); + + /** Whether the value is at the minimum. */ + readonly atMin = computed(() => { + const min = this.inputs.min(); + return min !== undefined && this.inputs.value() <= min; + }); + + /** Whether the value is at the maximum. */ + readonly atMax = computed(() => { + const max = this.inputs.max(); + return max !== undefined && this.inputs.value() >= max; + }); + + /** The keydown event manager for the spinbutton. */ + readonly keydown = computed(() => { + return new KeyboardEventManager() + .on('ArrowUp', () => this.increment()) + .on('ArrowDown', () => this.decrement()) + .on('Home', () => this.goToMin()) + .on('End', () => this.goToMax()) + .on('PageUp', () => this.incrementByPage()) + .on('PageDown', () => this.decrementByPage()); + }); + + constructor(inputs: SpinButtonInputs) { + this.inputs = inputs; + } + + /** Whether the spinbutton value can be modified. */ + private _canModify(): boolean { + return !this.inputs.disabled() && !this.inputs.readonly(); + } + + /** Validates the spinbutton configuration and returns a list of violations. */ + validate(): string[] { + const min = this.inputs.min(); + const max = this.inputs.max(); + if (min !== undefined && max !== undefined && min > max) { + return [`Spinbutton has invalid bounds: min (${min}) is greater than max (${max}).`]; + } + return []; + } + + /** Sets the spinbutton to its default initial state. */ + setDefaultState(): void {} + + /** Handles keydown events for the spinbutton. */ + onKeydown(event: KeyboardEvent): void { + if (this._canModify()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events for the spinbutton. */ + onPointerdown(_event: PointerEvent): void { + const element = this.inputs.inputElement(); + if (element && !this.inputs.disabled()) { + element.focus(); + } + } + + /** Increments the value by the step amount. */ + increment(): void { + if (this._canModify()) { + this._adjustValue(this.inputs.step()); + } + } + + /** Decrements the value by the step amount. */ + decrement(): void { + if (this._canModify()) { + this._adjustValue(-this.inputs.step()); + } + } + + /** Increments the value by the page step amount. */ + incrementByPage(): void { + if (this._canModify()) { + this._adjustValue(this.inputs.pageStep() ?? this.inputs.step() * 10); + } + } + + /** Decrements the value by the page step amount. */ + decrementByPage(): void { + if (this._canModify()) { + this._adjustValue(-(this.inputs.pageStep() ?? this.inputs.step() * 10)); + } + } + + /** Sets the value to the minimum. */ + goToMin(): void { + const min = this.inputs.min(); + if (this._canModify() && min !== undefined) { + this.inputs.value.set(min); + } + } + + /** Sets the value to the maximum. */ + goToMax(): void { + const max = this.inputs.max(); + if (this._canModify() && max !== undefined) { + this.inputs.value.set(max); + } + } + + /** Adjusts the value by the given delta, respecting bounds and wrap behavior. */ + private _adjustValue(delta: number): void { + const min = this.inputs.min(); + const max = this.inputs.max(); + let newValue = this.inputs.value() + delta; + + if (this.inputs.wrap() && min !== undefined && max !== undefined) { + const range = max - min + 1; + newValue = min + ((((newValue - min) % range) + range) % range); + } else { + if (min !== undefined) newValue = Math.max(min, newValue); + if (max !== undefined) newValue = Math.min(max, newValue); + } + + this.inputs.value.set(newValue); + } +} diff --git a/src/aria/spinbutton/BUILD.bazel b/src/aria/spinbutton/BUILD.bazel new file mode 100644 index 000000000000..4c105731fbd4 --- /dev/null +++ b/src/aria/spinbutton/BUILD.bazel @@ -0,0 +1,57 @@ +load("//tools:defaults.bzl", "extract_api_to_json", "ng_project", "ng_web_test_suite") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "spinbutton", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/private", + "//src/cdk/a11y", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":spinbutton", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/axe-core", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + +filegroup( + name = "source-files", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), +) + +extract_api_to_json( + name = "json_api", + srcs = [ + ":source-files", + ], + entry_point = ":index.ts", + module_name = "@angular/aria/spinbutton", + output_name = "aria-spinbutton.json", + private_modules = [""], + repo = "angular/components", +) diff --git a/src/aria/spinbutton/index.ts b/src/aria/spinbutton/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/aria/spinbutton/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/aria/spinbutton/public-api.ts b/src/aria/spinbutton/public-api.ts new file mode 100644 index 000000000000..e51b4d8f0941 --- /dev/null +++ b/src/aria/spinbutton/public-api.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {SpinButton} from './spinbutton'; +export {SpinButtonInput} from './spinbutton-input'; +export {SpinButtonIncrement} from './spinbutton-increment'; +export {SpinButtonDecrement} from './spinbutton-decrement'; diff --git a/src/aria/spinbutton/spinbutton-decrement.ts b/src/aria/spinbutton/spinbutton-decrement.ts new file mode 100644 index 000000000000..a5bdab1c71f7 --- /dev/null +++ b/src/aria/spinbutton/spinbutton-decrement.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, Directive, inject} from '@angular/core'; +import {SPINBUTTON} from './spinbutton-tokens'; + +/** + * A button that decrements the value of a spinbutton. + * + * This directive should be applied to a button element within an `ngSpinButton` container. + * It automatically manages the `aria-controls` attribute and disables the button when + * the value is at the minimum (unless wrap is enabled). + * + * ```html + * + * ``` + * + * @developerPreview 21.0 + */ +@Directive({ + selector: '[ngSpinButtonDecrement]', + exportAs: 'ngSpinButtonDecrement', + host: { + '[attr.aria-controls]': 'spinButton.inputId()', + '[attr.aria-disabled]': '_isDisabled() || null', + '[attr.tabindex]': '-1', + '(click)': '_onClick()', + }, +}) +export class SpinButtonDecrement { + /** The parent spinbutton container. */ + readonly spinButton = inject(SPINBUTTON); + + /** Whether the decrement button should be disabled. */ + readonly _isDisabled = computed(() => { + if (this.spinButton.disabled() || this.spinButton.readonly()) return true; + if (this.spinButton.wrap()) return false; + return this.spinButton._pattern.atMin(); + }); + + /** Handles click events on the decrement button. */ + _onClick(): void { + if (!this._isDisabled()) { + this.spinButton.decrement(); + } + } +} diff --git a/src/aria/spinbutton/spinbutton-increment.ts b/src/aria/spinbutton/spinbutton-increment.ts new file mode 100644 index 000000000000..c801bd3c3458 --- /dev/null +++ b/src/aria/spinbutton/spinbutton-increment.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, Directive, inject} from '@angular/core'; +import {SPINBUTTON} from './spinbutton-tokens'; + +/** + * A button that increments the value of a spinbutton. + * + * This directive should be applied to a button element within an `ngSpinButton` container. + * It automatically manages the `aria-controls` attribute and disables the button when + * the value is at the maximum (unless wrap is enabled). + * + * ```html + * + * ``` + * + * @developerPreview 21.0 + */ +@Directive({ + selector: '[ngSpinButtonIncrement]', + exportAs: 'ngSpinButtonIncrement', + host: { + '[attr.aria-controls]': 'spinButton.inputId()', + '[attr.aria-disabled]': '_isDisabled() || null', + '[attr.tabindex]': '-1', + '(click)': '_onClick()', + }, +}) +export class SpinButtonIncrement { + /** The parent spinbutton container. */ + readonly spinButton = inject(SPINBUTTON); + + /** Whether the increment button should be disabled. */ + readonly _isDisabled = computed(() => { + if (this.spinButton.disabled() || this.spinButton.readonly()) return true; + if (this.spinButton.wrap()) return false; + return this.spinButton._pattern.atMax(); + }); + + /** Handles click events on the increment button. */ + _onClick(): void { + if (!this._isDisabled()) { + this.spinButton.increment(); + } + } +} diff --git a/src/aria/spinbutton/spinbutton-input.ts b/src/aria/spinbutton/spinbutton-input.ts new file mode 100644 index 000000000000..ba44a2bb14b3 --- /dev/null +++ b/src/aria/spinbutton/spinbutton-input.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {afterRenderEffect, Directive, ElementRef, inject, input} from '@angular/core'; +import {SPINBUTTON, SPINBUTTON_INPUT} from './spinbutton-tokens'; + +/** + * The input element for a spinbutton widget. + * + * This directive should be applied to an input or span element within an `ngSpinButton` container. + * It handles the ARIA attributes, keyboard interactions, and value synchronization. + * + * ```html + * + * ``` + * + * @developerPreview 21.0 + */ +@Directive({ + selector: '[ngSpinButtonInput]', + exportAs: 'ngSpinButtonInput', + providers: [{provide: SPINBUTTON_INPUT, useExisting: SpinButtonInput}], + host: { + 'role': 'spinbutton', + '[attr.id]': 'spinButton.inputId()', + '[tabindex]': 'spinButton._pattern.tabIndex()', + '[attr.aria-valuenow]': 'spinButton._pattern.ariaValueNow()', + '[attr.aria-valuemin]': 'spinButton.min()', + '[attr.aria-valuemax]': 'spinButton.max()', + '[attr.aria-valuetext]': 'spinButton.valueText() || null', + '[attr.aria-disabled]': 'spinButton.disabled() || null', + '[attr.aria-readonly]': 'spinButton.readonly() || null', + '[attr.aria-invalid]': 'spinButton._pattern.invalid() || null', + '(keydown)': '_onKeydown($event)', + '(focusin)': 'spinButton._onFocus()', + '(input)': '_onInput($event)', + '(change)': '_onChange($event)', + '[attr.autocomplete]': '_isNativeInput ? "off" : null', + '[attr.autocorrect]': '_isNativeInput ? "off" : null', + '[attr.spellcheck]': '_isNativeInput ? "false" : null', + '[attr.inputmode]': '_isNativeInput ? inputmode() : null', + }, +}) +export class SpinButtonInput { + readonly element = inject(ElementRef).nativeElement as HTMLElement; + readonly spinButton = inject(SPINBUTTON); + readonly _isNativeInput = this.element.tagName === 'INPUT'; + readonly inputmode = input('numeric'); + + constructor() { + if (this._isNativeInput) { + afterRenderEffect(() => { + const value = this.spinButton.value(); + const input = this.element as HTMLInputElement; + if (input.value !== String(value)) { + input.value = String(value); + } + }); + } + } + + _onKeydown(event: KeyboardEvent): void { + if (this._isNativeInput && this._isNumericKey(event)) { + return; + } + this.spinButton._pattern.onKeydown(event); + } + + _onInput(event: Event): void { + if (!this._isNativeInput) return; + const input = event.target as HTMLInputElement; + const filtered = input.value.replace(/[^0-9\-]/g, ''); + if (filtered !== input.value) { + input.value = filtered; + } + } + + _onChange(event: Event): void { + if (!this._isNativeInput) return; + const input = event.target as HTMLInputElement; + const parsed = parseInt(input.value, 10); + if (!isNaN(parsed)) { + this.spinButton.value.set(parsed); + } else { + input.value = String(this.spinButton.value()); + } + } + + private _isNumericKey(event: KeyboardEvent): boolean { + if (event.ctrlKey || event.metaKey || event.altKey) return false; + return /^[0-9\-]$/.test(event.key) || ['Backspace', 'Delete', 'Tab'].includes(event.key); + } +} diff --git a/src/aria/spinbutton/spinbutton-tokens.ts b/src/aria/spinbutton/spinbutton-tokens.ts new file mode 100644 index 000000000000..310f439ec01c --- /dev/null +++ b/src/aria/spinbutton/spinbutton-tokens.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {InjectionToken} from '@angular/core'; +import type {SpinButton} from './spinbutton'; +import type {SpinButtonInput} from './spinbutton-input'; + +/** Token used to expose the spinbutton container. */ +export const SPINBUTTON = new InjectionToken('SPINBUTTON'); + +/** Token used to expose the spinbutton input element. */ +export const SPINBUTTON_INPUT = new InjectionToken('SPINBUTTON_INPUT'); diff --git a/src/aria/spinbutton/spinbutton.spec.ts b/src/aria/spinbutton/spinbutton.spec.ts new file mode 100644 index 000000000000..fc27a2780cd3 --- /dev/null +++ b/src/aria/spinbutton/spinbutton.spec.ts @@ -0,0 +1,526 @@ +import {Component, DebugElement, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {SpinButton} from './spinbutton'; +import {SpinButtonInput} from './spinbutton-input'; +import {SpinButtonIncrement} from './spinbutton-increment'; +import {SpinButtonDecrement} from './spinbutton-decrement'; + +interface ModifierKeys { + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +describe('SpinButton', () => { + let fixture: ComponentFixture; + let spinButtonDebugElement: DebugElement; + let spinButtonInputDebugElement: DebugElement; + let spinButtonInstance: SpinButton; + let spinButtonElement: HTMLElement; + let inputElement: HTMLElement; + let incrementButton: HTMLElement; + let decrementButton: HTMLElement; + + const keydown = (key: string, modifierKeys: ModifierKeys = {}) => { + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + }; + + const clickIncrement = () => { + incrementButton.click(); + fixture.detectChanges(); + }; + + const clickDecrement = () => { + decrementButton.click(); + fixture.detectChanges(); + }; + + const up = (modifierKeys?: ModifierKeys) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: ModifierKeys) => keydown('ArrowDown', modifierKeys); + const home = (modifierKeys?: ModifierKeys) => keydown('Home', modifierKeys); + const end = (modifierKeys?: ModifierKeys) => keydown('End', modifierKeys); + const pageUp = (modifierKeys?: ModifierKeys) => keydown('PageUp', modifierKeys); + const pageDown = (modifierKeys?: ModifierKeys) => keydown('PageDown', modifierKeys); + + function setupSpinButton(opts?: { + value?: number; + min?: number; + max?: number; + step?: number; + pageStep?: number; + disabled?: boolean; + readonly?: boolean; + wrap?: boolean; + valueText?: string; + }) { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr')], + }); + + fixture = TestBed.createComponent(SpinButtonExample); + const testComponent = fixture.componentInstance as SpinButtonExample; + + if (opts?.value !== undefined) testComponent.value.set(opts.value); + if (opts?.min !== undefined) testComponent.min = opts.min; + if (opts?.max !== undefined) testComponent.max = opts.max; + if (opts?.step !== undefined) testComponent.step = opts.step; + if (opts?.pageStep !== undefined) testComponent.pageStep = opts.pageStep; + if (opts?.disabled !== undefined) testComponent.disabled = opts.disabled; + if (opts?.readonly !== undefined) testComponent.readonly = opts.readonly; + if (opts?.wrap !== undefined) testComponent.wrap = opts.wrap; + if (opts?.valueText !== undefined) testComponent.valueText = opts.valueText; + + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function setupDefaultSpinButton() { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr')], + }); + + fixture = TestBed.createComponent(DefaultSpinButtonExample); + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function defineTestVariables(testFixture: ComponentFixture) { + spinButtonDebugElement = testFixture.debugElement.query(By.directive(SpinButton)); + spinButtonInputDebugElement = testFixture.debugElement.query(By.directive(SpinButtonInput)); + spinButtonInstance = spinButtonDebugElement.injector.get(SpinButton); + spinButtonElement = spinButtonDebugElement.nativeElement; + inputElement = spinButtonInputDebugElement.nativeElement; + incrementButton = testFixture.debugElement.query( + By.directive(SpinButtonIncrement), + ).nativeElement; + decrementButton = testFixture.debugElement.query( + By.directive(SpinButtonDecrement), + ).nativeElement; + } + + afterEach(async () => await runAccessibilityChecks(spinButtonElement)); + + describe('ARIA attributes and roles', () => { + describe('default configuration', () => { + beforeEach(() => setupDefaultSpinButton()); + + it('should correctly set the role attribute to "spinbutton" on the input element', () => { + expect(inputElement.getAttribute('role')).toBe('spinbutton'); + }); + + it('should set aria-valuenow to the current value', () => { + expect(inputElement.getAttribute('aria-valuenow')).toBe('0'); + }); + + it('should not set aria-disabled when not disabled', () => { + expect(inputElement.getAttribute('aria-disabled')).toBeNull(); + }); + + it('should not set aria-readonly when not readonly', () => { + expect(inputElement.getAttribute('aria-readonly')).toBeNull(); + }); + + it('should set tabindex="0" on the input element', () => { + expect(inputElement.getAttribute('tabindex')).toBe('0'); + }); + }); + + describe('custom configuration', () => { + it('should set aria-valuemin when min is provided', () => { + setupSpinButton({min: 0}); + expect(inputElement.getAttribute('aria-valuemin')).toBe('0'); + }); + + it('should set aria-valuemax when max is provided', () => { + setupSpinButton({max: 100}); + expect(inputElement.getAttribute('aria-valuemax')).toBe('100'); + }); + + it('should set aria-valuetext when valueText is provided', () => { + setupSpinButton({valueText: 'Five items'}); + expect(inputElement.getAttribute('aria-valuetext')).toBe('Five items'); + }); + + it('should set aria-disabled to "true" when disabled', () => { + setupSpinButton({disabled: true}); + expect(inputElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set aria-readonly to "true" when readonly', () => { + setupSpinButton({readonly: true}); + expect(inputElement.getAttribute('aria-readonly')).toBe('true'); + }); + + it('should set tabindex="-1" when disabled', () => { + setupSpinButton({disabled: true}); + expect(inputElement.getAttribute('tabindex')).toBe('-1'); + }); + }); + + describe('button aria-controls', () => { + beforeEach(() => setupDefaultSpinButton()); + + it('should set aria-controls on increment button to reference input id', () => { + const inputId = inputElement.getAttribute('id'); + expect(incrementButton.getAttribute('aria-controls')).toBe(inputId); + }); + + it('should set aria-controls on decrement button to reference input id', () => { + const inputId = inputElement.getAttribute('id'); + expect(decrementButton.getAttribute('aria-controls')).toBe(inputId); + }); + }); + }); + + describe('keyboard navigation', () => { + it('should increment value on ArrowUp', () => { + setupSpinButton({value: 5}); + up(); + expect(spinButtonInstance.value()).toBe(6); + }); + + it('should decrement value on ArrowDown', () => { + setupSpinButton({value: 5}); + down(); + expect(spinButtonInstance.value()).toBe(4); + }); + + it('should increment by step amount', () => { + setupSpinButton({value: 5, step: 5}); + up(); + expect(spinButtonInstance.value()).toBe(10); + }); + + it('should decrement by step amount', () => { + setupSpinButton({value: 10, step: 5}); + down(); + expect(spinButtonInstance.value()).toBe(5); + }); + + it('should go to minimum value on Home', () => { + setupSpinButton({value: 50, min: 0, max: 100}); + home(); + expect(spinButtonInstance.value()).toBe(0); + }); + + it('should go to maximum value on End', () => { + setupSpinButton({value: 50, min: 0, max: 100}); + end(); + expect(spinButtonInstance.value()).toBe(100); + }); + + it('should increment by page step on PageUp', () => { + setupSpinButton({value: 5, step: 1, pageStep: 10}); + pageUp(); + expect(spinButtonInstance.value()).toBe(15); + }); + + it('should decrement by page step on PageDown', () => { + setupSpinButton({value: 15, step: 1, pageStep: 10}); + pageDown(); + expect(spinButtonInstance.value()).toBe(5); + }); + + it('should use step * 10 as default page step', () => { + setupSpinButton({value: 5, step: 2}); + pageUp(); + expect(spinButtonInstance.value()).toBe(25); // 5 + 2*10 + }); + + it('should not respond to keyboard when disabled', () => { + setupSpinButton({value: 5, disabled: true}); + up(); + expect(spinButtonInstance.value()).toBe(5); + }); + + it('should not respond to keyboard when readonly', () => { + setupSpinButton({value: 5, readonly: true}); + up(); + expect(spinButtonInstance.value()).toBe(5); + }); + }); + + describe('value boundaries', () => { + it('should not exceed maximum value (clamping)', () => { + setupSpinButton({value: 10, max: 10}); + up(); + expect(spinButtonInstance.value()).toBe(10); + }); + + it('should not go below minimum value (clamping)', () => { + setupSpinButton({value: 0, min: 0}); + down(); + expect(spinButtonInstance.value()).toBe(0); + }); + + it('should set aria-invalid when value exceeds max', () => { + setupSpinButton({value: 15, max: 10}); + expect(inputElement.getAttribute('aria-invalid')).toBe('true'); + }); + + it('should set aria-invalid when value is below min', () => { + setupSpinButton({value: -5, min: 0}); + expect(inputElement.getAttribute('aria-invalid')).toBe('true'); + }); + + it('should not set aria-invalid when value is within bounds', () => { + setupSpinButton({value: 5, min: 0, max: 10}); + expect(inputElement.getAttribute('aria-invalid')).toBeNull(); + }); + }); + + describe('wrap behavior', () => { + it('should wrap from max to min on ArrowUp when wrap is enabled', () => { + setupSpinButton({value: 12, min: 1, max: 12, wrap: true}); + up(); + expect(spinButtonInstance.value()).toBe(1); + }); + + it('should wrap from min to max on ArrowDown when wrap is enabled', () => { + setupSpinButton({value: 1, min: 1, max: 12, wrap: true}); + down(); + expect(spinButtonInstance.value()).toBe(12); + }); + + it('should not wrap when wrap is disabled', () => { + setupSpinButton({value: 12, min: 1, max: 12, wrap: false}); + up(); + expect(spinButtonInstance.value()).toBe(12); + }); + }); + + describe('increment/decrement buttons', () => { + it('should increment value when increment button is clicked', () => { + setupSpinButton({value: 5}); + clickIncrement(); + expect(spinButtonInstance.value()).toBe(6); + }); + + it('should decrement value when decrement button is clicked', () => { + setupSpinButton({value: 5}); + clickDecrement(); + expect(spinButtonInstance.value()).toBe(4); + }); + + it('should disable increment button when at max (without wrap)', () => { + setupSpinButton({value: 10, max: 10, wrap: false}); + expect(incrementButton.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should disable decrement button when at min (without wrap)', () => { + setupSpinButton({value: 0, min: 0, wrap: false}); + expect(decrementButton.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not disable increment button when at max with wrap enabled', () => { + setupSpinButton({value: 10, max: 10, wrap: true}); + expect(incrementButton.getAttribute('aria-disabled')).toBeNull(); + }); + + it('should not disable decrement button when at min with wrap enabled', () => { + setupSpinButton({value: 0, min: 0, wrap: true}); + expect(decrementButton.getAttribute('aria-disabled')).toBeNull(); + }); + + it('should disable both buttons when spinbutton is disabled', () => { + setupSpinButton({value: 5, disabled: true}); + expect(incrementButton.getAttribute('aria-disabled')).toBe('true'); + expect(decrementButton.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should disable both buttons when spinbutton is readonly', () => { + setupSpinButton({value: 5, readonly: true}); + expect(incrementButton.getAttribute('aria-disabled')).toBe('true'); + expect(decrementButton.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not increment when increment button is disabled', () => { + setupSpinButton({value: 10, max: 10, wrap: false}); + clickIncrement(); + expect(spinButtonInstance.value()).toBe(10); + }); + + it('should not decrement when decrement button is disabled', () => { + setupSpinButton({value: 0, min: 0, wrap: false}); + clickDecrement(); + expect(spinButtonInstance.value()).toBe(0); + }); + }); + + describe('two-way binding', () => { + it('should update value model on keyboard navigation', () => { + setupSpinButton({value: 5}); + up(); + expect(spinButtonInstance.value()).toBe(6); + down(); + down(); + expect(spinButtonInstance.value()).toBe(4); + }); + + it('should update value model on button clicks', () => { + setupSpinButton({value: 5}); + clickIncrement(); + expect(spinButtonInstance.value()).toBe(6); + clickDecrement(); + expect(spinButtonInstance.value()).toBe(5); + }); + + it('should reflect value changes in aria-valuenow', () => { + setupSpinButton({value: 5}); + expect(inputElement.getAttribute('aria-valuenow')).toBe('5'); + up(); + expect(inputElement.getAttribute('aria-valuenow')).toBe('6'); + }); + }); + + describe('native input element', () => { + it('should set autocomplete="off" on native input', () => { + setupDefaultSpinButton(); + expect(inputElement.getAttribute('autocomplete')).toBe('off'); + }); + + it('should set autocorrect="off" on native input', () => { + setupDefaultSpinButton(); + expect(inputElement.getAttribute('autocorrect')).toBe('off'); + }); + + it('should set spellcheck="false" on native input', () => { + setupDefaultSpinButton(); + expect(inputElement.getAttribute('spellcheck')).toBe('false'); + }); + + it('should set inputmode="numeric" by default on native input', () => { + setupDefaultSpinButton(); + expect(inputElement.getAttribute('inputmode')).toBe('numeric'); + }); + }); + + describe('span-based spinbutton', () => { + let spanFixture: ComponentFixture; + let spanInputElement: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr')], + }); + spanFixture = TestBed.createComponent(SpanSpinButtonExample); + spanFixture.detectChanges(); + spanInputElement = spanFixture.debugElement.query( + By.directive(SpinButtonInput), + ).nativeElement; + spinButtonElement = spanFixture.debugElement.query(By.directive(SpinButton)).nativeElement; + }); + + it('should work on span elements', () => { + expect(spanInputElement.getAttribute('role')).toBe('spinbutton'); + }); + + it('should not set autocomplete on span elements', () => { + expect(spanInputElement.getAttribute('autocomplete')).toBeNull(); + }); + + it('should not set inputmode on span elements', () => { + expect(spanInputElement.getAttribute('inputmode')).toBeNull(); + }); + }); + + describe('RTL support', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('rtl')], + }); + fixture = TestBed.createComponent(SpinButtonExample); + const testComponent = fixture.componentInstance as SpinButtonExample; + testComponent.value.set(5); + testComponent.min = 0; + testComponent.max = 10; + fixture.detectChanges(); + defineTestVariables(fixture); + }); + + it('should increment value on ArrowUp in RTL', () => { + up(); + expect(spinButtonInstance.value()).toBe(6); + }); + + it('should decrement value on ArrowDown in RTL', () => { + down(); + expect(spinButtonInstance.value()).toBe(4); + }); + + it('should go to minimum on Home in RTL', () => { + home(); + expect(spinButtonInstance.value()).toBe(0); + }); + + it('should go to maximum on End in RTL', () => { + end(); + expect(spinButtonInstance.value()).toBe(10); + }); + }); +}); + +@Component({ + template: ` +
+ + + +
+ `, + imports: [SpinButton, SpinButtonInput, SpinButtonIncrement, SpinButtonDecrement], +}) +class SpinButtonExample { + value = signal(0); + min: number | undefined; + max: number | undefined; + step = 1; + pageStep: number | undefined; + disabled = false; + readonly = false; + wrap = false; + valueText: string | undefined; +} + +@Component({ + template: ` +
+ + + +
+ `, + imports: [SpinButton, SpinButtonInput, SpinButtonIncrement, SpinButtonDecrement], +}) +class DefaultSpinButtonExample {} + +@Component({ + template: ` +
+ {{ value() }} +
+ `, + imports: [SpinButton, SpinButtonInput], +}) +class SpanSpinButtonExample { + value = signal(5); +} diff --git a/src/aria/spinbutton/spinbutton.ts b/src/aria/spinbutton/spinbutton.ts new file mode 100644 index 000000000000..e28fee46fde7 --- /dev/null +++ b/src/aria/spinbutton/spinbutton.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + afterRenderEffect, + booleanAttribute, + computed, + contentChild, + Directive, + ElementRef, + inject, + input, + model, + signal, +} from '@angular/core'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {SpinButtonPattern} from '../private'; +import {SPINBUTTON, SPINBUTTON_INPUT} from './spinbutton-tokens'; +import type {SpinButtonInput} from './spinbutton-input'; + +/** + * A spinbutton container that manages the value state and provides it to child elements. + * + * The `ngSpinButton` directive serves as the parent container for a spinbutton widget, + * coordinating the behavior of the `ngSpinButtonInput`, `ngSpinButtonIncrement`, and + * `ngSpinButtonDecrement` elements within it. + * + * ```html + *
+ * + * + * + *
+ * ``` + * + * @developerPreview 21.0 + */ +@Directive({ + selector: '[ngSpinButton]', + exportAs: 'ngSpinButton', + providers: [{provide: SPINBUTTON, useExisting: SpinButton}], +}) +export class SpinButton { + /** A reference to the container element. */ + readonly element = inject(ElementRef).nativeElement as HTMLElement; + + /** A unique identifier for the spinbutton input element. */ + readonly inputId = input(inject(_IdGenerator).getId('ng-spinbutton-', true)); + + /** The current numeric value of the spinbutton. */ + readonly value = model(0); + + /** The minimum allowed value. */ + readonly min = input(undefined); + + /** The maximum allowed value. */ + readonly max = input(undefined); + + /** The amount to increment or decrement by. */ + readonly step = input(1); + + /** The amount to increment or decrement by for PageUp/PageDown. */ + readonly pageStep = input(undefined); + + /** Whether the spinbutton is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Whether the spinbutton is readonly. */ + readonly readonly = input(false, {transform: booleanAttribute}); + + /** Whether to wrap the value at boundaries. */ + readonly wrap = input(false, {transform: booleanAttribute}); + + /** Human-readable value text for aria-valuetext. */ + readonly valueText = input(undefined); + + /** The spinbutton input element within this container. */ + private readonly _inputChild = contentChild(SPINBUTTON_INPUT); + + /** Signal for the input element reference. */ + private readonly _inputElement = computed(() => this._inputChild()?.element); + + /** Whether the spinbutton has received focus yet. */ + private _hasFocused = signal(false); + + /** The UI pattern instance for this spinbutton. */ + readonly _pattern: SpinButtonPattern = new SpinButtonPattern({ + id: this.inputId, + value: this.value, + min: this.min, + max: this.max, + step: this.step, + pageStep: this.pageStep, + disabled: this.disabled, + readonly: this.readonly, + wrap: this.wrap, + valueText: this.valueText, + inputElement: this._inputElement, + }); + + constructor() { + afterRenderEffect(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const violations = this._pattern.validate(); + for (const violation of violations) { + console.error(violation); + } + } + }); + + afterRenderEffect(() => { + if (!this._hasFocused()) { + this._pattern.setDefaultState(); + } + }); + } + + /** Called when the input receives focus. */ + _onFocus(): void { + this._hasFocused.set(true); + } + + /** Increments the value by the step amount. */ + increment(): void { + this._pattern.increment(); + } + + /** Decrements the value by the step amount. */ + decrement(): void { + this._pattern.decrement(); + } + + /** Increments the value by the page step amount. */ + incrementByPage(): void { + this._pattern.incrementByPage(); + } + + /** Decrements the value by the page step amount. */ + decrementByPage(): void { + this._pattern.decrementByPage(); + } + + /** Sets the value to the minimum. */ + goToMin(): void { + this._pattern.goToMin(); + } + + /** Sets the value to the maximum. */ + goToMax(): void { + this._pattern.goToMax(); + } +} diff --git a/src/components-examples/aria/spinbutton/BUILD.bazel b/src/components-examples/aria/spinbutton/BUILD.bazel new file mode 100644 index 000000000000..15db66fadc59 --- /dev/null +++ b/src/components-examples/aria/spinbutton/BUILD.bazel @@ -0,0 +1,26 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "spinbutton", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/spinbutton", + "//src/cdk/a11y", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/aria/spinbutton/index.ts b/src/components-examples/aria/spinbutton/index.ts new file mode 100644 index 000000000000..1a0c3905bbfa --- /dev/null +++ b/src/components-examples/aria/spinbutton/index.ts @@ -0,0 +1,2 @@ +export {SpinButtonGuestCounterExample} from './spinbutton-guest-counter/spinbutton-guest-counter-example'; +export {SpinButtonTimeFieldExample} from './spinbutton-time-field/spinbutton-time-field-example'; diff --git a/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.css b/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.css new file mode 100644 index 000000000000..99e2f78ddd86 --- /dev/null +++ b/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.css @@ -0,0 +1,112 @@ +.example-fieldset { + border: 1px solid var(--mat-sys-outline-variant); + border-radius: var(--mat-sys-corner-small); + padding: 16px; + margin: 0; +} + +.example-legend { + font-weight: 500; + padding: 0 8px; +} + +.example-spinner-fields { + display: flex; + flex-direction: row; + gap: 24px; +} + +.example-spinner-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.example-spinner-field .example-label { + font-weight: 500; + font-size: 14px; +} + +.example-spinner-field small { + color: var(--mat-sys-on-surface-variant); + font-size: 12px; +} + +.example-spinner { + display: inline-flex; + align-items: stretch; + width: fit-content; + border-radius: var(--mat-sys-corner-small); +} + +.example-spinner:focus-within { + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; +} + + +.example-spinner:has([aria-invalid='true']) .example-spinner-button, +.example-spinner:has([aria-invalid='true']) .example-spinner-input { + border-color: var(--mat-sys-error); +} + +.example-spinner-button { + background: var(--mat-sys-surface-container); + border: 1px solid var(--mat-sys-outline-variant); + padding: 8px 12px; + cursor: pointer; + font-size: 18px; + line-height: 1; + min-width: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.example-spinner-button:first-child { + border-radius: var(--mat-sys-corner-small) 0 0 var(--mat-sys-corner-small); + border-right: none; +} + +.example-spinner-button:last-child { + border-radius: 0 var(--mat-sys-corner-small) var(--mat-sys-corner-small) 0; + border-left: none; +} + +.example-spinner-button:hover:not([aria-disabled='true']) { + background: var(--mat-sys-surface-container-high); +} + +.example-spinner-button:active:not([aria-disabled='true']) { + background: var(--mat-sys-surface-container-highest); +} + +.example-spinner-button[aria-disabled='true'] { + cursor: not-allowed; + color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent); + background: var(--mat-sys-surface-container); +} + +.example-spinner-input { + border: 1px solid var(--mat-sys-outline-variant); + border-left: none; + border-right: none; + padding: 8px; + text-align: center; + width: 60px; + font-size: 16px; + background: var(--mat-sys-surface); + outline: none; +} + +.example-spinner-input[aria-invalid='true'] { + background: color-mix(in srgb, var(--mat-sys-error) 10%, var(--mat-sys-surface)); +} + +.example-spinner-field small.example-error { + color: var(--mat-sys-error); +} + +.example-hidden { + display: none; +} diff --git a/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.html b/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.html new file mode 100644 index 000000000000..75f1dbc129f5 --- /dev/null +++ b/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.html @@ -0,0 +1,58 @@ +
+ Guests +
+
+ +
+ + + +
+ Must be between 1 and 8 + 1 to 8 +
+ +
+ +
+ + + +
+ Must be between 0 and 8 + 0 to 8 +
+ +
+ +
+ + + +
+ Must be between 0 and 12 + 0 to 12 +
+
+
diff --git a/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.ts b/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.ts new file mode 100644 index 000000000000..45b9133ad03e --- /dev/null +++ b/src/components-examples/aria/spinbutton/spinbutton-guest-counter/spinbutton-guest-counter-example.ts @@ -0,0 +1,37 @@ +import {Component, effect, inject, signal} from '@angular/core'; +import {LiveAnnouncer} from '@angular/cdk/a11y'; +import { + SpinButton, + SpinButtonInput, + SpinButtonIncrement, + SpinButtonDecrement, +} from '@angular/aria/spinbutton'; + +/** @title APG Hotel Guest Counter Spinbutton Example */ +@Component({ + selector: 'spinbutton-guest-counter-example', + templateUrl: 'spinbutton-guest-counter-example.html', + styleUrl: 'spinbutton-guest-counter-example.css', + imports: [SpinButton, SpinButtonInput, SpinButtonIncrement, SpinButtonDecrement], +}) +export class SpinButtonGuestCounterExample { + adults = signal(1); + kids = signal(0); + animals = signal(0); + + private _liveAnnouncer = inject(LiveAnnouncer); + + constructor() { + effect(() => this._liveAnnouncer.announce(String(this.adults()))); + effect(() => this._liveAnnouncer.announce(String(this.kids()))); + effect(() => this._liveAnnouncer.announce(String(this.animals()))); + } + + /** Restore min value if input is empty on blur */ + onBlur(spinButton: SpinButton, minValue: number): void { + const input = spinButton._pattern.inputs.inputElement?.() as HTMLInputElement | undefined; + if (input && input.value === '') { + spinButton.value.set(minValue); + } + } +} diff --git a/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.css b/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.css new file mode 100644 index 000000000000..67a722ca970e --- /dev/null +++ b/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.css @@ -0,0 +1,62 @@ +.example-time-field { + display: inline-flex; + flex-direction: column; + gap: 8px; +} + +.example-time-field .example-label { + font-weight: 500; + font-size: 14px; +} + +.example-time-segments { + display: inline-flex; + align-items: baseline; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: var(--mat-sys-corner-small); + padding: 8px 12px; + background: var(--mat-sys-surface); +} + +.example-time-segments:focus-within { + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; +} + +.example-time-segment { + outline: none; +} + +.example-time-segment [ngSpinButtonInput] { + padding: 4px; + font-size: 20px; + font-variant-numeric: tabular-nums; + min-width: 2em; + text-align: center; + cursor: default; + border-radius: var(--mat-sys-corner-extra-small); + outline: none; + user-select: none; + caret-color: transparent; + color: var(--mat-sys-on-surface); +} + +.example-time-segment [ngSpinButtonInput]:focus { + background-color: var(--mat-sys-secondary-container); + color: var(--mat-sys-on-secondary-container); +} + +.example-time-segment.example-period [ngSpinButtonInput] { + min-width: 2.5em; +} + +.example-time-separator { + font-size: 20px; + color: var(--mat-sys-on-surface-variant); + padding: 0 2px; +} + +.example-time-output { + color: var(--mat-sys-on-surface-variant); + font-size: 12px; +} diff --git a/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.html b/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.html new file mode 100644 index 000000000000..56f13f682554 --- /dev/null +++ b/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.html @@ -0,0 +1,17 @@ +
+ Select Time +
+ + {{ hoursDisplay() }} + + + + {{ minutesDisplay() }} + + + + {{ periodDisplay() }} + +
+
Selected: {{ formattedTime() }}
+
diff --git a/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.ts b/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.ts new file mode 100644 index 000000000000..3e67a9358b4f --- /dev/null +++ b/src/components-examples/aria/spinbutton/spinbutton-time-field/spinbutton-time-field-example.ts @@ -0,0 +1,120 @@ +import {Component, signal, computed, viewChild, ElementRef} from '@angular/core'; +import {SpinButton, SpinButtonInput} from '@angular/aria/spinbutton'; + +/** @title Time Field */ +@Component({ + selector: 'spinbutton-time-field-example', + templateUrl: 'spinbutton-time-field-example.html', + styleUrl: 'spinbutton-time-field-example.css', + imports: [SpinButton, SpinButtonInput], +}) +export class SpinButtonTimeFieldExample { + hours = signal(12); + minutes = signal(0); + period = signal(0); + + hoursDisplay = computed(() => this.hours().toString().padStart(2, '0')); + minutesDisplay = computed(() => this.minutes().toString().padStart(2, '0')); + periodDisplay = computed(() => (this.period() === 0 ? 'AM' : 'PM')); + + formattedTime = computed( + () => `${this.hoursDisplay()}:${this.minutesDisplay()} ${this.periodDisplay()}`, + ); + + private _hoursInput = viewChild>('hoursInput'); + private _minutesInput = viewChild>('minutesInput'); + private _periodInput = viewChild>('periodInput'); + + onHoursKeydown(event: KeyboardEvent): void { + if (event.key === 'ArrowRight') { + event.preventDefault(); + this._advanceToMinutes(); + return; + } + + // Prevent all character input from contenteditable + if (event.key.length === 1) { + event.preventDefault(); + } + + if (!/^[0-9]$/.test(event.key)) return; + + const digit = parseInt(event.key, 10); + const current = this.hours(); + const appended = current * 10 + digit; + + if (appended >= 1 && appended <= 12) { + this.hours.set(appended); + this._advanceToMinutes(); + } else if (digit >= 1 && digit <= 9) { + this.hours.set(digit); + if (digit > 1) { + this._advanceToMinutes(); + } + } + } + + onMinutesKeydown(event: KeyboardEvent): void { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this._hoursInput()?.nativeElement.focus(); + return; + } + if (event.key === 'ArrowRight') { + event.preventDefault(); + this._advanceToPeriod(); + return; + } + + // Prevent all character input from contenteditable + if (event.key.length === 1) { + event.preventDefault(); + } + + if (!/^[0-9]$/.test(event.key)) return; + + const digit = parseInt(event.key, 10); + const current = this.minutes(); + const appended = current * 10 + digit; + + if (appended <= 59) { + this.minutes.set(appended); + if (appended > 5) { + this._advanceToPeriod(); + } + } else { + this.minutes.set(digit); + if (digit > 5) { + this._advanceToPeriod(); + } + } + } + + onPeriodKeydown(event: KeyboardEvent): void { + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this._minutesInput()?.nativeElement.focus(); + return; + } + + const key = event.key.toLowerCase(); + if (key === 'a') { + this.period.set(0); + event.preventDefault(); + } else if (key === 'p') { + this.period.set(1); + event.preventDefault(); + } else if (event.key.length === 1) { + // Prevent any other character input (numbers, letters, etc.) + event.preventDefault(); + } + } + + private _advanceToMinutes(): void { + this._minutesInput()?.nativeElement.focus(); + } + + private _advanceToPeriod(): void { + this._periodInput()?.nativeElement.focus(); + } +} diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index b18b11e22ea1..44cc4b458dcc 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -33,6 +33,7 @@ ng_project( "//src/dev-app/aria-menu", "//src/dev-app/aria-menubar", "//src/dev-app/aria-select", + "//src/dev-app/aria-spinbutton", "//src/dev-app/aria-tabs", "//src/dev-app/aria-toolbar", "//src/dev-app/aria-tree", diff --git a/src/dev-app/aria-spinbutton/BUILD.bazel b/src/dev-app/aria-spinbutton/BUILD.bazel new file mode 100644 index 000000000000..fc7fbbe8b6c9 --- /dev/null +++ b/src/dev-app/aria-spinbutton/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "aria-spinbutton", + srcs = glob(["**/*.ts"]), + assets = [ + "spinbutton-demo.html", + ":spinbutton-demo.css", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/components-examples/aria/spinbutton", + ], +) diff --git a/src/dev-app/aria-spinbutton/spinbutton-demo.css b/src/dev-app/aria-spinbutton/spinbutton-demo.css new file mode 100644 index 000000000000..a73e715474c2 --- /dev/null +++ b/src/dev-app/aria-spinbutton/spinbutton-demo.css @@ -0,0 +1,23 @@ +.example-spinbutton-grid { + display: flex; + flex-direction: column; + gap: 48px; +} + +.example-spinbutton-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + padding-bottom: 48px; + border-bottom: 1px solid var(--mat-sys-outline-variant); +} + +.example-spinbutton-container:last-child { + border-bottom: none; + padding-bottom: 0; +} + +h2 { + margin-top: 0; + margin-bottom: 16px; +} diff --git a/src/dev-app/aria-spinbutton/spinbutton-demo.html b/src/dev-app/aria-spinbutton/spinbutton-demo.html new file mode 100644 index 000000000000..f4c0fea0355e --- /dev/null +++ b/src/dev-app/aria-spinbutton/spinbutton-demo.html @@ -0,0 +1,11 @@ +
+
+

Guest Counter

+ +
+ +
+

Time Field

+ +
+
diff --git a/src/dev-app/aria-spinbutton/spinbutton-demo.ts b/src/dev-app/aria-spinbutton/spinbutton-demo.ts new file mode 100644 index 000000000000..9dd4af139c97 --- /dev/null +++ b/src/dev-app/aria-spinbutton/spinbutton-demo.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import { + SpinButtonGuestCounterExample, + SpinButtonTimeFieldExample, +} from '@angular/components-examples/aria/spinbutton'; + +@Component({ + templateUrl: 'spinbutton-demo.html', + imports: [SpinButtonGuestCounterExample, SpinButtonTimeFieldExample], + styleUrl: 'spinbutton-demo.css', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SpinButtonDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 8e552a3d0727..22a7afb51a2f 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -70,6 +70,7 @@ export class DevAppLayout { {name: 'Aria Menu', route: '/aria-menu'}, {name: 'Aria Menubar', route: '/aria-menubar'}, {name: 'Aria Select', route: '/aria-select'}, + {name: 'Aria Spinbutton', route: '/aria-spinbutton'}, {name: 'Aria Tabs', route: '/aria-tabs'}, {name: 'Aria Toolbar', route: '/aria-toolbar'}, {name: 'Aria Tree', route: '/aria-tree'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index 2fab9c4af821..a3b75184244c 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -85,6 +85,10 @@ export const DEV_APP_ROUTES: Routes = [ path: 'aria-toolbar', loadComponent: () => import('./aria-toolbar/toolbar-demo').then(m => m.ToolbarDemo), }, + { + path: 'aria-spinbutton', + loadComponent: () => import('./aria-spinbutton/spinbutton-demo').then(m => m.SpinButtonDemo), + }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo),