diff --git a/src/styles/cdk.scss b/src/styles/cdk.scss new file mode 100644 index 000000000..bf02054ca --- /dev/null +++ b/src/styles/cdk.scss @@ -0,0 +1,3 @@ +@use "@angular/cdk" as cdk; + +@include cdk.a11y-visually-hidden; diff --git a/src/styles/index.scss b/src/styles/index.scss index d271e91b4..c6104eee7 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,2 +1,3 @@ @use "@tedi-design-system/core/index"; @use "vertical-spacing"; +@use "cdk"; diff --git a/tedi/components/form/number-field/number-field.component.html b/tedi/components/form/number-field/number-field.component.html index 2ce01eff9..94a798cae 100644 --- a/tedi/components/form/number-field/number-field.component.html +++ b/tedi/components/form/number-field/number-field.component.html @@ -20,13 +20,10 @@ 'tedi-number-field__button--small': size() === 'small', }" [disabled]="decrementDisabled()" + [attr.aria-label]="'numberField.decrement' | tediTranslate: step()" (click)="handleButtonClick('decrement')" > - +
@@ -70,17 +68,15 @@ 'tedi-number-field__button--small': size() === 'small', }" [disabled]="incrementDisabled()" + [attr.aria-label]="'numberField.increment' | tediTranslate: step()" (click)="handleButtonClick('increment')" > - +
@if (feedbackText(); as feedback) { { let fixture: ComponentFixture; let component: NumberFieldComponent; let el: HTMLElement; + let mockLiveAnnouncer: jest.Mocked; beforeEach(() => { + mockLiveAnnouncer = { + announce: jest.fn().mockResolvedValue(undefined), + clear: jest.fn(), + } as unknown as jest.Mocked; + TestBed.configureTestingModule({ imports: [ NumberFieldComponent, @@ -22,7 +29,10 @@ describe("NumberFieldComponent", () => { TextComponent, FeedbackTextComponent, ], - providers: [{ provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }], + providers: [ + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: LiveAnnouncer, useValue: mockLiveAnnouncer }, + ], }); fixture = TestBed.createComponent(NumberFieldComponent); @@ -57,6 +67,19 @@ describe("NumberFieldComponent", () => { expect(onTouched).toHaveBeenCalled(); }); + it("should announce value change on increment button click", () => { + const buttons = el.querySelectorAll("button"); + const incrementBtn = buttons[1] as HTMLButtonElement; + + incrementBtn.click(); + fixture.detectChanges(); + + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + expect.any(String), + "polite" + ); + }); + it("should decrement the value on decrement button click", () => { component.writeValue(5); fixture.detectChanges(); @@ -74,6 +97,39 @@ describe("NumberFieldComponent", () => { expect(onChange).toHaveBeenCalledWith(4); }); + it("should announce value change on decrement button click", () => { + component.writeValue(5); + fixture.detectChanges(); + + const decrementBtn = el.querySelector("button") as HTMLButtonElement; + + decrementBtn.click(); + fixture.detectChanges(); + + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + expect.any(String), + "polite" + ); + }); + + it("should have aria-label on decrement button", () => { + const decrementBtn = el.querySelector("button") as HTMLButtonElement; + expect(decrementBtn.getAttribute("aria-label")).toBeTruthy(); + }); + + it("should have aria-label on increment button", () => { + const buttons = el.querySelectorAll("button"); + const incrementBtn = buttons[1] as HTMLButtonElement; + expect(incrementBtn.getAttribute("aria-label")).toBeTruthy(); + }); + + it("should have aria-hidden icons inside buttons", () => { + const icons = el.querySelectorAll("tedi-icon"); + icons.forEach((icon) => { + expect(icon.getAttribute("aria-hidden")).toBe("true"); + }); + }); + it("should disable decrement button when value === min", () => { component.writeValue(3); fixture.componentRef.setInput("min", 3); @@ -93,6 +149,32 @@ describe("NumberFieldComponent", () => { expect(incrementBtn.disabled).toBeTruthy(); }); + it("should set aria-invalid to true when value is below min", () => { + component.writeValue(2); + fixture.componentRef.setInput("min", 5); + fixture.detectChanges(); + + const inputEl = el.querySelector("input") as HTMLInputElement; + expect(inputEl.getAttribute("aria-invalid")).toBe("true"); + }); + + it("should set aria-invalid to true when value is above max", () => { + component.writeValue(10); + fixture.componentRef.setInput("max", 5); + fixture.detectChanges(); + + const inputEl = el.querySelector("input") as HTMLInputElement; + expect(inputEl.getAttribute("aria-invalid")).toBe("true"); + }); + + it("should set aria-invalid to true when invalid input is true", () => { + fixture.componentRef.setInput("invalid", true); + fixture.detectChanges(); + + const inputEl = el.querySelector("input") as HTMLInputElement; + expect(inputEl.getAttribute("aria-invalid")).toBe("true"); + }); + it("should call onChange when input is changed", () => { const onChange = jest.fn(); component.registerOnChange(onChange); @@ -187,4 +269,31 @@ describe("NumberFieldComponent", () => { expect(blurSpy).toHaveBeenCalled(); expect(onTouched).toHaveBeenCalled(); }); + + it("should set aria-describedby when feedbackText is provided", () => { + fixture.componentRef.setInput("feedbackText", { + text: "Error message", + type: "error", + }); + fixture.detectChanges(); + + const inputEl = el.querySelector("input") as HTMLInputElement; + expect(inputEl.getAttribute("aria-describedby")).toBe("test-id-feedback"); + }); + + it("should not set aria-describedby when feedbackText is not provided", () => { + const inputEl = el.querySelector("input") as HTMLInputElement; + expect(inputEl.getAttribute("aria-describedby")).toBeNull(); + }); + + it("should set id on feedback-text element matching aria-describedby", () => { + fixture.componentRef.setInput("feedbackText", { + text: "Error message", + type: "error", + }); + fixture.detectChanges(); + + const feedbackEl = el.querySelector("tedi-feedback-text"); + expect(feedbackEl?.getAttribute("id")).toBe("test-id-feedback"); + }); }); diff --git a/tedi/components/form/number-field/number-field.component.ts b/tedi/components/form/number-field/number-field.component.ts index 6f0db8a18..c31d0e589 100644 --- a/tedi/components/form/number-field/number-field.component.ts +++ b/tedi/components/form/number-field/number-field.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + inject, input, model, ViewEncapsulation, @@ -10,8 +11,10 @@ import { signal, ViewChild, } from "@angular/core"; +import { LiveAnnouncer } from "@angular/cdk/a11y"; import { ButtonComponent } from "../../buttons/button/button.component"; import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; +import { TediTranslationService } from "../../../services/translation/translation.service"; import { ComponentInputs } from "../../../types/inputs.type"; import { IconComponent } from "../../base/icon/icon.component"; import { TextComponent } from "../../base/text/text.component"; @@ -109,6 +112,8 @@ export class NumberFieldComponent implements ControlValueAccessor { private formDisabled = signal(false); private onChange: (value: number) => void = () => { }; private onTouched: () => void = () => { }; + private translationService = inject(TediTranslationService); + private liveAnnouncer = inject(LiveAnnouncer); readonly isInvalid = computed(() => { const min = this.min(); @@ -135,6 +140,10 @@ export class NumberFieldComponent implements ControlValueAccessor { return this.isDisabled() || (max !== undefined && this.value() >= max); }); + readonly feedbackId = computed(() => + this.feedbackText() ? `${this.inputId()}-feedback` : null + ); + writeValue(value?: number): void { this.value.set(value ? (isNaN(value) ? 0 : value) : 0); } @@ -166,13 +175,23 @@ export class NumberFieldComponent implements ControlValueAccessor { this.value.set(nextValue); this.onChange(nextValue); this.onTouched(); + this.announceValue(nextValue); + } + + announceValue(value: number) { + this.liveAnnouncer.announce( + this.translationService.translate("numberField.quantityUpdated", value), + "polite" + ); } handleInputChange(event: Event) { const input = event.target as HTMLInputElement; const value = isNaN(input.valueAsNumber) ? 0 : input.valueAsNumber; - this.value.set(value); - this.onChange(value); + if (value !== this.value()) { + this.value.set(value); + this.onChange(value); + } } handleBlur() {