diff --git a/README.md b/README.md index a343a5500..7428cbd3e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,13 @@ This project ships with AI agent skills to help both contributors and consumers ### For consumers — `tedi-angular` -Helps you build UIs with `@tedi-design-system/angular`: component usage, forms integration, theming, and translation setup. +Helps you build UIs with `@tedi-design-system/angular`: component usage, forms integration, theming, and translation setup. Lives in [`skills/tedi-angular`](./skills/tedi-angular). + +Install it into your agent (Claude Code, Cursor, Codex, and others) with the [`skills`](https://www.skills.sh/tedi-design-system/angular/tedi-angular) CLI: + +```bash +npx skills add https://github.com/tedi-design-system/angular --skill tedi-angular +``` ### For contributors — `contributing` diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index 5b36a2b83..fd1e2a33e 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -245,6 +245,9 @@ Both entry points declare `[tedi-floating-button]`. The Community component is - **`tedi-date-field`, not `tedi-date-picker`.** DatePicker is deprecated; DateField wraps a typed input with a popover or modal calendar and supports `single`, `multiple` and `range`. +- **`tedi-date-time-field` when the value needs a date *and* a time.** It pairs the same typed + input with a calendar plus a TimePicker (side by side, or as a two-step date-then-time flow), in + `single` or `range` mode. Don't compose a DateField and a TimeField by hand. - **`tedi-time-field`, not `tedi-time-picker`.** TimePicker is the bare picker surface behind TimeField. On its own, with no value, the `scroll` wheel parks on `12:00` as display only, and nothing is selected until the user picks. diff --git a/skills/tedi-angular/references/forms.md b/skills/tedi-angular/references/forms.md index 6a085ed67..2d8d94348 100644 --- a/skills/tedi-angular/references/forms.md +++ b/skills/tedi-angular/references/forms.md @@ -25,6 +25,7 @@ component's `ɵɵComponentDeclaration` carries its real selector; see | RadioGroupComponent | `tedi-radio-group` | `string \| null` | | ToggleComponent | `tedi-toggle` | `boolean` | | DateFieldComponent | `tedi-date-field` | `Date \| Date[] \| DateRange \| null` | +| DateTimeFieldComponent | `tedi-date-time-field` | `Date \| DateRange \| null` (each end carries a time) | | DatePickerComponent | `tedi-date-picker` | `Date \| null` — **deprecated**, use `DateFieldComponent` | | TimeFieldComponent | `tedi-time-field` | `string \| null` (HH:mm) | | TimePickerComponent | `tedi-time-picker` | `string \| null` (HH:mm) | @@ -190,6 +191,46 @@ Pick the picker style with `pickerVariant` (`"scroll" | "slots" | "dropdown" | " `TimePickerComponent` (`tedi-time-picker`) is the standalone picker surface behind TimeField — most consumers should reach for `tedi-time-field` instead. +## Date and Time Selection + +Use `DateTimeFieldComponent` (`tedi-date-time-field`) when one value carries both a date and a time +— don't wire a DateField and a TimeField together by hand. It pairs the same typed input with a +popover holding the calendar and a TimePicker, in `single` or `range` mode (a range carries a time +on each end). + +```html + + + + +``` + +`layout` picks how the two halves are arranged: `"side-by-side"` (default) shows the calendar and +the time picker together, `"multi-step"` asks for the date first and then advances to a separate +time step. `range` always renders side by side. + +Set `availableTimes` — a `string[]` of `HH:mm` values, or a `(date: Date) => string[]` for +per-date slots — to replace the scroll wheel with a grid of predefined slots; `timeGridVariant` +(`"button" | "radio"`) and `slotColumns` shape that grid. Without it, `minuteStep` sets the wheel's +granularity. + +```html + +``` + +It takes the same calendar inputs as DateField (`minDate`/`maxDate`, `disablePast`/`disableFuture`, +`disabledMatchers`, `availableDays`/`unavailableDays`, `selectionLevel`, `monthYearSelectType`, +`showWeekNumbers`, `numberOfMonths`). `useNativePicker` falls back to the OS +`` (single mode only) and `modal`/`fullscreen` open the picker in a +modal instead of the popover; both accept a breakpoint name (`"sm"`, `"md"`, ...) to switch below +that breakpoint. Like DateField it owns no label — compose it with `tedi-form-field` and +`tedi-label`. + ## Value and event conventions TEDI form controls hand you the **parsed value**, not a raw DOM event: diff --git a/src/tedi/components/content/calendar/calendar-header/calendar-header.component.scss b/src/tedi/components/content/calendar/calendar-header/calendar-header.component.scss index 26f53cf27..b340d4867 100644 --- a/src/tedi/components/content/calendar/calendar-header/calendar-header.component.scss +++ b/src/tedi/components/content/calendar/calendar-header/calendar-header.component.scss @@ -26,8 +26,8 @@ align-items: center; padding-left: var(--layout-grid-gutters-04); font-family: inherit; - font-size: var(--body-regular-size); - font-weight: 500; + font-size: var(--heading-h6-size); + font-weight: var(--heading-h6-weight); color: var(--general-text-primary); text-transform: capitalize; cursor: pointer; @@ -66,7 +66,7 @@ &__select-arrow.tedi-icon { flex-shrink: 0; - font-size: var(--tedi-size-09); + font-size: var(--icon-05); color: var(--general-icon-tertiary); } @@ -75,13 +75,14 @@ } &__static-label { - font-size: var(--body-regular-size); - font-weight: 500; + font-size: var(--heading-h6-size); + font-weight: var(--heading-h6-weight); color: var(--general-text-primary); text-transform: capitalize; } &--disabled { + .tedi-calendar-header__select, .tedi-calendar-header__label-button { pointer-events: none; diff --git a/src/tedi/components/content/calendar/calendar.component.scss b/src/tedi/components/content/calendar/calendar.component.scss index 8c82df23b..2d5bc38c5 100644 --- a/src/tedi/components/content/calendar/calendar.component.scss +++ b/src/tedi/components/content/calendar/calendar.component.scss @@ -6,10 +6,8 @@ // the months into a column when they fit. Control the count per screen size // with a BreakpointInput (e.g. [numberOfMonths]="{ xs: 1, lg: 2 }"). --_tedi-calendar-day-columns: 7; - --_tedi-calendar-month-width: calc( - var(--_tedi-calendar-day-columns) * var(--form-calendar-date-width) + 2 * - var(--card-padding-md-default) - ); + --_tedi-calendar-padding-x: var(--card-padding-md-default); + --_tedi-calendar-month-width: calc(var(--_tedi-calendar-day-columns) * var(--form-calendar-date-width) + 2 * var(--_tedi-calendar-padding-x)); display: flex; flex-direction: column; @@ -20,12 +18,8 @@ // still hugs the columns it shows — no leftover whitespace. The fallback to // --_tedi-calendar-month-count keeps it correct before/without the measurement // (e.g. SSR): all months side by side. - width: calc( - var( - --_tedi-calendar-fit-columns, - var(--_tedi-calendar-month-count, 1) - ) * var(--_tedi-calendar-month-width) - ); + width: calc(var(--_tedi-calendar-fit-columns, + var(--_tedi-calendar-month-count, 1)) * var(--_tedi-calendar-month-width)); min-width: var(--_tedi-calendar-month-width); height: fit-content; background: var(--card-background-primary); @@ -34,6 +28,12 @@ --_tedi-calendar-day-columns: 8; } + // A month grid with the default padding is 312px, wider than a dialog or + // popover can be on a 320px phone. Tighten the sides there, as in Figma. + @media (width < 360px) { + --_tedi-calendar-padding-x: var(--card-padding-xs); + } + &__months { display: flex; flex-wrap: wrap; @@ -44,12 +44,17 @@ &__month { display: flex; flex-direction: column; - padding: var(--card-padding-md-default); + padding: var(--card-padding-md-default) var(--_tedi-calendar-padding-x); + } + + &__month .tedi-calendar-header { + margin-right: calc(-1 * var(--layout-grid-gutters-08)); + margin-left: calc(-1 * var(--layout-grid-gutters-08)); } &__footer { padding-top: var(--layout-grid-gutters-08); - margin: 0 var(--card-padding-md-default) var(--card-padding-xs); + margin: 0 var(--_tedi-calendar-padding-x) var(--card-padding-xs); border-top: var(--tedi-borders-01) solid var(--general-border-primary); &:empty { diff --git a/src/tedi/components/content/calendar/calendar.component.ts b/src/tedi/components/content/calendar/calendar.component.ts index e18b05686..f71c27ff9 100644 --- a/src/tedi/components/content/calendar/calendar.component.ts +++ b/src/tedi/components/content/calendar/calendar.component.ts @@ -38,13 +38,15 @@ import { import { CalendarMonthGridComponent } from "./calendar-month-grid/calendar-month-grid.component"; import { CalendarYearGridComponent } from "./calendar-year-grid/calendar-year-grid.component"; import { CalendarHeaderComponent } from "./calendar-header/calendar-header.component"; -import { CalendarView, DateFieldMode } from "./types"; +import { + CalendarView, + DateFieldMode, + DayAvailabilityInput, + MonthPredicate, + YearPredicate, +} from "./types"; type CalendarValue = Date | Date[] | DateRange | null; -type DayPredicate = (date: Date) => boolean; -type DayAvailabilityInput = Date[] | DayPredicate | undefined; -type MonthPredicate = (month: Date) => boolean; -type YearPredicate = (year: Date) => boolean; const YEAR_PAGE_SIZE = 12; diff --git a/src/tedi/components/content/calendar/index.ts b/src/tedi/components/content/calendar/index.ts index 90a7c4320..24c541e06 100644 --- a/src/tedi/components/content/calendar/index.ts +++ b/src/tedi/components/content/calendar/index.ts @@ -1,3 +1,11 @@ export { CalendarComponent } from "./calendar.component"; -export type { CalendarView, DateFieldMode, DateRange } from "./types"; +export type { + CalendarView, + DateFieldMode, + DateRange, + DayPredicate, + DayAvailabilityInput, + MonthPredicate, + YearPredicate, +} from "./types"; export type { Matcher } from "../../../utils/matchers.util"; diff --git a/src/tedi/components/content/calendar/types.ts b/src/tedi/components/content/calendar/types.ts index 0f3a57a03..9dff042be 100644 --- a/src/tedi/components/content/calendar/types.ts +++ b/src/tedi/components/content/calendar/types.ts @@ -1,3 +1,13 @@ export type { DateRange } from "../../../utils/date.util"; export type CalendarView = "days" | "months" | "years"; export type DateFieldMode = "single" | "multiple" | "range"; + +/** + * Whitelist/blacklist of days — an explicit `Date[]` or a predicate. Shared by + * Calendar and the fields that wrap it, so consumers get one type back from the + * entry point rather than three structurally identical copies. + */ +export type DayPredicate = (date: Date) => boolean; +export type DayAvailabilityInput = Date[] | DayPredicate | undefined; +export type MonthPredicate = (month: Date) => boolean; +export type YearPredicate = (year: Date) => boolean; diff --git a/src/tedi/components/form/date-field/date-field.component.html b/src/tedi/components/form/date-field/date-field.component.html index cef29c3c7..037b5b447 100644 --- a/src/tedi/components/form/date-field/date-field.component.html +++ b/src/tedi/components/form/date-field/date-field.component.html @@ -19,6 +19,8 @@ [iconDisabled]="!enableCalendarResolved()" [useNativePicker]="useNativePickerEffective()" [nativeIsoValue]="nativeIsoValue()" + [nativeMin]="nativeMin()" + [nativeMax]="nativeMax()" [clearable]="canClear()" (click)="handleInputClick($event)" (inputChange)="handleInputChange($event)" @@ -41,6 +43,7 @@ class="tedi-date-field__overlay" role="dialog" [attr.aria-label]="'date-field.calendar-dialog' | tediTranslate" + [style.max-height.px]="overlayMaxHeight()" cdkTrapFocus (keydown)="handleOverlayKeydown($event)" > diff --git a/src/tedi/components/form/date-field/date-field.component.scss b/src/tedi/components/form/date-field/date-field.component.scss index f5d1eeb9c..acf7f9b54 100644 --- a/src/tedi/components/form/date-field/date-field.component.scss +++ b/src/tedi/components/form/date-field/date-field.component.scss @@ -33,6 +33,7 @@ } &__overlay { + overflow-y: auto; background: var(--card-background-primary); border-radius: var(--card-radius-rounded); box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); diff --git a/src/tedi/components/form/date-field/date-field.component.spec.ts b/src/tedi/components/form/date-field/date-field.component.spec.ts index 7ffd13b02..7193664c2 100644 --- a/src/tedi/components/form/date-field/date-field.component.spec.ts +++ b/src/tedi/components/form/date-field/date-field.component.spec.ts @@ -367,6 +367,20 @@ describe("DateFieldComponent", () => { expect(input.type).toBe("date"); }); + it("bounds the native input by minDate and maxDate", () => { + const { el } = createField({ + useNativePicker: true, + minDate: new Date(2026, 4, 1), + maxDate: new Date(2026, 4, 31), + }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.getAttribute("min")).toBe("2026-05-01"); + expect(input.getAttribute("max")).toBe("2026-05-31"); + expect(input.hasAttribute("step")).toBe(false); + }); + it("does NOT switch to native picker when mode is multiple", () => { const { el } = createField({ useNativePicker: true, @@ -662,6 +676,42 @@ describe("DateFieldComponent", () => { expect(() => component.handleOverlayAttached()).not.toThrow(); }); + it("caps the popover to the roomier side of the trigger so a tall calendar scrolls", () => { + const { fixture, component } = createField(); + openOverlay(component); + fixture.detectChanges(); + + jest + .spyOn(component["hostEl"].nativeElement, "getBoundingClientRect") + .mockReturnValue({ top: 600, bottom: 640 } as DOMRect); + const originalHeight = window.innerHeight; + Object.defineProperty(window, "innerHeight", { + value: 800, + configurable: true, + }); + + try { + component.handleOverlayAttached(); + fixture.detectChanges(); + + // More room above (600 - 8) than below (800 - 640 - 8). + expect(component.overlayMaxHeight()).toBe(592); + + const overlay = document.querySelector( + ".tedi-date-field__overlay", + ); + expect(overlay?.style.maxHeight).toBe("592px"); + } finally { + Object.defineProperty(window, "innerHeight", { + value: originalHeight, + configurable: true, + }); + } + + component.overlayOpen.set(false); + fixture.detectChanges(); + }); + it("moves DOM focus into the calendar when opened via the icon (real flow)", () => { const { fixture, el, component } = createField(); const iconBtn = el.querySelector( diff --git a/src/tedi/components/form/date-field/date-field.component.ts b/src/tedi/components/form/date-field/date-field.component.ts index 5f7a9ac5e..44c44e6ac 100644 --- a/src/tedi/components/form/date-field/date-field.component.ts +++ b/src/tedi/components/form/date-field/date-field.component.ts @@ -37,6 +37,9 @@ import { CalendarView, DateFieldMode, DateRange, + DayAvailabilityInput, + MonthPredicate, + YearPredicate, } from "../../content/calendar/types"; import { FormFieldControl, @@ -59,8 +62,10 @@ import { formatLocaleDate, formatLocaleDateHint, isSameDay, + nativeDateBounds, parseLocaleDate, startOfMonth, + toNativeDate, } from "../../../utils/date.util"; import { matchAny, Matcher } from "../../../utils/matchers.util"; import { ModalService } from "../../overlay/modal/modal.service"; @@ -72,14 +77,11 @@ import { } from "./date-field-modal/date-field-modal.component"; import { TediTranslationPipe } from "../../../services/translation/translation.pipe"; -type DateFieldValue = Date | Date[] | DateRange | null; -type DateFieldFormatter = (value: DateFieldValue) => string; -type DateFieldParser = (value: string) => DateFieldValue | undefined; -type DayAvailabilityInput = Date[] | ((d: Date) => boolean) | undefined; -type MonthPredicate = (month: Date) => boolean; -type YearPredicate = (year: Date) => boolean; -type DateFieldCalendarTrigger = "input" | "button"; -type DateFieldModalInput = boolean | "sm" | "md" | "lg" | "xl"; +export type DateFieldValue = Date | Date[] | DateRange | null; +export type DateFieldFormatter = (value: DateFieldValue) => string; +export type DateFieldParser = (value: string) => DateFieldValue | undefined; +export type DateFieldCalendarTrigger = "input" | "button"; +export type DateFieldModalInput = boolean | "sm" | "md" | "lg" | "xl"; export type DateFieldUseNativePicker = boolean | "sm" | "md" | "lg" | "xl"; export type DateFieldSize = "default" | "small"; @@ -360,6 +362,7 @@ export class DateFieldComponent readonly currentMonth = signal(new Date()); readonly overlayOpen = signal(false); + readonly overlayMaxHeight = signal(null); private readonly openedBy = signal("button"); @@ -530,7 +533,26 @@ export class DateFieldComponent if (!this.useNativePickerEffective()) return ""; const v = this.value(); if (!(v instanceof Date)) return ""; - return this.toIsoDate(v); + return toNativeDate(v); + }); + + private readonly nativeBounds = computed(() => + nativeDateBounds({ + minDate: this.minDate(), + maxDate: this.maxDate(), + disablePast: this.disablePast(), + disableFuture: this.disableFuture(), + }), + ); + + readonly nativeMin = computed(() => { + const min = this.nativeBounds().min; + return min ? toNativeDate(min) : undefined; + }); + + readonly nativeMax = computed(() => { + const max = this.nativeBounds().max; + return max ? toNativeDate(max) : undefined; }); readonly effectivePlaceholder = computed(() => { @@ -773,12 +795,27 @@ export class DateFieldComponent } handleOverlayAttached(): void { + this.updateOverlayMaxHeight(); this.calendar()?.focusActiveCell(); if (this.hideOnScroll()) { this.setupScrollListener(); } } + // Cap the popover to whichever side of the trigger has more room and let it + // scroll (only its height — never its width, which would make the calendar + // re-measure and wrap its months). Without this a tall calendar — e.g. two + // months stacked into a column on a phone — is laid out past the viewport + // edge, and because the overlay is fixed-positioned the page cannot scroll + // to it, leaving the far month unreachable. + private updateOverlayMaxHeight(): void { + const rect = this.hostEl.nativeElement.getBoundingClientRect(); + const margin = 8; + const spaceBelow = window.innerHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + this.overlayMaxHeight.set(Math.max(spaceBelow, spaceAbove)); + } + handleOverlayDetached(): void { this.cleanupScrollListener(); this.closeOverlay(); @@ -1011,13 +1048,6 @@ export class DateFieldComponent return new Date(now.getFullYear(), now.getMonth(), now.getDate()); } - private toIsoDate(date: Date): string { - const y = date.getFullYear().toString().padStart(4, "0"); - const m = (date.getMonth() + 1).toString().padStart(2, "0"); - const d = date.getDate().toString().padStart(2, "0"); - return `${y}-${m}-${d}`; - } - private queryNativeInput(): HTMLInputElement | null { const host = this.hostEl.nativeElement as HTMLElement; return host.querySelector(".tedi-date-input__input"); diff --git a/src/tedi/components/form/date-field/date-input/date-input.component.html b/src/tedi/components/form/date-field/date-input/date-input.component.html index ecc70c821..9c4223737 100644 --- a/src/tedi/components/form/date-field/date-input/date-input.component.html +++ b/src/tedi/components/form/date-field/date-input/date-input.component.html @@ -26,6 +26,9 @@ [attr.name]="name() || null" [type]="inputType()" [value]="inputValue()" + [attr.min]="useNativePicker() ? (nativeMin() ?? null) : null" + [attr.max]="useNativePicker() ? (nativeMax() ?? null) : null" + [attr.step]="useNativePicker() ? (nativeStep() ?? null) : null" [attr.placeholder]="placeholder() || null" [disabled]="disabled()" [readOnly]="readOnly()" diff --git a/src/tedi/components/form/date-field/date-input/date-input.component.scss b/src/tedi/components/form/date-field/date-input/date-input.component.scss index 2df335f02..bded07753 100644 --- a/src/tedi/components/form/date-field/date-input/date-input.component.scss +++ b/src/tedi/components/form/date-field/date-input/date-input.component.scss @@ -64,13 +64,14 @@ min-width: 0; } - // When useNativePicker is active the input swaps to type=date. WebKit/Blink - // (Chrome, Safari, Edge) render their own calendar-picker indicator on the - // right, plus inner spin/clear buttons — we replace them with our own icon - // button, so suppress the browser-default ones. Firefox does not render a - // native indicator on so no Firefox-specific rule is - // needed. - &__input[type="date"] { + // When useNativePicker is active the input swaps to type=date (date-field) + // or type=datetime-local (date-time-field). WebKit/Blink (Chrome, Safari, + // Edge) render their own calendar-picker indicator on the right, plus inner + // spin/clear buttons — we replace them with our own icon button, so suppress + // the browser-default ones. Firefox does not render a native indicator on + // these inputs so no Firefox-specific rule is needed. + &__input[type="date"], + &__input[type="datetime-local"] { &::-webkit-calendar-picker-indicator { display: none; appearance: none; diff --git a/src/tedi/components/form/date-field/date-input/date-input.component.spec.ts b/src/tedi/components/form/date-field/date-input/date-input.component.spec.ts index 23c138abb..b199aa25a 100644 --- a/src/tedi/components/form/date-field/date-input/date-input.component.spec.ts +++ b/src/tedi/components/form/date-field/date-input/date-input.component.spec.ts @@ -81,6 +81,21 @@ describe("DateInputComponent", () => { expect(input.value).toBe("2026-05-14"); }); + it("applies native min, max and step only while the native picker is on", () => { + fixture.componentRef.setInput("nativeMin", "2026-05-01"); + fixture.componentRef.setInput("nativeMax", "2026-05-31"); + fixture.componentRef.setInput("nativeStep", 7); + fixture.detectChanges(); + expect(getInput().hasAttribute("min")).toBe(false); + + fixture.componentRef.setInput("useNativePicker", true); + fixture.detectChanges(); + const input = getInput(); + expect(input.getAttribute("min")).toBe("2026-05-01"); + expect(input.getAttribute("max")).toBe("2026-05-31"); + expect(input.getAttribute("step")).toBe("7"); + }); + it("does not render tags when mode is single, even with tags provided", () => { fixture.componentRef.setInput("mode", "single"); fixture.componentRef.setInput("tags", [{ id: "a", label: "01.01.2026" }]); diff --git a/src/tedi/components/form/date-field/date-input/date-input.component.ts b/src/tedi/components/form/date-field/date-input/date-input.component.ts index ca9030c91..df2d7ea2b 100644 --- a/src/tedi/components/form/date-field/date-input/date-input.component.ts +++ b/src/tedi/components/form/date-field/date-input/date-input.component.ts @@ -87,7 +87,15 @@ 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(""); + /** `min` of the native input, in its `nativeInputType` format. */ + readonly nativeMin = input(undefined); + /** `max` of the native input, in its `nativeInputType` format. */ + readonly nativeMax = input(undefined); + /** `step` of the native input, in seconds for `datetime-local` and days for `date`. */ + readonly nativeStep = input(undefined); /** Whether to show the clear button. Independent of `readOnly`, like `removable`. */ readonly clearable = input(false); @@ -161,7 +169,7 @@ export class DateInputComponent implements AfterViewChecked { }); readonly inputType = computed(() => - this.useNativePicker() ? "date" : "text", + this.useNativePicker() ? this.nativeInputType() : "text", ); readonly inputValue = computed(() => diff --git a/src/tedi/components/form/date-field/index.ts b/src/tedi/components/form/date-field/index.ts index a5684f3f4..a1f59ac74 100644 --- a/src/tedi/components/form/date-field/index.ts +++ b/src/tedi/components/form/date-field/index.ts @@ -3,5 +3,17 @@ export type { CalendarView, DateFieldMode, DateRange, + DayAvailabilityInput, + MonthPredicate, + YearPredicate, } from "../../content/calendar/types"; +export type { + DateFieldValue, + DateFieldFormatter, + DateFieldParser, + DateFieldCalendarTrigger, + DateFieldModalInput, + DateFieldSize, + DateFieldUseNativePicker, +} from "./date-field.component"; export type { Matcher } from "../../../utils/matchers.util"; diff --git a/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.html b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.html new file mode 100644 index 000000000..0b1d8b59b --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.html @@ -0,0 +1,197 @@ + + +

