diff --git a/tedi/components/form/date-picker/date-picker.component.html b/tedi/components/form/date-picker/date-picker.component.html index 6a9b0dcff..47807775c 100644 --- a/tedi/components/form/date-picker/date-picker.component.html +++ b/tedi/components/form/date-picker/date-picker.component.html @@ -10,12 +10,12 @@ [class.tedi-date-picker__input--error]="inputState() === 'error'" [attr.id]="inputId()" [attr.placeholder]="inputPlaceholder()" - [attr.aria-expanded]="!inputDisabled() && popover().floatUiComponent().state" + [attr.aria-expanded]="!fieldDisabled() && popover().floatUiComponent().state" [attr.aria-controls]="uniqueId" [attr.aria-readonly]="!allowManualInput()" [readOnly]="!allowManualInput()" [value]="inputValue()" - [disabled]="inputDisabled()" + [disabled]="fieldDisabled()" (input)="onInput($event)" (blur)="onInputBlur()" (click)="onInputClick()" @@ -29,7 +29,7 @@ class="tedi-date-picker__clear" [iconSize]="18" [ariaLabel]="'date-picker.clear-date' | tediTranslate" - [disabled]="inputDisabled()" + [disabled]="fieldDisabled()" (click)="clearInput()" > @@ -48,7 +48,7 @@ size="small" class="tedi-date-picker__toggle" [attr.aria-label]="'date-picker.open-calendar' | tediTranslate" - [disabled]="inputDisabled()" + [disabled]="fieldDisabled()" (click)="openCalendar()" > diff --git a/tedi/components/form/date-picker/date-picker.component.spec.ts b/tedi/components/form/date-picker/date-picker.component.spec.ts index 98539b1dd..16e8ef9a8 100644 --- a/tedi/components/form/date-picker/date-picker.component.spec.ts +++ b/tedi/components/form/date-picker/date-picker.component.spec.ts @@ -1,4 +1,7 @@ +import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; import { DatePickerComponent } from "./date-picker.component"; import { TediTranslationService } from "../../../services/translation/translation.service"; import { NgxFloatUiContentComponent } from "ngx-float-ui"; @@ -14,6 +17,15 @@ class TranslationMock { } } +@Component({ + standalone: true, + imports: [DatePickerComponent, ReactiveFormsModule], + template: ``, +}) +class TestHostComponent { + control = new FormControl(null); +} + describe("DatePickerComponent", () => { let fixture: ComponentFixture; let component: DatePickerComponent; @@ -1282,4 +1294,393 @@ describe("DatePickerComponent", () => { expect(input.value).toBe(""); }); }); + + describe("ControlValueAccessor", () => { + describe("writeValue()", () => { + it("should set selected and inputValue when given a Date", () => { + const date = new Date(2024, 5, 15); + + component.writeValue(date); + fixture.detectChanges(); + + expect(component.selected()).toEqual(date); + expect(component.inputValue()).toBe("15.06.2024"); + }); + + it("should clear selected and inputValue when given null", () => { + component.selected.set(new Date(2024, 5, 15)); + component.inputValue.set("15.06.2024"); + fixture.detectChanges(); + + component.writeValue(null); + fixture.detectChanges(); + + expect(component.selected()).toBeNull(); + expect(component.inputValue()).toBe(""); + }); + }); + + describe("registerOnChange()", () => { + it("should call onChange when a day is selected", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const date = new Date(2024, 5, 15); + component.selectDay({ + date, + disabled: false, + inCurrentMonth: true, + }); + + expect(onChange).toHaveBeenCalledWith(date); + }); + + it("should NOT call onChange when selecting the same day", () => { + const date = new Date(2024, 5, 15); + component.selected.set(date); + fixture.detectChanges(); + + const onChange = jest.fn(); + component.registerOnChange(onChange); + + component.selectDay({ + date: new Date(2024, 5, 15), + disabled: false, + inCurrentMonth: true, + }); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should call onChange with null when input is cleared", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + component.selected.set(new Date(2024, 5, 15)); + fixture.detectChanges(); + + component.clearInput(); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it("should call onChange when valid date is entered manually on blur", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const input = getInput(); + input.value = "20.06.2024"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(onChange).toHaveBeenCalledWith(new Date(2024, 5, 20)); + }); + + it("should NOT call onChange when same date is entered manually on blur", () => { + const existingDate = new Date(2024, 5, 20); + component.selected.set(existingDate); + component.inputValue.set("20.06.2024"); + fixture.detectChanges(); + + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const input = getInput(); + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("should NOT call onChange when invalid date is entered manually on blur", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + const input = getInput(); + input.value = "invalid"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe("registerOnTouched()", () => { + it("should call onTouched when input is blurred", () => { + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + + const input = getInput(); + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(onTouched).toHaveBeenCalled(); + }); + + it("should call onTouched when calendar is closed", () => { + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + + component.closeCalendar(); + + expect(onTouched).toHaveBeenCalled(); + }); + + it("should call onTouched even when allowManualInput is false", () => { + fixture.componentRef.setInput("allowManualInput", false); + fixture.detectChanges(); + + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + + const input = getInput(); + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(onTouched).toHaveBeenCalled(); + }); + }); + + describe("setDisabledState()", () => { + it("should disable all controls when setDisabledState(true) is called", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + const input = getInput(); + const toggleButton = el.querySelector(".tedi-date-picker__toggle") as HTMLButtonElement; + + expect(component.fieldDisabled()).toBe(true); + expect(input.disabled).toBe(true); + expect(toggleButton.disabled).toBe(true); + }); + + it("should enable all controls when setDisabledState(false) is called", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + component.setDisabledState(false); + fixture.detectChanges(); + + const input = getInput(); + const toggleButton = el.querySelector(".tedi-date-picker__toggle") as HTMLButtonElement; + + expect(component.fieldDisabled()).toBe(false); + expect(input.disabled).toBe(false); + expect(toggleButton.disabled).toBe(false); + }); + }); + + describe("fieldDisabled computed", () => { + it("should be true when inputDisabled is true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + + expect(component.fieldDisabled()).toBe(true); + }); + + it("should be true when formDisabled is true (via setDisabledState)", () => { + component.setDisabledState(true); + fixture.detectChanges(); + + expect(component.fieldDisabled()).toBe(true); + }); + + it("should be true when both inputDisabled and formDisabled are true", () => { + fixture.componentRef.setInput("inputDisabled", true); + component.setDisabledState(true); + fixture.detectChanges(); + + expect(component.fieldDisabled()).toBe(true); + }); + + it("should be false when both inputDisabled and formDisabled are false", () => { + fixture.componentRef.setInput("inputDisabled", false); + component.setDisabledState(false); + fixture.detectChanges(); + + expect(component.fieldDisabled()).toBe(false); + }); + }); + + describe("integration with form disabled state", () => { + it("should disable clear button when form is disabled", () => { + component.selected.set(new Date(2024, 5, 15)); + fixture.detectChanges(); + + component.setDisabledState(true); + fixture.detectChanges(); + + const clearButton = el.querySelector(".tedi-date-picker__clear") as HTMLButtonElement; + expect(clearButton.disabled).toBe(true); + }); + + it("should work with combined inputDisabled and form disabled", () => { + // Start with inputDisabled=true + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + + expect(component.fieldDisabled()).toBe(true); + + // Set inputDisabled=false, but enable form disabled + fixture.componentRef.setInput("inputDisabled", false); + component.setDisabledState(true); + fixture.detectChanges(); + + expect(component.fieldDisabled()).toBe(true); + + // Disable form disabled - should now be enabled + component.setDisabledState(false); + fixture.detectChanges(); + + expect(component.fieldDisabled()).toBe(false); + }); + }); + }); +}); + +describe("DatePickerComponent NG_VALUE_ACCESSOR integration", () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let datePicker: DatePickerComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + ], + }); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + + fixture.detectChanges(); + + datePicker = fixture.debugElement.query( + By.directive(DatePickerComponent), + ).componentInstance as DatePickerComponent; + + const mockFloatUiElement = document.createElement("div"); + const mockContainer = document.createElement("div"); + mockContainer.className = "float-ui-container-popover"; + mockFloatUiElement.appendChild(mockContainer); + + jest.spyOn(datePicker.popover(), "floatUiComponent").mockReturnValue({ + state: false, + show: jest.fn(), + hide: jest.fn(), + elRef: { + nativeElement: mockFloatUiElement, + }, + } as unknown as NgxFloatUiContentComponent); + + fixture.detectChanges(); + }); + + it("should update component when FormControl value changes", () => { + const date = new Date(2024, 5, 15); + + host.control.setValue(date); + fixture.detectChanges(); + + expect(datePicker.selected()).toEqual(date); + expect(datePicker.inputValue()).toBe("15.06.2024"); + }); + + it("should update FormControl when date is selected in component", () => { + const date = new Date(2024, 6, 20); + + datePicker.selectDay({ + date, + disabled: false, + inCurrentMonth: true, + }); + fixture.detectChanges(); + + expect(host.control.value).toEqual(date); + }); + + it("should clear FormControl when input is cleared", () => { + host.control.setValue(new Date(2024, 5, 15)); + fixture.detectChanges(); + + datePicker.clearInput(); + fixture.detectChanges(); + + expect(host.control.value).toBeNull(); + }); + + it("should disable component when FormControl is disabled", () => { + host.control.disable(); + fixture.detectChanges(); + + expect(datePicker.fieldDisabled()).toBe(true); + }); + + it("should enable component when FormControl is enabled", () => { + host.control.disable(); + fixture.detectChanges(); + + host.control.enable(); + fixture.detectChanges(); + + expect(datePicker.fieldDisabled()).toBe(false); + }); + + it("should mark FormControl as touched when input is blurred", () => { + expect(host.control.touched).toBe(false); + + const input = fixture.debugElement.query(By.css("input")).nativeElement; + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(host.control.touched).toBe(true); + }); + + it("should mark FormControl as dirty when value changes", () => { + expect(host.control.dirty).toBe(false); + + datePicker.selectDay({ + date: new Date(2024, 5, 15), + disabled: false, + inCurrentMonth: true, + }); + fixture.detectChanges(); + + expect(host.control.dirty).toBe(true); + }); + + it("should handle initial FormControl value", () => { + const initialDate = new Date(2024, 8, 10); + host.control.setValue(initialDate, { emitEvent: false }); + + const newFixture = TestBed.createComponent(TestHostComponent); + newFixture.componentInstance.control.setValue(initialDate, { emitEvent: false }); + newFixture.detectChanges(); + + const newDatePicker = newFixture.debugElement.query( + By.directive(DatePickerComponent), + ).componentInstance as DatePickerComponent; + + expect(newDatePicker.selected()).toEqual(initialDate); + }); + + it("should update FormControl when valid date is entered manually", () => { + const input = fixture.debugElement.query(By.css("input")).nativeElement; + + input.value = "25.07.2024"; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + + input.dispatchEvent(new Event("blur")); + fixture.detectChanges(); + + expect(host.control.value).toEqual(new Date(2024, 6, 25)); + }); }); diff --git a/tedi/components/form/date-picker/date-picker.component.ts b/tedi/components/form/date-picker/date-picker.component.ts index 17bd97967..a99cd9cf2 100644 --- a/tedi/components/form/date-picker/date-picker.component.ts +++ b/tedi/components/form/date-picker/date-picker.component.ts @@ -10,7 +10,9 @@ import { viewChild, ElementRef, effect, + forwardRef, } from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { ButtonComponent } from "../../buttons/button/button.component"; import { ClosingButtonComponent } from "../../buttons/closing-button/closing-button.component"; import { IconComponent } from "../../base/icon/icon.component"; @@ -67,11 +69,33 @@ let datePickerId = 0; DatePickerYearGridComponent, TediTranslationPipe ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatePickerComponent), + multi: true, + }, + ], }) -export class DatePickerComponent implements OnInit { +export class DatePickerComponent implements OnInit, ControlValueAccessor { readonly today = new Date(); readonly uniqueId = `tedi-date-picker-id-${datePickerId++}`; + private formDisabled = signal(false); + private onChange: (value: Date | null) => void = () => {}; + private onTouched: () => void = () => {}; + + private emitIfChanged(value: Date | null): void { + const current = this.selected(); + const changed = value === null + ? current !== null + : !current || !isSameDay(value, current); + + if (changed) { + this.onChange(value); + } + } + /** Selected date */ readonly selected = model(null); @@ -113,6 +137,9 @@ export class DatePickerComponent implements OnInit { /** Is input disabled? */ readonly inputDisabled = input(false); + /** Internal computed for combined disabled state (inputDisabled + formDisabled from reactive forms) */ + readonly fieldDisabled = computed(() => this.inputDisabled() || this.formDisabled()); + /** Is manual typing into input allowed? */ readonly allowManualInput = input(true); @@ -295,6 +322,24 @@ export class DatePickerComponent implements OnInit { this.activeDate.set(active); } + // ControlValueAccessor implementation + writeValue(value: Date | null): void { + this.selected.set(value); + this.inputValue.set(value ? formatDate(value) : ""); + } + + registerOnChange(fn: (value: Date | null) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.formDisabled.set(disabled); + } + getTabIndex(date: Date): number { const active = this.activeDate(); return active && date.toDateString() === active.toDateString() ? 0 : -1; @@ -331,6 +376,7 @@ export class DatePickerComponent implements OnInit { selectDay(day: DatePickerDay) { if (day.disabled) return; + this.emitIfChanged(day.date); this.selected.set(day.date); this.inputValue.set(formatDate(day.date)); @@ -544,12 +590,15 @@ export class DatePickerComponent implements OnInit { } onInputBlur() { + this.onTouched(); + if (!this.allowManualInput()) return; const selected = this.selected(); const parsed = parseDate(this.inputValue()); if (parsed) { + this.emitIfChanged(parsed); this.selected.set(parsed); this.month.set(parsed); } else { @@ -570,6 +619,7 @@ export class DatePickerComponent implements OnInit { } clearInput() { + this.emitIfChanged(null); this.inputValue.set(""); this.selected.set(null); } @@ -577,6 +627,7 @@ export class DatePickerComponent implements OnInit { closeCalendar() { this.popover().floatUiComponent().hide(); this.inputElement().nativeElement.focus(); + this.onTouched(); } openCalendar() { diff --git a/tedi/components/form/date-picker/date-picker.stories.ts b/tedi/components/form/date-picker/date-picker.stories.ts index 0866a94f6..97ccf32e9 100644 --- a/tedi/components/form/date-picker/date-picker.stories.ts +++ b/tedi/components/form/date-picker/date-picker.stories.ts @@ -4,6 +4,7 @@ import { argsToTemplate, moduleMetadata, } from "@storybook/angular"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { DatePickerComponent } from "./date-picker.component"; /** @@ -21,7 +22,7 @@ export default { }, decorators: [ moduleMetadata({ - imports: [DatePickerComponent], + imports: [DatePickerComponent, ReactiveFormsModule], }), ], argTypes: { @@ -230,3 +231,28 @@ export const Default: StoryObj = { `, }), }; + +export const WithReactiveForms: StoryObj = { + render: () => { + const dateControl = new FormControl(new Date(2024, 5, 15)); + + return { + props: { dateControl }, + template: ` + + + + + Value: {{ dateControl.value ?? 'null' }} + Touched: {{ dateControl.touched }} + Dirty: {{ dateControl.dirty }} + + + `, + }; + }, +};