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() {