From de306b7cc84158fd6d78cf7b57ccc44ba00c966d Mon Sep 17 00:00:00 2001 From: m2rt Date: Tue, 30 Jun 2026 16:24:15 +0300 Subject: [PATCH 1/3] feat(date-time-field): new tedi-ready component #507 --- skills/tedi-angular/references/components.md | 59 ++ .../date-input/date-input.component.ts | 6 +- .../date-time-field-modal.component.html | 81 ++ .../date-time-field-modal.component.scss | 43 ++ .../date-time-field-modal.component.spec.ts | 216 ++++++ .../date-time-field-modal.component.ts | 149 ++++ .../date-time-field.component.html | 173 +++++ .../date-time-field.component.scss | 115 +++ .../date-time-field.component.spec.ts | 677 +++++++++++++++++ .../date-time-field.component.ts | 706 ++++++++++++++++++ .../date-time-field.stories.ts | 333 +++++++++ .../date-time-field/date-time-field.util.ts | 111 +++ tedi/components/form/date-time-field/index.ts | 11 + .../form/form-field/form-field.component.html | 2 +- tedi/components/form/index.ts | 1 + tedi/services/translation/translations.ts | 30 + 16 files changed, 2711 insertions(+), 2 deletions(-) create mode 100644 tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.html create mode 100644 tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.scss create mode 100644 tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.spec.ts create mode 100644 tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.ts create mode 100644 tedi/components/form/date-time-field/date-time-field.component.html create mode 100644 tedi/components/form/date-time-field/date-time-field.component.scss create mode 100644 tedi/components/form/date-time-field/date-time-field.component.spec.ts create mode 100644 tedi/components/form/date-time-field/date-time-field.component.ts create mode 100644 tedi/components/form/date-time-field/date-time-field.stories.ts create mode 100644 tedi/components/form/date-time-field/date-time-field.util.ts create mode 100644 tedi/components/form/date-time-field/index.ts diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index d517602a7..0a46555e4 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -1033,6 +1033,65 @@ Form-control wrapper around the Calendar. Exposes a typed text input paired with ``` +### DateTimeField +**Selector:** `tedi-date-time-field` | ControlValueAccessor + +Form-control wrapper that pairs a typed text input with a popover holding the Calendar and a TimePicker. `side-by-side` shows both together; `multi-step` picks the date first, then advances to a separate time step. Setting `availableTimes` swaps the scroll-wheel for a grid of predefined slots. Supports `single` and `range` modes, a native `datetime-local` fallback, and a mobile modal. Like DateField it owns no label — compose with `tedi-form-field` + `tedi-label`. Inherits the same calendar options as DateField (`minDate`, `maxDate`, `disablePast`/`disableFuture`, `disabledMatchers`, `availableDays`/`unavailableDays`, `selectionLevel`, `monthYearSelectType`, `initialMonth`, `showOutsideDays`, `showWeekNumbers`). + +**Model:** `value: Date | DateRange | null` (range carries a time on each end) +**Outputs:** +- `openChange: boolean` — emitted when the picker (popover/modal) open state changes + +**Inputs:** +- `inputId: string` (required) — unique ID for label association +- `mode: DateTimeFieldMode = "single"` — "single" (one Date) or "range" ({ from, to }) +- `layout: DateTimeFieldLayout = "side-by-side"` — "side-by-side" or "multi-step"; `range` always renders side-by-side +- `availableTimes: string[] | ((date: Date) => string[]) | undefined` — predefined `HH:mm` slots (static or per-date); renders a slot grid instead of the wheel +- `timeGridVariant: "button" | "radio" | undefined` — slot grid style; defaults to "button" for side-by-side, "radio" for multi-step +- `minuteStep: number = 15` — minute interval for the scroll-wheel; ignored when `availableTimes` is set +- `slotColumns: number = 3` — columns in the slot grid +- `timeHeading / selectTimeLabel / backLabel: string | undefined` — label overrides (default to translated strings) +- `placeholder: string = ""` — placeholder when no value +- `localeCode: string = "et-EE"` — BCP-47 locale for the calendar and date/time formatting +- `inputDisabled / readOnly / required: boolean = false` +- `size: "default" | "small" = "default"` — should match the surrounding `tedi-form-field` +- `numberOfMonths: BreakpointInput = { xs: 1, md: 2 }` — months side by side in `range` mode (single is always 1) +- `useNativePicker: boolean | "sm" | "md" | "lg" | "xl" = false` — OS-native `datetime-local` picker (single mode only); `true` always, breakpoint name uses native below it +- `modal: boolean | "sm" | "md" | "lg" | "xl" = false` — open in a centered modal (with Cancel/Confirm) instead of the popover; breakpoint name means modal below it +- `fullscreen: boolean | "sm" | "md" | "lg" | "xl" = false` — render the modal fullscreen; only applies when it opens as a modal +- `formatDate: ((value: DateTimeFieldValue) => string) | undefined` — custom display formatter + +```html + + + + + + + + + + + + + + + + + + +``` + ### TimeField **Selector:** `tedi-time-field` **Model:** `value: string | null` — `HH:mm` diff --git a/tedi/components/form/date-field/date-input/date-input.component.ts b/tedi/components/form/date-field/date-input/date-input.component.ts index 436fd7fa7..d3abfcb3d 100644 --- a/tedi/components/form/date-field/date-input/date-input.component.ts +++ b/tedi/components/form/date-field/date-input/date-input.component.ts @@ -76,6 +76,8 @@ export class DateInputComponent implements AfterViewChecked { readonly iconActive = input(false); readonly iconDisabled = input(false); readonly useNativePicker = input(false); + /** Native input type used when `useNativePicker` is `true`. `date` for date-only fields, `datetime-local` for date-and-time fields. */ + readonly nativeInputType = input<"date" | "datetime-local">("date"); readonly nativeIsoValue = input(""); readonly clearable = input(false); @@ -150,7 +152,9 @@ export class DateInputComponent implements AfterViewChecked { return total - visible; }); - readonly inputType = computed(() => (this.useNativePicker() ? "date" : "text")); + readonly inputType = computed(() => + this.useNativePicker() ? this.nativeInputType() : "text", + ); readonly inputValue = computed(() => this.useNativePicker() ? this.nativeIsoValue() : this.value(), diff --git a/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.html b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.html new file mode 100644 index 000000000..93d0b6886 --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.html @@ -0,0 +1,81 @@ + + +