{{ titleKey() | tediTranslate }}

+
+ + @if (!multiStep || step() === "date") { + + } + @if (!multiStep) { + + } + @if (!multiStep || step() === "time") { + @if (isRange) { +
+ + +
+ } @else { +
+ @if (!multiStep) { + + {{ + data.timeHeading ?? + ("date-time-field.time-heading" | tediTranslate) + }} + + } + @if (singleSlots()?.length) { + + } @else if (data.useNativeTimeInput()) { + + + + } @else { + + } +
+ } + } +
+ + @if (multiStep && step() === "time") { + + } + + @if (multiStep && step() === "date") { + + } @else { + + } + +
+ + +
+
+ {{ heading }} + @if (dateLabel) { + {{ dateLabel }} + } +
+ @if (slots?.length) { + + } @else if (data.useNativeTimeInput()) { + + + + } @else { + + } +
+
diff --git a/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.scss b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.scss new file mode 100644 index 000000000..444c16891 --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.scss @@ -0,0 +1,197 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; + +.tedi-date-time-field-modal { + --_tedi-modal-body-padding: 0; + + // Calendar and time picker sit side-by-side on wider screens and stack (time + // below calendar) once the row no longer fits — the modal itself shrink-wraps + // this content (see `width: "fit-content"` in date-time-field.component.ts). + // + // `.tedi-modal-dialog .tedi-modal-content` (modal.component.scss) forces a + // column layout with a large gap; the selector below (ancestor + compound + // classes) outweighs it so the row layout and the tight, separator-driven + // spacing win. + .tedi-date-time-field-modal__content.tedi-modal-content { + flex-direction: row; + gap: 0; + align-items: stretch; + + @include breakpoints.media-breakpoint-down(md) { + flex-direction: column; + align-items: center; + } + } + + // Range: stack the calendar over the two ends. Side-mounting the two time + // pickers left the calendar with a tall empty gap and overflowed into a + // horizontal scroll on tablet, and the calendar↔time link was lost. Stacking + // keeps the ends grouped as a set beneath the shared calendar. + .tedi-date-time-field-modal__content--range.tedi-modal-content { + flex-direction: column; + align-items: center; + } + + .tedi-date-time-field-modal__content--single-month.tedi-modal-content { + @include breakpoints.media-breakpoint-up(md) { + flex-direction: row; + align-items: stretch; + } + } + + &__calendar { + flex-shrink: 0; + } + + // The separator only makes sense while the calendar and time picker sit + // side-by-side. Once they stack, hide it — a horizontal line plus its padding + // just adds dead space between the two. + &__separator { + display: flex; + flex-shrink: 0; + align-items: stretch; + padding: var(--card-padding-md-default) var(--separator-spacing-x-01); + + @include breakpoints.media-breakpoint-down(md) { + display: none; + } + } + + &__separator-line { + width: var(--tedi-borders-01); + background: var(--general-separator-primary); + } + + // The vertical separator belongs to the single side-by-side layout; range + // stacks vertically, so the two ends carry their own top divider instead. + &__content--range &__separator { + display: none; + } + + &__times { + display: flex; + gap: var(--layout-grid-gutters-04); + justify-content: center; + + @include breakpoints.media-breakpoint-down(md) { + flex-direction: column; + align-items: center; + width: 100%; + } + } + + &__content--range &__times { + align-self: stretch; + padding-top: var(--card-padding-md-default); + border-top: var(--tedi-borders-01) solid var(--general-separator-primary); + } + + &__content--single-month { + @include breakpoints.media-breakpoint-up(md) { + .tedi-date-time-field-modal__separator { + display: flex; + } + + .tedi-date-time-field-modal__times { + flex-direction: column; + gap: 0; + width: var(--tedi-containers-03); + padding-top: 0; + border-top: none; + } + + .tedi-date-time-field-modal__time + .tedi-date-time-field-modal__time { + border-top: var(--tedi-borders-01) solid + var(--general-separator-primary); + } + } + } + + &__content--step &__times { + flex-direction: column; + gap: 0; + width: var(--tedi-containers-03); + max-width: 100%; + } + + &__times &__time { + align-self: stretch; + } + + &__times &__native-time { + max-width: none; + } + + &__times .tedi-time-picker--scroll { + --tedi-time-picker-width: 100%; + --tedi-time-picker-columns-height: calc( + var(--tedi-time-picker-item-height) * 3 + ); + + width: 100%; + } + + &__back { + margin-right: auto; + } + + // Groups the heading with its start/end date so they read as one label, and + // owns the gap down to the picker (same spacing whether or not a date shows). + &__time-header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: baseline; + align-self: stretch; + justify-content: space-between; + padding-top: var(--card-padding-md-default); + padding-bottom: var(--card-padding-xs); + } + + &__time-header &__heading { + padding: 0; + } + + // Echoes the start/end date beside each heading so it stays clear which + // calendar end the picker below drives. + &__date { + font-size: var(--body-regular-size); + line-height: var(--body-regular-line-height); + color: var(--general-text-primary); + } + + &__time { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default); + + // Matches the calendar's narrow-screen side padding so the headings and + // pickers line up with the day grid. + @media (width < 360px) { + padding-inline: var(--card-padding-xs); + } + } + + &__native-time { + width: 100%; + max-width: var(--tedi-containers-01); + } + + &__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/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.spec.ts b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.spec.ts new file mode 100644 index 000000000..a66ede092 --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.spec.ts @@ -0,0 +1,286 @@ +import { signal } from "@angular/core"; +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, + layout: "side-by-side", + useNativeTimeInput: signal(false), + minuteStep: 15, + slotColumns: 3, + gridVariant: "button", + timeHeading: undefined, + availableTimes: undefined, + size: "default", + ...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); + }); + + describe("multi-step", () => { + const query = (selector: string) => + fixture.nativeElement.querySelector(selector); + + it("shows the calendar first and blocks advancing until the range is complete", () => { + setup(makeData({ mode: "range", layout: "multi-step" })); + expect(component.titleKey()).toBe("date-field.modal-title"); + expect(query("tedi-calendar")).toBeTruthy(); + expect(component.canAdvance()).toBe(false); + + component.draft.set({ + from: new Date(2026, 4, 14), + to: new Date(2026, 4, 16), + } as DateRange); + expect(component.canAdvance()).toBe(true); + }); + + it("swaps the calendar for both time pickers on the time step", () => { + setup( + makeData({ + mode: "range", + layout: "multi-step", + value: { + from: new Date(2026, 4, 14, 9, 0), + to: new Date(2026, 4, 16, 17, 0), + } as DateRange, + }), + ); + component.goToTimeStep(); + fixture.detectChanges(); + expect(component.titleKey()).toBe("date-time-field.time-heading"); + expect(query("tedi-calendar")).toBeNull(); + expect( + fixture.nativeElement.querySelectorAll(".tedi-time-picker--scroll") + .length, + ).toBe(2); + expect(query(".tedi-date-time-field-modal__back")).toBeTruthy(); + + component.goToDateStep(); + fixture.detectChanges(); + expect(query("tedi-calendar")).toBeTruthy(); + }); + + it("renders native time inputs when requested", () => { + setup( + makeData({ + mode: "range", + layout: "multi-step", + useNativeTimeInput: signal(true), + }), + ); + component.goToTimeStep(); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelectorAll("tedi-time-field").length, + ).toBe(2); + }); + + it("advances a single value once a date is picked", () => { + setup(makeData({ layout: "multi-step" })); + expect(component.canAdvance()).toBe(false); + component.draft.set(new Date(2026, 4, 14)); + expect(component.canAdvance()).toBe(true); + }); + }); +}); diff --git a/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.ts b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.ts new file mode 100644 index 000000000..8549d075c --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field-modal/date-time-field-modal.component.ts @@ -0,0 +1,197 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + Signal, + signal, + viewChild, + ViewEncapsulation, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { ButtonComponent } from "../../../buttons/button/button.component"; +import { IconComponent } from "../../../base/icon/icon.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 { TimeFieldComponent } from "../../time-field/time-field.component"; +import { FormFieldComponent } from "../../form-field/form-field.component"; +import { InputSize } from "../../form-field/field-context.token"; +import { CalendarView, DateRange } from "../../../content/calendar/types"; +import { Matcher } from "../../../../utils/matchers.util"; +import { + DateTimeFieldLayout, + DateTimeFieldMode, + DateTimeFieldStep, + 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; + layout: DateTimeFieldLayout; + useNativeTimeInput: Signal; + minuteStep: number; + slotColumns: number; + gridVariant: DateTimeFieldTimeGridVariant; + timeHeading: string | undefined; + availableTimes: DateTimeFieldAvailableTimes; + size: InputSize; +} + +@Component({ + selector: "tedi-date-time-field-modal", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + NgTemplateOutlet, + ButtonComponent, + IconComponent, + CalendarComponent, + TimePickerComponent, + TimeFieldComponent, + FormFieldComponent, + 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 multiStep = this.data.layout === "multi-step"; + readonly singleMonthRange = this.isRange && this.data.numberOfMonths === 1; + readonly step = signal("date"); + readonly titleKey = computed(() => { + if (!this.multiStep) return "date-time-field.modal-title"; + return this.step() === "date" + ? "date-field.modal-title" + : "date-time-field.time-heading"; + }); + readonly canAdvance = computed(() => + this.isRange ? !!this.rangeValue().to : !!this.singleValue(), + ); + + 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 fromDateLabel = computed(() => + this.formatRangeEnd(this.rangeValue().from), + ); + readonly toDateLabel = computed(() => + this.formatRangeEnd(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()); + } + + goToTimeStep(): void { + this.step.set("time"); + } + + goToDateStep(): void { + this.step.set("date"); + } + + 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, + ); + } + + private readonly shortDateFormatter = new Intl.DateTimeFormat( + this.data.localeCode, + { day: "2-digit", month: "2-digit", year: "2-digit" }, + ); + + private formatRangeEnd(date: Date | undefined): string { + return date + ? `${this.shortDateFormatter.format(date)} - ${getTimeOf(date)}` + : ""; + } +} diff --git a/src/tedi/components/form/date-time-field/date-time-field.component.html b/src/tedi/components/form/date-time-field/date-time-field.component.html new file mode 100644 index 000000000..40e049c23 --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.component.html @@ -0,0 +1,283 @@ + + +@if (usePopover()) { + + + +} + + + + @if (footer) { +
+ +
+ } +
+
+ + + + + + + @if (!singleAvailableTimes()?.length && useNativeTimeInput()) { + + + + } @else { + + } + + + + + + + + +
+
+ {{ heading }} + @if (dateLabel) { + {{ dateLabel }} + } +
+ @if (slots?.length) { + + } @else if (useNativeTimeInput()) { + + + + } @else { + + } +
+
diff --git a/src/tedi/components/form/date-time-field/date-time-field.component.scss b/src/tedi/components/form/date-time-field/date-time-field.component.scss new file mode 100644 index 000000000..30021e886 --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.component.scss @@ -0,0 +1,245 @@ +@use "@tedi-design-system/core/bootstrap-utility/breakpoints"; +@use "../field-surface-mixins" as field-surface; + +.tedi-date-time-field { + @include field-surface.tokens; + + display: flex; + gap: var(--form-field-inner-spacing); + align-items: center; + min-width: 0; + + &:not(.tedi-field-surface) { + flex: 1; + } + + &:where(.tedi-field-surface) { + @include field-surface.surface; + + width: 100%; + } + + &.tedi-field-surface { + @include field-surface.surface-states(":focus-within", true); + } + + &--small { + @include field-surface.size(small); + } + + &--large { + @include field-surface.size(large); + } + + // Keep the popover within the viewport on narrow screens. `max-height` is set + // in TS from the room around the trigger; `overflow-y: auto` lets tall content + // (e.g. many predefined slots stacked in range mode) scroll inside the popover. + // Only the height is bounded — never the width — so the calendar never + // re-measures narrower and wraps its months. + &__overlay { + max-width: calc(100vw - var(--layout-grid-gutters-08)); + overflow-y: auto; + background: var(--card-background-primary); + border-radius: var(--card-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + } + + // Single side-by-side popover. Calendar and time picker sit in a row on wider + // viewports (as designed) and stack once the row no longer fits on a phone, so + // the time picker never spills off-screen. + &__split { + display: flex; + align-items: stretch; + + @include breakpoints.media-breakpoint-down(sm) { + flex-direction: column; + align-items: center; + } + } + + // 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); + + // Once the panels stack, the vertical divider just adds a stray line. + @include breakpoints.media-breakpoint-down(sm) { + display: none; + } + } + + &__split-time { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default); + + // Matches the calendar's narrow-screen side padding so the headings and + // pickers line up with the day grid. + @media (width < 360px) { + padding-inline: var(--card-padding-xs); + } + } + + // 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; + } + + &__native-time { + width: 100%; + max-width: var(--tedi-containers-01); + } + + &__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--single-month { + @include breakpoints.media-breakpoint-up(md) { + flex-direction: row; + + .tedi-date-time-field__range-times { + flex: 0 0 var(--tedi-containers-03); + flex-direction: column; + } + + .tedi-date-time-field__split-time + .tedi-date-time-field__split-time { + border-top: var(--tedi-borders-01) solid var(--general-border-primary); + } + } + } + + // The two ends sit side-by-side beneath the calendar on wider viewports and + // stack (start over end) once they no longer fit, so neither picker spills off + // the popover. + &__range-times { + display: flex; + justify-content: center; + + @include breakpoints.media-breakpoint-down(md) { + flex-direction: column; + align-items: center; + } + } + + &__range-times .tedi-date-time-field__split-time { + flex: 1 1 0; + align-self: stretch; + } + + &__range-times &__native-time { + max-width: none; + } + + &__range-times .tedi-time-picker--scroll { + --tedi-time-picker-width: 100%; + --tedi-time-picker-columns-height: calc( + var(--tedi-time-picker-item-height) * 3 + ); + + width: 100%; + } + + // Groups the heading with its start/end date so they read as one label, and + // owns the gap down to the picker (so the spacing is the same whether or not a + // date is shown). + &__range-time-header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: baseline; + align-self: stretch; + justify-content: space-between; + padding-top: var(--card-padding-md-default); + padding-bottom: var(--card-padding-xs); + } + + &__range-time-header &__split-heading { + padding: 0; + } + + // Echoes the start/end date beside each heading so it stays clear which + // calendar end the picker below drives: the calendar↔time link is otherwise + // ambiguous with a single calendar and two pickers. + &__range-date { + font-size: var(--body-regular-size); + line-height: var(--body-regular-line-height); + color: var(--general-text-primary); + } + + &__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-date-time-field__range-times { + flex-direction: column; + } + + &__time-footer { + display: flex; + justify-content: center; + padding: 0 var(--card-padding-md-default) var(--card-padding-md-default); + } + + &__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/src/tedi/components/form/date-time-field/date-time-field.component.spec.ts b/src/tedi/components/form/date-time-field/date-time-field.component.spec.ts new file mode 100644 index 000000000..cc35f9d96 --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.component.spec.ts @@ -0,0 +1,1010 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Subject } from "rxjs"; +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(); +} + +/** + * Stands in for a `ModalRef`. `closed` has to be a real Observable — the field + * pipes it through `takeUntilDestroyed`. + */ +function stubModalRef(): { closed: Subject } { + return { closed: new Subject() }; +} + +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("walks the multi-step layout from the calendar to both time pickers", () => { + const { component, fixture } = createField({ + mode: "range", + layout: "multi-step", + availableTimes: ["09:00", "10:00"], + }); + component.handleIconClick(); + fixture.detectChanges(); + expect(document.querySelector("tedi-calendar")).toBeTruthy(); + expect(document.querySelector("tedi-time-picker")).toBeNull(); + + component.goToTimeStep(); + fixture.detectChanges(); + expect(document.querySelector("tedi-calendar")).toBeNull(); + expect(document.querySelectorAll(".tedi-time-picker--slots").length).toBe( + 2, + ); + + component.goToDateStep(); + fixture.detectChanges(); + expect(document.querySelector("tedi-calendar")).toBeTruthy(); + component.closeOverlay(); + fixture.detectChanges(); + }); + + it("keeps the overlay open after a range slot pick in multi-step", () => { + const { component } = createField({ + mode: "range", + layout: "multi-step", + availableTimes: ["09:00", "10:00"], + }); + component.handleIconClick(); + component.goToTimeStep(); + component.handleRangeTimeSelect("from", "09:00"); + expect(component.overlayOpen()).toBe(true); + }); + + it("places the time pickers beside a single-month calendar", () => { + const { component, fixture } = createField({ + mode: "range", + numberOfMonths: 1, + }); + component.handleIconClick(); + fixture.detectChanges(); + expect( + document.querySelector(".tedi-date-time-field__range--single-month"), + ).toBeTruthy(); + component.closeOverlay(); + fixture.detectChanges(); + }); + + 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("bounds the native input by the date constraints and minute step", () => { + const { el } = createField({ + useNativePicker: true, + minDate: new Date(2025, 8, 1, 14, 0), + maxDate: new Date(2025, 8, 30), + minuteStep: 15, + }); + const input = el.querySelector( + "input.tedi-date-input__input", + )!; + expect(input.getAttribute("min")).toBe("2025-09-01T00:00"); + expect(input.getAttribute("max")).toBe("2025-09-30T23:59"); + expect(input.getAttribute("step")).toBe("900"); + }); + + it("uses the tighter of minDate and disablePast", () => { + const { component } = createField({ + useNativePicker: true, + minDate: new Date(2000, 0, 1), + disablePast: true, + disableFuture: true, + }); + const today = new Date(); + const iso = `${today.getFullYear()}-${pad2(today.getMonth() + 1)}-${pad2(today.getDate())}`; + expect(component.nativeMin()).toBe(`${iso}T00:00`); + expect(component.nativeMax()).toBe(`${iso}T23:59`); + }); + + it("leaves the native input unbounded without constraints", () => { + const { component } = createField({ useNativePicker: true }); + expect(component.nativeMin()).toBeUndefined(); + expect(component.nativeMax()).toBeUndefined(); + expect(component.nativeStep()).toBeUndefined(); + }); + + it("sets a native step only for a minuteStep above 1", () => { + const { component, fixture } = createField(); + expect(component.nativeStep()).toBeUndefined(); + fixture.componentRef.setInput("minuteStep", 5); + expect(component.nativeStep()).toBe(300); + }); + + it("passes an explicit minuteStep to the native time inputs only", () => { + const { component, fixture } = createField({ + mode: "range", + useNativeTimePicker: true, + }); + component.handleIconClick(); + fixture.detectChanges(); + const steps = () => + Array.from( + document.querySelectorAll( + ".tedi-time-field__input", + ), + ).map((i) => i.getAttribute("step")); + expect(steps()).toEqual([null, null]); + fixture.componentRef.setInput("minuteStep", 10); + fixture.detectChanges(); + expect(steps()).toEqual(["600", "600"]); + component.closeOverlay(); + fixture.detectChanges(); + }); + + 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(stubModalRef()); + 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 }); + const ref = stubModalRef(); + modalService.open.mockReturnValue(ref); + component.handleIconClick(); + const result = new Date(2025, 8, 1, 12, 0); + ref.closed.next(result); + expect(component.value()).toBe(result); + expect(component.overlayOpen()).toBe(false); + }); + + it("ignores a close that lands after the field is destroyed", () => { + const { component, fixture, modalService } = createField({ modal: true }); + const ref = stubModalRef(); + modalService.open.mockReturnValue(ref); + component.handleIconClick(); + + fixture.destroy(); + ref.closed.next(new Date(2025, 8, 1, 12, 0)); + + expect(component.value()).toBeNull(); + }); + + 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, + }); + const ref = stubModalRef(); + modalService.open.mockReturnValue(ref); + component.handleIconClick(); + ref.closed.next(undefined); + 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("the invalid input toggles the invalid signal", () => { + const { component, fixture } = createField(); + expect(component.invalid()).toBe(false); + fixture.componentRef.setInput("invalid", true); + fixture.detectChanges(); + expect(component.invalid()).toBe(true); + }); + + it("paints its own surface when it stands alone", () => { + const { component, el } = createField(); + expect(component.paintsSurface()).toBe(true); + expect(el.classList.contains("tedi-field-surface")).toBe(true); + }); + + it("focus moves focus to the text input", () => { + const { component, el } = createField(); + const input = el.querySelector( + ".tedi-date-input__input", + ) as HTMLInputElement; + const focusSpy = jest.spyOn(input, "focus"); + + component.focus(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("focus does nothing when the field is disabled", () => { + const { component, el } = createField({ inputDisabled: true }); + const input = el.querySelector( + ".tedi-date-input__input", + ) as HTMLInputElement; + const focusSpy = jest.spyOn(input, "focus"); + + component.focus(); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it("reset clears the value", () => { + const { component } = createField({ value: new Date(2025, 8, 1, 10, 0) }); + component.reset(); + expect(component.value()).toBeNull(); + }); + + it("reset does nothing when the field is read-only", () => { + const value = new Date(2025, 8, 1, 10, 0); + const { component } = createField({ value, readOnly: true }); + component.reset(); + expect(component.value()).toBe(value); + }); + + 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, + ); + }); + }); + + describe("native time input on narrow screens", () => { + it("resolves to a native input below md and the wheel from md up", () => { + const { component, breakpoint, fixture } = createField(); + expect(component.useNativeTimeInput()).toBe(false); + breakpoint.setBreakpoint("sm"); + fixture.detectChanges(); + expect(component.useNativeTimeInput()).toBe(true); + }); + + it("resolves the layout per breakpoint", () => { + const { component, breakpoint } = createField({ + layout: { xs: "multi-step", md: "side-by-side" }, + }); + expect(component.resolvedLayout()).toBe("side-by-side"); + breakpoint.setBreakpoint("sm"); + expect(component.resolvedLayout()).toBe("multi-step"); + }); + + it("owns its clear button and honours clearable", () => { + const { component, fixture } = createField({ + value: new Date(2025, 8, 1, 9, 0), + }); + expect(component.ownsClearButton).toBe(true); + expect(component.canClear()).toBe(true); + fixture.componentRef.setInput("clearable", false); + expect(component.canClear()).toBe(false); + }); + + it("keeps the wheel below md in multi-step", () => { + const { component, breakpoint } = createField({ layout: "multi-step" }); + breakpoint.setBreakpoint("xs"); + expect(component.useNativeTimeInput()).toBe(false); + }); + + it("resolves an explicit useNativeTimePicker per breakpoint", () => { + const { component, breakpoint } = createField({ + useNativeTimePicker: { xs: false, lg: true }, + }); + expect(component.useNativeTimeInput()).toBe(true); + breakpoint.setBreakpoint("xs"); + expect(component.useNativeTimeInput()).toBe(false); + }); + + it("renders a native time-field in the popover below md", () => { + const { component, breakpoint, fixture } = createField(); + breakpoint.setBreakpoint("sm"); + component.handleIconClick(); + fixture.detectChanges(); + expect(document.querySelector("tedi-time-field")).toBeTruthy(); + expect(document.querySelector(".tedi-time-picker--scroll")).toBeNull(); + component.closeOverlay(); + fixture.detectChanges(); + }); + + it("renders the scroll wheel in the popover from md up", () => { + const { component, fixture } = createField(); + component.handleIconClick(); + fixture.detectChanges(); + expect(document.querySelector("tedi-time-field")).toBeNull(); + expect(document.querySelector("tedi-time-picker")).toBeTruthy(); + component.closeOverlay(); + fixture.detectChanges(); + }); + + it("keeps the slot grid (not a native input) when availableTimes is set", () => { + const { component, breakpoint, fixture } = createField({ + availableTimes: ["09:00", "10:00"], + }); + breakpoint.setBreakpoint("sm"); + component.handleIconClick(); + fixture.detectChanges(); + expect(document.querySelector("tedi-time-field")).toBeNull(); + expect(document.querySelector(".tedi-time-picker--slots")).toBeTruthy(); + component.closeOverlay(); + fixture.detectChanges(); + }); + }); + + describe("range time headings", () => { + it("labels the two ends with distinct start/end headings", () => { + const { component, fixture } = createField({ mode: "range" }); + component.handleIconClick(); + fixture.detectChanges(); + const headings = Array.from( + document.querySelectorAll(".tedi-date-time-field__split-heading"), + ).map((h) => h.textContent?.trim()); + expect(headings).toContain("date-time-field.time-heading-from"); + expect(headings).toContain("date-time-field.time-heading-to"); + component.closeOverlay(); + fixture.detectChanges(); + }); + + it("uses scroll wheels for both ends from md up", () => { + const { component, fixture } = createField({ mode: "range" }); + component.handleIconClick(); + fixture.detectChanges(); + expect(document.querySelector("tedi-time-field")).toBeNull(); + expect( + document.querySelectorAll(".tedi-time-picker--scroll").length, + ).toBe(2); + component.closeOverlay(); + fixture.detectChanges(); + }); + + it("uses native time inputs for both ends when useNativeTimePicker is set", () => { + const { component, fixture } = createField({ + mode: "range", + useNativeTimePicker: true, + }); + component.handleIconClick(); + fixture.detectChanges(); + expect(document.querySelectorAll("tedi-time-field").length).toBe(2); + expect(document.querySelector(".tedi-time-picker--scroll")).toBeNull(); + component.closeOverlay(); + fixture.detectChanges(); + }); + + it("keeps the slot grid for both ends when availableTimes is set", () => { + const { component, fixture } = createField({ + mode: "range", + availableTimes: ["09:00", "10:00"], + }); + component.handleIconClick(); + fixture.detectChanges(); + expect(document.querySelector("tedi-time-field")).toBeNull(); + expect(document.querySelectorAll(".tedi-time-picker--slots").length).toBe( + 2, + ); + component.closeOverlay(); + fixture.detectChanges(); + }); + + it("shows each end's selected date under its heading", () => { + const { component, fixture } = createField({ + mode: "range", + value: { + from: new Date(2025, 8, 1, 9, 0), + to: new Date(2025, 8, 5, 17, 0), + }, + }); + component.handleIconClick(); + fixture.detectChanges(); + const dates = Array.from( + document.querySelectorAll(".tedi-date-time-field__range-date"), + ).map((d) => d.textContent?.trim()); + expect(dates.length).toBe(2); + expect(dates.every((d) => !!d)).toBe(true); + component.closeOverlay(); + fixture.detectChanges(); + }); + }); +}); diff --git a/src/tedi/components/form/date-time-field/date-time-field.component.ts b/src/tedi/components/form/date-time-field/date-time-field.component.ts new file mode 100644 index 000000000..595a9de2f --- /dev/null +++ b/src/tedi/components/form/date-time-field/date-time-field.component.ts @@ -0,0 +1,949 @@ +import { + afterNextRender, + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + ElementRef, + forwardRef, + inject, + Injector, + input, + model, + OnInit, + 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 { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +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 { TimeFieldComponent } from "../time-field/time-field.component"; +import { FormFieldComponent } from "../form-field/form-field.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 { + FieldContext, + InputSize, + TEDI_FIELD_CONTEXT, +} from "../form-field/field-context.token"; +import { deriveControlState } from "../form-field/derive-control-state"; +import { + breakpointInput, + BreakpointInput, + BreakpointObject, + BreakpointService, +} from "../../../services/breakpoint/breakpoint.service"; +import { + formatLocaleDate, + formatLocaleDateHint, + nativeDateBounds, + parseLocaleDate, + startOfMonth, + toNativeDate, +} 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, + MonthPredicate, + YearPredicate, +} 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"; +export type DateTimeFieldSize = "default" | "small"; +export 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, + TimeFieldComponent, + FormFieldComponent, + ButtonComponent, + IconComponent, + NgTemplateOutlet, + OverlayModule, + A11yModule, + TediTranslationPipe, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateTimeFieldComponent), + multi: true, + }, + { + provide: TEDI_FORM_FIELD_CONTROL, + useExisting: forwardRef(() => DateTimeFieldComponent), + }, + { + provide: TEDI_FIELD_CONTEXT, + useFactory: (field: DateTimeFieldComponent) => field.childContext, + deps: [forwardRef(() => DateTimeFieldComponent)], + }, + ], + host: { + class: "tedi-date-time-field", + "[class.tedi-field-surface]": "paintsSurface()", + "[class.tedi-field-surface--invalid]": "paintsSurface() && invalid()", + "[class.tedi-field-surface--valid]": "paintsSurface() && valid()", + "[class.tedi-field-surface--disabled]": "paintsSurface() && disabled()", + "[class.tedi-date-time-field--small]": "resolvedSize() === 'small'", + "[class.tedi-date-time-field--large]": "resolvedSize() === 'large'", + }, +}) +export class DateTimeFieldComponent + implements OnInit, ControlValueAccessor, FormFieldControl +{ + private readonly fieldContext = inject(TEDI_FIELD_CONTEXT, { + optional: true, + skipSelf: true, + }); + private readonly derived = deriveControlState(); + /** + * Unique ID for label association and accessibility. Bind the sibling + * `