Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ng-dev/commit-message.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const commitMessage: CommitMessageConfig = {
'aria/grid',
'aria/listbox',
'aria/menu',
'aria/spinbutton',
'aria/tabs',
'aria/toolbar',
'aria/tree',
Expand Down
37 changes: 37 additions & 0 deletions goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,43 @@ export function signal<T>(initialValue: T): WritableSignalLike<T>;
// @public (undocumented)
export type SignalLike<T> = () => T;

// @public
export interface SpinButtonInputs {
disabled: SignalLike<boolean>;
id: SignalLike<string>;
inputElement: SignalLike<HTMLElement | undefined>;
max: SignalLike<number | undefined>;
min: SignalLike<number | undefined>;
pageStep: SignalLike<number | undefined>;
readonly: SignalLike<boolean>;
step: SignalLike<number>;
value: WritableSignalLike<number>;
valueText: SignalLike<string | undefined>;
wrap: SignalLike<boolean>;
}

// @public
export class SpinButtonPattern {
constructor(inputs: SpinButtonInputs);
readonly ariaValueNow: SignalLike<number>;
readonly atMax: SignalLike<boolean>;
readonly atMin: SignalLike<boolean>;
decrement(): void;
decrementByPage(): void;
goToMax(): void;
goToMin(): void;
increment(): void;
incrementByPage(): void;
readonly inputs: SpinButtonInputs;
readonly invalid: SignalLike<boolean>;
readonly keydown: SignalLike<KeyboardEventManager<KeyboardEvent>>;
onKeydown(event: KeyboardEvent): void;
onPointerdown(_event: PointerEvent): void;
setDefaultState(): void;
readonly tabIndex: SignalLike<-1 | 0>;
validate(): string[];
}

// @public
export interface TabInputs extends Omit<ListNavigationItem, 'index'>, Omit<ExpansionItem, 'expandable'> {
tablist: SignalLike<TabListPattern>;
Expand Down
84 changes: 84 additions & 0 deletions goldens/aria/spinbutton/index.api.md
Original file line number Diff line number Diff line change
@@ -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<boolean, unknown>;
readonly element: HTMLElement;
goToMax(): void;
goToMin(): void;
increment(): void;
incrementByPage(): void;
readonly inputId: _angular_core.InputSignal<string>;
readonly max: _angular_core.InputSignal<number | undefined>;
readonly min: _angular_core.InputSignal<number | undefined>;
_onFocus(): void;
readonly pageStep: _angular_core.InputSignal<number | undefined>;
readonly _pattern: SpinButtonPattern;
readonly readonly: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly step: _angular_core.InputSignal<number>;
readonly value: _angular_core.ModelSignal<number>;
readonly valueText: _angular_core.InputSignal<string | undefined>;
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
// (undocumented)
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>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButton, never>;
}

// @public
export class SpinButtonDecrement {
readonly _isDisabled: _angular_core.Signal<boolean>;
_onClick(): void;
readonly spinButton: SpinButton;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<SpinButtonDecrement, "[ngSpinButtonDecrement]", ["ngSpinButtonDecrement"], {}, {}, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButtonDecrement, never>;
}

// @public
export class SpinButtonIncrement {
readonly _isDisabled: _angular_core.Signal<boolean>;
_onClick(): void;
readonly spinButton: SpinButton;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<SpinButtonIncrement, "[ngSpinButtonIncrement]", ["ngSpinButtonIncrement"], {}, {}, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButtonIncrement, never>;
}

// @public
export class SpinButtonInput {
constructor();
// (undocumented)
readonly element: HTMLElement;
// (undocumented)
readonly inputmode: _angular_core.InputSignal<string | null>;
// (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<SpinButtonInput, "[ngSpinButtonInput]", ["ngSpinButtonInput"], { "inputmode": { "alias": "inputmode"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<SpinButtonInput, never>;
}

// (No @packageDocumentation comment for this package)

```
1 change: 1 addition & 0 deletions src/aria/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ARIA_ENTRYPOINTS = [
"grid",
"listbox",
"menu",
"spinbutton",
"tabs",
"toolbar",
"tree",
Expand Down
1 change: 1 addition & 0 deletions src/aria/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/aria/private/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export * from './grid/row';
export * from './grid/cell';
export * from './grid/widget';
export * from './deferred-content';
export * from './spinbutton/spinbutton';
15 changes: 15 additions & 0 deletions src/aria/private/spinbutton/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
187 changes: 187 additions & 0 deletions src/aria/private/spinbutton/spinbutton.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

/** The current numeric value of the spinbutton. */
value: WritableSignalLike<number>;

/** The minimum allowed value. */
min: SignalLike<number | undefined>;

/** The maximum allowed value. */
max: SignalLike<number | undefined>;

/** The amount to increment or decrement by. */
step: SignalLike<number>;

/** The amount to increment or decrement by for page up/down. */
pageStep: SignalLike<number | undefined>;

/** Whether the spinbutton is disabled. */
disabled: SignalLike<boolean>;

/** Whether the spinbutton is readonly. */
readonly: SignalLike<boolean>;

/** Whether to wrap the value at boundaries. */
wrap: SignalLike<boolean>;

/** Human-readable value text for aria-valuetext. */
valueText: SignalLike<string | undefined>;

/** Reference to the input element. */
inputElement: SignalLike<HTMLElement | undefined>;
}

/** 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);
}
}
Loading