{{ "date-time-field.modal-title" | tediTranslate }}

+
+ + + + @if (isRange) { +
+ + +
+ } @else { +
+ + {{ data.timeHeading ?? ("date-time-field.time-heading" | tediTranslate) }} + + +
+ } +
+ + + + +
+ + +
+ + {{ data.timeHeading ?? ("date-time-field.time-heading" | tediTranslate) }} + + +
+
diff --git a/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.scss b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.scss new file mode 100644 index 000000000..c0d2eafb3 --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.scss @@ -0,0 +1,43 @@ +.tedi-date-time-field-modal { + --_tedi-modal-body-padding: 0; + + &__content { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-04); + align-items: center; + } + + &__times { + display: flex; + flex-direction: column; + gap: var(--layout-grid-gutters-04); + width: 100%; + } + + &__time { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default); + } + + &__heading { + align-self: stretch; + padding-top: var(--card-padding-md-default); + padding-bottom: var(--card-padding-xs); + font-size: var(--body-bold-size); + font-weight: var(--body-bold-weight); + line-height: var(--body-bold-line-height); + color: var(--general-text-primary); + text-align: center; + } + + // See date-time-field.component.scss: re-assert the slot grid's `display: grid` (which + // tedi-radio-card-group's flex can override by injection order) and drop its own + // padding since `&__time` already provides it. + .tedi-time-picker__grid { + display: grid; + padding: 0; + } +} diff --git a/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.spec.ts b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.spec.ts new file mode 100644 index 000000000..1871c22b9 --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.spec.ts @@ -0,0 +1,216 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + DateTimeFieldModalComponent, + DateTimeFieldModalData, +} from "./date-time-field-modal.component"; +import { ModalRef } from "../../../overlay/modal/modal-ref"; +import { MODAL_DATA } from "../../../overlay/modal/modal.types"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; +import { DateRange } from "../../../content/calendar/types"; + +class TranslationMock { + translate(key: string): string { + return key; + } + track(key: string) { + return () => key; + } +} + +function makeData( + overrides: Partial = {}, +): DateTimeFieldModalData { + return { + value: null, + currentMonth: new Date(2026, 4, 1), + mode: "single", + selectionLevel: "days", + localeCode: "et-EE", + showOutsideDays: true, + showWeekNumbers: false, + numberOfMonths: 1, + monthYearSelectType: "dropdown", + required: false, + disabledMatchers: [], + availableDays: undefined, + unavailableDays: undefined, + shouldDisableMonth: undefined, + shouldDisableYear: undefined, + minuteStep: 15, + slotColumns: 3, + gridVariant: "button", + timeHeading: undefined, + availableTimes: undefined, + ...overrides, + }; +} + +describe("DateTimeFieldModalComponent", () => { + let fixture: ComponentFixture; + let component: DateTimeFieldModalComponent; + let close: jest.Mock; + + function setup(data: DateTimeFieldModalData = makeData()): void { + close = jest.fn(); + const ref = { close } as unknown as ModalRef; + TestBed.configureTestingModule({ + imports: [DateTimeFieldModalComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: MODAL_DATA, useValue: data }, + { provide: ModalRef, useValue: ref }, + ], + }); + fixture = TestBed.createComponent(DateTimeFieldModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it("renders and seeds the draft from data.value", () => { + const value = new Date(2026, 4, 14, 10, 0); + setup(makeData({ value })); + expect(component.draft()).toBe(value); + }); + + it("cancel closes with undefined", () => { + setup(); + component.cancel(); + expect(close).toHaveBeenCalledWith(undefined); + }); + + it("confirm closes with the current draft", () => { + setup(); + const next = new Date(2026, 4, 14, 9, 0); + component.draft.set(next); + component.confirm(); + expect(close).toHaveBeenCalledWith(next); + }); + + it("combines the picked time into the single draft", () => { + setup(makeData({ value: new Date(2026, 4, 14, 0, 0) })); + component.handleTimeSelect("09:30"); + const v = component.draft() as Date; + expect(v.getHours()).toBe(9); + expect(v.getMinutes()).toBe(30); + }); + + it("combines the from time into the range draft", () => { + setup( + makeData({ + mode: "range", + value: { from: new Date(2026, 4, 14, 0, 0) } as DateRange, + }), + ); + component.handleRangeTimeSelect("from", "08:00"); + const v = component.draft() as DateRange; + expect(v.from.getHours()).toBe(8); + }); + + function stubCalendar(value: unknown): void { + (component as unknown as { calendar: () => unknown }).calendar = () => ({ + value: () => value, + }); + } + + it("combines the calendar date with the existing time on select (single)", () => { + setup(makeData({ value: new Date(2026, 4, 14, 14, 30) })); + stubCalendar(new Date(2026, 4, 20)); + component.handleCalendarSelect(); + const v = component.draft() as Date; + expect(v.getDate()).toBe(20); + expect(v.getHours()).toBe(14); + expect(v.getMinutes()).toBe(30); + }); + + it("combines both ends on range select", () => { + setup( + makeData({ + mode: "range", + value: { + from: new Date(2026, 4, 14, 9, 0), + to: new Date(2026, 4, 16, 17, 0), + } as DateRange, + }), + ); + stubCalendar({ + from: new Date(2026, 4, 20), + to: new Date(2026, 4, 22), + } as DateRange); + component.handleCalendarSelect(); + const v = component.draft() as DateRange; + expect(v.from.getDate()).toBe(20); + expect(v.from.getHours()).toBe(9); + expect(v.to?.getDate()).toBe(22); + expect(v.to?.getHours()).toBe(17); + }); + + it("clears the draft when the calendar selection is empty", () => { + setup(makeData({ value: new Date(2026, 4, 14, 9, 0) })); + stubCalendar(null); + component.handleCalendarSelect(); + expect(component.draft()).toBeNull(); + }); + + it("combines the to time into the range draft", () => { + setup( + makeData({ + mode: "range", + value: { from: new Date(2026, 4, 14, 9, 0) } as DateRange, + }), + ); + component.handleRangeTimeSelect("to", "18:30"); + const v = component.draft() as DateRange; + expect(v.to?.getHours()).toBe(18); + }); + + it("resolves available times from a function", () => { + setup( + makeData({ + value: new Date(2026, 4, 14, 0, 0), + availableTimes: () => ["10:00", "11:00"], + }), + ); + expect(component.singleSlots()).toEqual(["10:00", "11:00"]); + }); + + it("resolves a static available-times array", () => { + setup(makeData({ availableTimes: ["09:00", "09:30"] })); + expect(component.singleSlots()).toEqual(["09:00", "09:30"]); + }); + + it("returns undefined slots when no availableTimes are set", () => { + setup(); + expect(component.singleSlots()).toBeUndefined(); + }); + + it("creates a single value from today when no date is selected", () => { + setup(); + component.handleTimeSelect("07:45"); + const v = component.draft() as Date; + expect(v.getHours()).toBe(7); + expect(v.getMinutes()).toBe(45); + }); + + it("snaps to the first slot when the existing time is unavailable", () => { + setup( + makeData({ + value: new Date(2026, 4, 14, 23, 0), + availableTimes: ["09:30", "10:00"], + }), + ); + stubCalendar(new Date(2026, 4, 20)); + component.handleCalendarSelect(); + const v = component.draft() as Date; + expect(v.getHours()).toBe(9); + expect(v.getMinutes()).toBe(30); + }); + + it("ignores empty time selections", () => { + setup(makeData({ value: new Date(2026, 4, 14, 9, 0) })); + component.handleTimeSelect(null); + component.handleRangeTimeSelect("from", null); + expect((component.draft() as Date).getHours()).toBe(9); + }); +}); diff --git a/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.ts b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.ts new file mode 100644 index 000000000..02ffe325a --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.ts @@ -0,0 +1,149 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, + viewChild, + ViewEncapsulation, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { ButtonComponent } from "../../../buttons/button/button.component"; +import { ModalComponent } from "../../../overlay/modal/modal.component"; +import { ModalContentComponent } from "../../../overlay/modal/modal-content/modal-content.component"; +import { ModalFooterComponent } from "../../../overlay/modal/modal-footer/modal-footer.component"; +import { ModalHeaderComponent } from "../../../overlay/modal/modal-header/modal-header.component"; +import { ModalRef } from "../../../overlay/modal/modal-ref"; +import { MODAL_DATA } from "../../../overlay/modal/modal.types"; +import { TediTranslationPipe } from "../../../../services/translation/translation.pipe"; +import { CalendarComponent } from "../../../content/calendar/calendar.component"; +import { TimePickerComponent } from "../../time-picker/time-picker.component"; +import { + CalendarView, + DateRange, +} from "../../../content/calendar/types"; +import { Matcher } from "../../../../utils/matchers.util"; +import { + DateTimeFieldMode, + DateTimeFieldTimeGridVariant, + DateTimeFieldValue, +} from "../date-time-field.component"; +import { + applyRangeTime, + combineCalendarSelection, + combineDateTime, + DateTimeFieldAvailableTimes, + DayAvailabilityInput, + getTimeOf, + MonthPredicate, + RangeParts, + resolveAvailableTimes, + toRangeParts, + toSingle, + YearPredicate, +} from "../date-time-field.util"; + +export interface DateTimeFieldModalData { + value: DateTimeFieldValue; + currentMonth: Date; + mode: DateTimeFieldMode; + selectionLevel: CalendarView; + localeCode: string; + showOutsideDays: boolean; + showWeekNumbers: boolean; + numberOfMonths: number; + monthYearSelectType: "dropdown" | "grid"; + required: boolean; + disabledMatchers: Matcher[]; + availableDays: DayAvailabilityInput; + unavailableDays: DayAvailabilityInput; + shouldDisableMonth: MonthPredicate | undefined; + shouldDisableYear: YearPredicate | undefined; + minuteStep: number; + slotColumns: number; + gridVariant: DateTimeFieldTimeGridVariant; + timeHeading: string | undefined; + availableTimes: DateTimeFieldAvailableTimes; +} + +@Component({ + selector: "tedi-date-time-field-modal", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + NgTemplateOutlet, + ButtonComponent, + CalendarComponent, + TimePickerComponent, + ModalComponent, + ModalContentComponent, + ModalFooterComponent, + ModalHeaderComponent, + TediTranslationPipe, + ], + templateUrl: "./date-time-field-modal.component.html", + styleUrl: "./date-time-field-modal.component.scss", +}) +export class DateTimeFieldModalComponent { + readonly data = inject(MODAL_DATA) as DateTimeFieldModalData; + private readonly ref = inject(ModalRef); + + readonly calendar = viewChild("calendar"); + + readonly draft = signal(this.data.value); + readonly month = signal(this.data.currentMonth); + + readonly isRange = this.data.mode === "range"; + + readonly singleValue = computed(() => toSingle(this.draft())); + readonly rangeValue = computed(() => toRangeParts(this.draft())); + + readonly singleTime = computed(() => getTimeOf(this.singleValue())); + readonly fromTime = computed(() => getTimeOf(this.rangeValue().from)); + readonly toTime = computed(() => getTimeOf(this.rangeValue().to)); + + readonly singleSlots = computed(() => + resolveAvailableTimes(this.data.availableTimes, this.singleValue()), + ); + readonly fromSlots = computed(() => + resolveAvailableTimes(this.data.availableTimes, this.rangeValue().from), + ); + readonly toSlots = computed(() => + resolveAvailableTimes(this.data.availableTimes, this.rangeValue().to), + ); + + cancel(): void { + this.ref.close(undefined); + } + + confirm(): void { + this.ref.close(this.draft()); + } + + handleCalendarSelect(): void { + const calendar = this.calendar(); + if (!calendar) return; + this.draft.set( + combineCalendarSelection( + calendar.value() as Date | RangeParts | null, + this.isRange, + this.draft(), + this.data.availableTimes, + ) as DateTimeFieldValue, + ); + } + + handleTimeSelect(time: string | null): void { + if (!time) return; + const base = this.singleValue() ?? new Date(); + this.draft.set(combineDateTime(base, time)); + } + + handleRangeTimeSelect(kind: "from" | "to", time: string | null): void { + if (!time) return; + this.draft.set( + applyRangeTime(kind, time, this.rangeValue(), new Date()) as DateRange, + ); + } +} diff --git a/tedi/components/form/date-time-field/date-time-field.component.html b/tedi/components/form/date-time-field/date-time-field.component.html new file mode 100644 index 000000000..68167b999 --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field.component.html @@ -0,0 +1,173 @@ + + +@if (usePopover()) { + + + +} + + + + @if (footer) { +
+ +
+ } +
+
+ + + + + + + + + + +
+ + {{ timeHeading() ?? ("date-time-field.time-heading" | tediTranslate) }} + + +
+
diff --git a/tedi/components/form/date-time-field/date-time-field.component.scss b/tedi/components/form/date-time-field/date-time-field.component.scss new file mode 100644 index 000000000..757c7ea5a --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field.component.scss @@ -0,0 +1,115 @@ +.tedi-date-time-field { + display: flex; + flex: 1; + gap: var(--form-field-inner-spacing); + align-items: center; + min-width: 0; + + &__overlay { + background: var(--card-background-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + } + + &__split { + display: flex; + align-items: stretch; + } + + // Side-by-side layout: grow the scroll-wheel so its height lines up with the calendar + // grid beside it (taller than the picker's standalone default). The picker derives its + // fade band from this height, so only the height needs overriding. + &__split .tedi-time-picker--scroll { + --tedi-time-picker-columns-height: 17.5rem; + } + + &__split-separator { + flex: 0 0 var(--tedi-borders-01); + align-self: stretch; + background: var(--general-border-primary); + } + + &__split-time { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default); + } + + // The time picker's slot grid declares `display: grid`, but tedi-radio-card-group's + // own `display: flex` can win by stylesheet-injection order, collapsing the grid to a + // single flex row. Re-assert grid here (higher specificity) so the picker's + // `grid-template-columns` honours the configured column count. The grid's own padding + // is dropped — `&__split-time` already provides the surrounding padding (avoids + // doubling it). + &__overlay .tedi-time-picker__grid { + display: grid; + padding: 0; + } + + &__split-heading { + align-self: stretch; + padding-top: var(--card-padding-md-default); + padding-bottom: var(--card-padding-xs); + font-size: var(--body-bold-size); + font-weight: var(--body-bold-weight); + line-height: var(--body-bold-line-height); + color: var(--general-text-primary); + text-align: center; + } + + &__range { + display: flex; + flex-direction: column; + } + + &__range-separator { + flex: 0 0 var(--tedi-borders-01); + background: var(--general-border-primary); + } + + &__range-times { + display: flex; + justify-content: center; + } + + &__range-times .tedi-date-time-field__split-time { + flex: 1 1 0; + } + + &__time-step { + display: flex; + flex-direction: column; + min-width: var(--tedi-containers-03); + } + + &__time-header { + display: flex; + gap: var(--layout-grid-gutters-04); + align-items: center; + justify-content: space-between; + padding: var(--card-padding-md-default); + } + + &__time-back { + display: inline-flex; + gap: var(--layout-grid-gutters-02); + align-items: center; + } + + &__time-date { + font-size: var(--body-bold-size); + font-weight: var(--body-bold-weight); + line-height: var(--body-bold-line-height); + color: var(--general-text-primary); + } + + &__time-step .tedi-time-picker { + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default); + } + + &__select-time-wrapper { + display: flex; + justify-content: center; + } +} diff --git a/tedi/components/form/date-time-field/date-time-field.component.spec.ts b/tedi/components/form/date-time-field/date-time-field.component.spec.ts new file mode 100644 index 000000000..662f502de --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field.component.spec.ts @@ -0,0 +1,677 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Component, signal } from "@angular/core"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { + DateTimeFieldComponent, + DateTimeFieldValue, +} from "./date-time-field.component"; +import { DateRange } from "../../content/calendar/types"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; +import { BreakpointService } from "../../../services/breakpoint/breakpoint.service"; +import { ModalService } from "../../overlay/modal/modal.service"; + +class TranslationMock { + translate(key: string): string { + return key; + } + track(key: string) { + return () => key; + } +} + +class BreakpointServiceMock { + private current = signal("lg"); + + setBreakpoint(bp: string): void { + this.current.set(bp); + } + + currentBreakpoint() { + return this.current; + } + + isBelowBreakpoint(bp: string | (() => string)) { + return () => { + const target = typeof bp === "function" ? bp() : bp; + const order = ["xs", "sm", "md", "lg", "xl", "xxl"]; + const ci = order.indexOf(this.current()); + const ti = order.indexOf(target); + return ci !== -1 && ti !== -1 && ci < ti; + }; + } + + isAboveBreakpoint(bp: string | (() => string)) { + return () => { + const target = typeof bp === "function" ? bp() : bp; + const order = ["xs", "sm", "md", "lg", "xl", "xxl"]; + const ci = order.indexOf(this.current()); + const ti = order.indexOf(target); + return ci !== -1 && ti !== -1 && ci >= ti; + }; + } +} + +class ModalServiceStub { + open = jest.fn(); + closeAll = jest.fn(); +} + +function configureBaseModule( + breakpoint: BreakpointServiceMock = new BreakpointServiceMock(), + modalService: ModalServiceStub = new ModalServiceStub(), +): { breakpoint: BreakpointServiceMock; modalService: ModalServiceStub } { + TestBed.configureTestingModule({ + imports: [DateTimeFieldComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: BreakpointService, useValue: breakpoint }, + { provide: ModalService, useValue: modalService }, + ], + }); + return { breakpoint, modalService }; +} + +function createField(inputs: Record = {}): { + fixture: ComponentFixture; + component: DateTimeFieldComponent; + el: HTMLElement; + breakpoint: BreakpointServiceMock; + modalService: ModalServiceStub; +} { + const { breakpoint, modalService } = configureBaseModule(); + const fixture = TestBed.createComponent(DateTimeFieldComponent); + fixture.componentRef.setInput("inputId", "test-dtf"); + for (const [k, v] of Object.entries(inputs)) { + fixture.componentRef.setInput(k, v); + } + fixture.detectChanges(); + return { + fixture, + component: fixture.componentInstance, + el: fixture.nativeElement, + breakpoint, + modalService, + }; +} + +const pad2 = (n: number): string => String(n).padStart(2, "0"); + +/** Replace the calendar viewChild with a stub returning `value`. */ +function stubCalendar( + component: DateTimeFieldComponent, + value: DateTimeFieldValue, +): void { + (component as unknown as { calendar: () => unknown }).calendar = () => ({ + value: () => value, + focusActiveCell: () => {}, + }); +} + +describe("DateTimeFieldComponent", () => { + it("creates the component", () => { + const { component } = createField(); + expect(component).toBeTruthy(); + }); + + describe("input + display", () => { + it("assigns the inputId to the date-input", () => { + const { el } = createField(); + const input = el.querySelector("input.tedi-date-input__input"); + expect(input?.id).toBe("test-dtf"); + }); + + it("derives a date + time placeholder for et", () => { + const { component } = createField(); + expect(component.effectivePlaceholder()).toMatch(/tt:mm$/); + }); + + it("formats a single date+time value", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 11, 30) }); + expect(component.displayValue()).toContain("11:30"); + }); + + it("formats a range value with both ends", () => { + const { component } = createField({ + mode: "range", + value: { + from: new Date(2025, 8, 1, 9, 0), + to: new Date(2025, 8, 3, 17, 0), + } as DateRange, + }); + const display = component.displayValue(); + expect(display).toContain("09:00"); + expect(display).toContain("17:00"); + expect(display).toContain("–"); + }); + }); + + describe("opening the picker", () => { + it("opens the popover on icon click", () => { + const { component, el, fixture } = createField(); + el + .querySelector(".tedi-date-input__icon")! + .click(); + fixture.detectChanges(); + expect(component.overlayOpen()).toBe(true); + }); + + it("emits openChange when the picker opens", () => { + const { component, fixture } = createField(); + const spy = jest.fn(); + component.openChange.subscribe(spy); + component.handleIconClick(); + fixture.detectChanges(); + expect(spy).toHaveBeenCalledWith(true); + }); + + it("resets the multi-step to the date step when closed", () => { + const { component } = createField({ layout: "multi-step" }); + component.handleIconClick(); + component.goToTimeStep(); + expect(component.step()).toBe("time"); + component.closeOverlay(); + expect(component.step()).toBe("date"); + }); + }); + + describe("selection", () => { + it("combines the picked date with the existing time", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 14, 45) }); + stubCalendar(component, new Date(2025, 8, 10)); + component.handleCalendarSelect(); + const v = component.value() as Date; + expect(v.getDate()).toBe(10); + expect(v.getHours()).toBe(14); + expect(v.getMinutes()).toBe(45); + }); + + it("combines the picked time with the selected date", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 0, 0) }); + component.handleTimeSelect("09:30"); + const v = component.value() as Date; + expect(v.getHours()).toBe(9); + expect(v.getMinutes()).toBe(30); + expect(v.getDate()).toBe(1); + }); + + it("parses a typed date + time string", () => { + const { component } = createField(); + component.handleInputChange("01.09.2025 11:30"); + const v = component.value() as Date; + expect(v.getFullYear()).toBe(2025); + expect(v.getMonth()).toBe(8); + expect(v.getDate()).toBe(1); + expect(v.getHours()).toBe(11); + expect(v.getMinutes()).toBe(30); + }); + + it("ignores a typed date that fails the disable matchers", () => { + const { component } = createField({ + value: new Date(2025, 8, 1, 10, 0), + minDate: new Date(2025, 8, 5), + }); + component.handleInputChange("01.09.2025 11:30"); + expect((component.value() as Date).getDate()).toBe(1); + expect((component.value() as Date).getHours()).toBe(10); + }); + + it("clears the value", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 10, 0) }); + component.handleClear(); + expect(component.value()).toBeNull(); + }); + }); + + describe("range", () => { + it("forces the side-by-side layout", () => { + const { component } = createField({ mode: "range", layout: "multi-step" }); + expect(component.effectiveLayout()).toBe("side-by-side"); + }); + + it("combines the from time into the range", () => { + const { component } = createField({ + mode: "range", + value: { from: new Date(2025, 8, 1, 0, 0) } as DateRange, + }); + component.handleRangeTimeSelect("from", "08:15"); + const v = component.value() as DateRange; + expect(v.from.getHours()).toBe(8); + expect(v.from.getMinutes()).toBe(15); + }); + + it("combines the to time into the range", () => { + const { component } = createField({ + mode: "range", + value: { from: new Date(2025, 8, 1, 9, 0) } as DateRange, + }); + component.handleRangeTimeSelect("to", "18:30"); + const v = component.value() as DateRange; + expect(v.to?.getHours()).toBe(18); + expect(v.from.getHours()).toBe(9); + }); + + it("resolves two months on a wide breakpoint", () => { + const { component } = createField({ mode: "range" }); + expect(component.numberOfMonthsResolved()).toBe(2); + }); + + it("combines both ends from a calendar range select", () => { + const { component } = createField({ + mode: "range", + value: { + from: new Date(2025, 8, 1, 9, 0), + to: new Date(2025, 8, 3, 17, 0), + } as DateRange, + }); + stubCalendar(component, { + from: new Date(2025, 8, 10), + to: new Date(2025, 8, 12), + } as DateRange); + component.handleCalendarSelect(); + const v = component.value() as DateRange; + expect(v.from.getDate()).toBe(10); + expect(v.from.getHours()).toBe(9); + expect(v.to?.getDate()).toBe(12); + expect(v.to?.getHours()).toBe(17); + }); + + it("clears the value when the calendar range is empty", () => { + const { component } = createField({ + mode: "range", + value: { from: new Date(2025, 8, 1, 9, 0) } as DateRange, + }); + stubCalendar(component, {} as DateRange); + component.handleCalendarSelect(); + expect(component.value()).toBeNull(); + }); + }); + + describe("available times", () => { + it("uses the slots variant when availableTimes is set", () => { + const { component } = createField({ + value: new Date(2025, 8, 1, 10, 0), + availableTimes: ["09:30", "10:00"], + }); + expect(component.singleAvailableTimes()).toEqual(["09:30", "10:00"]); + }); + + it("defaults to the radio grid variant in multi-step", () => { + const { component } = createField({ + layout: "multi-step", + availableTimes: ["09:30"], + }); + expect(component.resolvedGridVariant()).toBe("radio"); + }); + + it("snaps the time to the first slot when the existing time is unavailable", () => { + const { component } = createField({ + value: new Date(2025, 8, 1, 14, 45), + availableTimes: ["09:30", "10:00"], + }); + stubCalendar(component, new Date(2025, 8, 2)); + component.handleCalendarSelect(); + const v = component.value() as Date; + expect(`${v.getHours()}:${v.getMinutes()}`).toBe("9:30"); + }); + }); + + describe("native picker", () => { + it("renders a datetime-local input and ISO value", () => { + const { component, el } = createField({ + useNativePicker: true, + value: new Date(2025, 8, 1, 11, 30), + }); + expect(component.useNativePickerEffective()).toBe(true); + const input = el.querySelector( + "input.tedi-date-input__input", + )!; + expect(input.type).toBe("datetime-local"); + expect(component.nativeIsoValue()).toBe("2025-09-01T11:30"); + }); + + it("parses a native datetime-local string", () => { + const { component } = createField({ useNativePicker: true }); + component.handleInputChange("2025-09-01T11:30"); + const v = component.value() as Date; + expect(v.getHours()).toBe(11); + expect(v.getMinutes()).toBe(30); + }); + + it("never uses the native picker in range mode", () => { + const { component } = createField({ useNativePicker: true, mode: "range" }); + expect(component.useNativePickerEffective()).toBe(false); + }); + }); + + describe("modal", () => { + it("opens a modal when modal is enabled", () => { + const { component, modalService } = createField({ modal: true }); + modalService.open.mockReturnValue({ closed: { subscribe: jest.fn() } }); + component.handleIconClick(); + expect(modalService.open).toHaveBeenCalled(); + }); + }); + + describe("placeholder + layout resolution", () => { + it("uses an explicit placeholder verbatim", () => { + const { component } = createField({ placeholder: "custom" }); + expect(component.effectivePlaceholder()).toBe("custom"); + }); + + it("has no placeholder for the native picker", () => { + const { component } = createField({ useNativePicker: true }); + expect(component.effectivePlaceholder()).toBe(""); + }); + + it("has no placeholder in range mode", () => { + const { component } = createField({ mode: "range" }); + expect(component.effectivePlaceholder()).toBe(""); + }); + + it("uses an hh:mm hint for non-et locales", () => { + const { component } = createField({ localeCode: "en-GB" }); + expect(component.effectivePlaceholder()).toMatch(/hh:mm$/); + }); + + it("uses the popover when neither modal nor native applies", () => { + const { component } = createField(); + expect(component.usePopover()).toBe(true); + expect(component.useModal()).toBe(false); + }); + }); + + describe("time selection details", () => { + it("closes the overlay after a slot pick in multi-step", () => { + const { component } = createField({ + layout: "multi-step", + availableTimes: ["09:30", "10:00"], + value: new Date(2025, 8, 1, 0, 0), + }); + component.handleIconClick(); + component.goToTimeStep(); + component.handleTimeSelect("09:30"); + expect(component.overlayOpen()).toBe(false); + }); + + it("creates a value from today when no date is selected yet", () => { + const { component } = createField(); + component.handleTimeSelect("08:15"); + const v = component.value() as Date; + expect(v.getHours()).toBe(8); + expect(v.getMinutes()).toBe(15); + }); + + it("keeps the existing time when it is still an available slot", () => { + const { component } = createField({ + value: new Date(2025, 8, 1, 10, 0), + availableTimes: ["09:30", "10:00"], + }); + stubCalendar(component, new Date(2025, 8, 2)); + component.handleCalendarSelect(); + const v = component.value() as Date; + expect(`${v.getHours()}:${pad2(v.getMinutes())}`).toBe("10:00"); + }); + + it("seeds a 'to' time even without a from", () => { + const { component } = createField({ mode: "range" }); + component.handleRangeTimeSelect("to", "12:00"); + const v = component.value() as DateRange; + expect(v.from.getHours()).toBe(12); + expect(v.to?.getHours()).toBe(12); + }); + }); + + describe("overlay dismissal", () => { + it("closes on Escape", () => { + const { component } = createField(); + component.handleIconClick(); + const event = new KeyboardEvent("keydown", { key: "Escape" }); + jest.spyOn(event, "preventDefault"); + component.handleOverlayKeydown(event); + expect(component.overlayOpen()).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it("closes on an outside click but not an inside one", () => { + const { component, el } = createField(); + component.handleIconClick(); + const inside = new MouseEvent("click"); + Object.defineProperty(inside, "target", { value: el.querySelector("input") }); + component.handleOverlayOutsideClick(inside); + expect(component.overlayOpen()).toBe(true); + + const outside = new MouseEvent("click"); + Object.defineProperty(outside, "target", { value: document.body }); + component.handleOverlayOutsideClick(outside); + expect(component.overlayOpen()).toBe(false); + }); + }); + + describe("typed + native edge cases", () => { + it("commits null on empty input", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 10, 0) }); + component.handleInputChange(""); + expect(component.value()).toBeNull(); + }); + + it("parses a date-only string and keeps the existing time", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 14, 30) }); + component.handleInputChange("05.09.2025"); + const v = component.value() as Date; + expect(v.getDate()).toBe(5); + expect(v.getHours()).toBe(14); + expect(v.getMinutes()).toBe(30); + }); + + it("ignores an unparseable string", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 10, 0) }); + component.handleInputChange("not a date"); + expect((component.value() as Date).getDate()).toBe(1); + }); + + it("ignores an out-of-range typed time", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 10, 0) }); + component.handleInputChange("01.09.2025 25:99"); + expect((component.value() as Date).getHours()).toBe(10); + }); + + it("ignores typed input while read-only", () => { + const { component } = createField({ readOnly: true }); + component.handleInputChange("01.09.2025 11:30"); + expect(component.value()).toBeNull(); + }); + + it("clears the native value on empty input", () => { + const { component } = createField({ + useNativePicker: true, + value: new Date(2025, 8, 1, 10, 0), + }); + component.handleInputChange(""); + expect(component.value()).toBeNull(); + }); + + it("ignores an invalid native datetime string", () => { + const { component } = createField({ useNativePicker: true }); + component.handleInputChange("2025-13-40T99:99"); + expect(component.value()).toBeNull(); + }); + + it("does not clear when the field is disabled", () => { + const { component } = createField({ + value: new Date(2025, 8, 1, 10, 0), + inputDisabled: true, + }); + component.handleClear(); + expect(component.value()).not.toBeNull(); + }); + }); + + describe("display edge cases", () => { + it("formats a range with only a from value", () => { + const { component } = createField({ + mode: "range", + value: { from: new Date(2025, 8, 1, 9, 0) } as DateRange, + }); + const display = component.displayValue(); + expect(display).toContain("09:00"); + expect(display).not.toContain("–"); + }); + + it("returns an empty display for a null value", () => { + const { component } = createField(); + expect(component.displayValue()).toBe(""); + }); + + it("uses a custom formatDate when provided", () => { + const { component } = createField({ + value: new Date(2025, 8, 1, 10, 0), + formatDate: () => "CUSTOM", + }); + expect(component.displayValue()).toBe("CUSTOM"); + }); + }); + + describe("modal lifecycle", () => { + it("commits the value returned when the modal closes", () => { + const { component, modalService } = createField({ modal: true }); + let closedCb: ((v: DateTimeFieldValue) => void) | undefined; + modalService.open.mockReturnValue({ + closed: { subscribe: (cb: (v: DateTimeFieldValue) => void) => (closedCb = cb) }, + }); + component.handleIconClick(); + const result = new Date(2025, 8, 1, 12, 0); + closedCb!(result); + expect(component.value()).toBe(result); + expect(component.overlayOpen()).toBe(false); + }); + + it("keeps the value when the modal is dismissed", () => { + const existing = new Date(2025, 8, 1, 10, 0); + const { component, modalService } = createField({ modal: true, value: existing }); + let closedCb: ((v: DateTimeFieldValue) => void) | undefined; + modalService.open.mockReturnValue({ + closed: { subscribe: (cb: (v: DateTimeFieldValue) => void) => (closedCb = cb) }, + }); + component.handleIconClick(); + closedCb!(undefined as unknown as DateTimeFieldValue); + expect(component.value()).toBe(existing); + }); + }); + + describe("breakpoint resolution", () => { + it("resolves the native picker per breakpoint", () => { + const { component, breakpoint } = createField({ useNativePicker: "md" }); + expect(component.useNativePickerEffective()).toBe(false); + breakpoint.setBreakpoint("xs"); + expect(component.useNativePickerEffective()).toBe(true); + }); + + it("resolves the modal per breakpoint", () => { + const { component, breakpoint } = createField({ modal: "md" }); + expect(component.useModal()).toBe(false); + breakpoint.setBreakpoint("sm"); + expect(component.useModal()).toBe(true); + }); + + it("narrows the range to one month on small screens", () => { + const { component, breakpoint } = createField({ mode: "range" }); + expect(component.numberOfMonthsResolved()).toBe(2); + breakpoint.setBreakpoint("xs"); + expect(component.numberOfMonthsResolved()).toBe(1); + }); + + it("resolves the highest matching breakpoint tier", () => { + const { component, fixture, breakpoint } = createField({ mode: "range" }); + fixture.componentRef.setInput("numberOfMonths", { + xs: 1, + sm: 1, + md: 2, + lg: 2, + xl: 3, + xxl: 4, + }); + breakpoint.setBreakpoint("xxl"); + fixture.detectChanges(); + expect(component.numberOfMonthsResolved()).toBe(4); + breakpoint.setBreakpoint("xl"); + expect(component.numberOfMonthsResolved()).toBe(3); + breakpoint.setBreakpoint("sm"); + expect(component.numberOfMonthsResolved()).toBe(1); + }); + + it("focuses the input when the native picker has no showPicker", () => { + const { component, el } = createField({ useNativePicker: true }); + const input = el.querySelector( + "input.tedi-date-input__input", + )!; + const focusSpy = jest.spyOn(input, "focus"); + component.handleIconClick(); + expect(focusSpy).toHaveBeenCalled(); + }); + }); + + describe("ControlValueAccessor + FormFieldControl", () => { + it("writes a value via writeValue", () => { + const { component } = createField(); + const date = new Date(2025, 8, 1, 10, 0); + component.writeValue(date); + expect(component.value()).toBe(date); + }); + + it("calls onChange when a value commits", () => { + const { component } = createField(); + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.handleTimeSelect("10:00"); + expect(onChange).toHaveBeenCalled(); + }); + + it("reflects the disabled state", () => { + const { component } = createField(); + component.setDisabledState(true); + expect(component.disabled()).toBe(true); + }); + + it("reflects the invalid state", () => { + const { component } = createField(); + component.setInvalidState(true); + expect(component.invalid()).toBe(true); + }); + + it("integrates with a reactive FormControl", () => { + TestBed.resetTestingModule(); + configureBaseModule(); + + @Component({ + standalone: true, + imports: [DateTimeFieldComponent, ReactiveFormsModule], + template: ``, + }) + class HostComponent { + control = new FormControl(null); + } + + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const field = fixture.debugElement.query( + By.directive(DateTimeFieldComponent), + ).componentInstance as DateTimeFieldComponent; + + const date = new Date(2025, 8, 1, 12, 0); + fixture.componentInstance.control.setValue(date); + fixture.detectChanges(); + expect(field.value()).toBe(date); + + field.handleTimeSelect("09:00"); + expect( + (fixture.componentInstance.control.value as Date).getHours(), + ).toBe(9); + }); + }); +}); diff --git a/tedi/components/form/date-time-field/date-time-field.component.ts b/tedi/components/form/date-time-field/date-time-field.component.ts new file mode 100644 index 000000000..aea771cbd --- /dev/null +++ b/tedi/components/form/date-time-field/date-time-field.component.ts @@ -0,0 +1,706 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + forwardRef, + inject, + input, + model, + output, + signal, + untracked, + viewChild, + ViewEncapsulation, +} from "@angular/core"; +import { ConnectedPosition, OverlayModule } from "@angular/cdk/overlay"; +import { A11yModule } from "@angular/cdk/a11y"; +import { NgTemplateOutlet } from "@angular/common"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { DateInputComponent } from "../date-field/date-input/date-input.component"; +import { CalendarComponent } from "../../content/calendar/calendar.component"; +import { CalendarView, DateRange } from "../../content/calendar/types"; +import { TimePickerComponent } from "../time-picker/time-picker.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { IconComponent } from "../../base/icon/icon.component"; +import { + FormFieldControl, + TEDI_FORM_FIELD_CONTROL, +} from "../form-field/form-field-control"; +import { + breakpointInput, + BreakpointInput, + BreakpointObject, + BreakpointService, +} from "../../../services/breakpoint/breakpoint.service"; +import { + formatLocaleDate, + formatLocaleDateHint, + parseLocaleDate, + startOfMonth, +} from "../../../utils/date.util"; +import { matchAny, Matcher } from "../../../utils/matchers.util"; +import { ModalService } from "../../overlay/modal/modal.service"; +import { ModalRef } from "../../overlay/modal/modal-ref"; +import { ModalFullscreen } from "../../overlay/modal/modal.types"; +import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; +import { + applyRangeTime, + combineCalendarSelection, + combineDateTime, + DateTimeFieldAvailableTimes, + DayAvailabilityInput, + getTimeOf, + MonthPredicate, + pad, + RangeParts, + resolveAvailableTimes, + toRangeParts, + toSingle, + YearPredicate, +} from "./date-time-field.util"; +import { + DateTimeFieldModalComponent, + DateTimeFieldModalData, +} from "./date-time-field-modal/date-time-field-modal.component"; + +export type { + DateTimeFieldAvailableTimes, + DayAvailabilityInput, +} from "./date-time-field.util"; +export type DateTimeFieldValue = Date | DateRange | null; +export type DateTimeFieldMode = "single" | "range"; +export type DateTimeFieldLayout = "side-by-side" | "multi-step"; +export type DateTimeFieldStep = "date" | "time"; +export type DateTimeFieldTimeGridVariant = "button" | "radio"; +export type DateTimeFieldUseNativePicker = boolean | "sm" | "md" | "lg" | "xl"; +export type DateTimeFieldModalInput = boolean | "sm" | "md" | "lg" | "xl"; +type DateTimeFieldFormatter = (value: DateTimeFieldValue) => string; + +@Component({ + selector: "tedi-date-time-field", + standalone: true, + templateUrl: "./date-time-field.component.html", + styleUrl: "./date-time-field.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + DateInputComponent, + CalendarComponent, + TimePickerComponent, + ButtonComponent, + IconComponent, + NgTemplateOutlet, + OverlayModule, + A11yModule, + TediTranslationPipe, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateTimeFieldComponent), + multi: true, + }, + { + provide: TEDI_FORM_FIELD_CONTROL, + useExisting: forwardRef(() => DateTimeFieldComponent), + }, + ], + host: { + class: "tedi-date-time-field", + }, +}) +export class DateTimeFieldComponent + implements ControlValueAccessor, FormFieldControl +{ + /** + * Unique ID for label association and accessibility. Bind the sibling + * `