-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(aria/spinbutton): add aria spinbutton component #32663
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8a1a79f
62059f1
151003b
b6d757b
e3c6d36
46d84e1
35866e8
f72df1b
ddc6abd
d1d7b40
26d84dd
0df5562
297c0a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| ## 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>; | ||
| 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 { | ||
| protected readonly _isDisabled: _angular_core.Signal<boolean>; | ||
| protected _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 { | ||
| protected readonly _isDisabled: _angular_core.Signal<boolean>; | ||
| protected _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) | ||
|
|
||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ ARIA_ENTRYPOINTS = [ | |
| "grid", | ||
| "listbox", | ||
| "menu", | ||
| "spinbutton", | ||
| "tabs", | ||
| "toolbar", | ||
| "tree", | ||
|
|
||
| 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", | ||
| ], | ||
| ) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,209 @@ | ||||||
| /** | ||||||
| * @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 violations: string[] = []; | ||||||
| const min = this.inputs.min(); | ||||||
| const max = this.inputs.max(); | ||||||
|
|
||||||
| if (min !== undefined && max !== undefined && min > max) { | ||||||
| violations.push(`Spinbutton has invalid bounds: min (${min}) is greater than max (${max}).`); | ||||||
| } | ||||||
|
|
||||||
| if (this.inputs.wrap() && (min === undefined || max === undefined)) { | ||||||
| violations.push( | ||||||
| `Spinbutton has wrap enabled but ${min === undefined ? 'min' : 'max'} is not defined. ` + | ||||||
| `Wrap behavior requires both min and max to be set.`, | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| const step = this.inputs.step(); | ||||||
| if (step <= 0) { | ||||||
| violations.push(`Spinbutton has invalid step: ${step}. Step must be a positive number.`); | ||||||
| } | ||||||
|
|
||||||
| const pageStep = this.inputs.pageStep(); | ||||||
| if (pageStep !== undefined && pageStep <= 0) { | ||||||
| violations.push( | ||||||
| `Spinbutton has invalid pageStep: ${pageStep}. PageStep must be a positive number.`, | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| return violations; | ||||||
| } | ||||||
|
|
||||||
| /** Noop. Spinbuttons don't manage items requiring default state initialization. */ | ||||||
| setDefaultState(): void {} | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is an implementation missing here ?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I saw that a few other patterns leave it as a noop too
Not sure if I should leave it like this or remove it entirely. |
||||||
|
|
||||||
| /** 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 { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this be
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at the other pattern classes, |
||||||
| 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); | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does it really belong in the public API?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The other pattern classes also exported in the private API golden, I assumed this one should be exported too