Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/styles/cdk.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@use "@angular/cdk" as cdk;

@include cdk.a11y-visually-hidden;
1 change: 1 addition & 0 deletions src/styles/index.scss
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
@use "@tedi-design-system/core/index";
@use "vertical-spacing";
@use "cdk";
16 changes: 6 additions & 10 deletions tedi/components/form/number-field/number-field.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,10 @@
'tedi-number-field__button--small': size() === 'small',
}"
[disabled]="decrementDisabled()"
[attr.aria-label]="'numberField.decrement' | tediTranslate: step()"
(click)="handleButtonClick('decrement')"
>
<tedi-icon
name="remove"
[size]="18"
[label]="'numberField.decrement' | tediTranslate: step()"
/>
<tedi-icon name="remove" [size]="18" />
</button>
<div
[class]="{
Expand All @@ -51,6 +48,7 @@
[attr.max]="max()"
[attr.step]="step()"
[attr.aria-invalid]="isInvalid()"
[attr.aria-describedby]="feedbackId()"
(input)="handleInputChange($event)"
(blur)="handleBlur()"
/>
Expand All @@ -70,17 +68,15 @@
'tedi-number-field__button--small': size() === 'small',
}"
[disabled]="incrementDisabled()"
[attr.aria-label]="'numberField.increment' | tediTranslate: step()"
(click)="handleButtonClick('increment')"
>
<tedi-icon
name="add"
[size]="18"
[label]="'numberField.increment' | tediTranslate: step()"
/>
<tedi-icon name="add" [size]="18" />
</button>
</div>
@if (feedbackText(); as feedback) {
<tedi-feedback-text
[attr.id]="feedbackId()"
[text]="feedback.text"
[type]="feedback.type"
[position]="feedback.position"
Expand Down
111 changes: 110 additions & 1 deletion tedi/components/form/number-field/number-field.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { NumberFieldComponent } from "./number-field.component";
import { LabelComponent } from "../label/label.component";
import { ButtonComponent } from "../../buttons/button/button.component";
Expand All @@ -11,8 +12,14 @@ describe("NumberFieldComponent", () => {
let fixture: ComponentFixture<NumberFieldComponent>;
let component: NumberFieldComponent;
let el: HTMLElement;
let mockLiveAnnouncer: jest.Mocked<LiveAnnouncer>;

beforeEach(() => {
mockLiveAnnouncer = {
announce: jest.fn().mockResolvedValue(undefined),
clear: jest.fn(),
} as unknown as jest.Mocked<LiveAnnouncer>;

TestBed.configureTestingModule({
imports: [
NumberFieldComponent,
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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<number, [number]>();
component.registerOnChange(onChange);
Expand Down Expand Up @@ -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");
});
});
23 changes: 21 additions & 2 deletions tedi/components/form/number-field/number-field.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
ViewEncapsulation,
Expand All @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down Expand Up @@ -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() {
Expand Down