From 6cb677be21ef11f56cd8b3bc664a00634d6a269c Mon Sep 17 00:00:00 2001 From: m2rt Date: Mon, 18 May 2026 11:57:14 +0300 Subject: [PATCH 1/3] feat(date-field,calendar): new tedi-ready component #6 --- .../calendar-day-grid.component.html | 53 + .../calendar-day-grid.component.scss | 187 +++ .../calendar-day-grid.component.spec.ts | 488 +++++++ .../calendar-day-grid.component.ts | 314 +++++ .../calendar/calendar-day-grid/index.ts | 1 + .../calendar-header.component.html | 191 +++ .../calendar-header.component.scss | 90 ++ .../calendar-header.component.spec.ts | 451 +++++++ .../calendar-header.component.ts | 236 ++++ .../content/calendar/calendar-header/index.ts | 1 + .../calendar-month-grid.component.html | 20 + .../calendar-month-grid.component.scss | 77 ++ .../calendar-month-grid.component.spec.ts | 223 ++++ .../calendar-month-grid.component.ts | 60 + .../calendar/calendar-month-grid/index.ts | 1 + .../calendar-year-grid.component.html | 20 + .../calendar-year-grid.component.scss | 77 ++ .../calendar-year-grid.component.spec.ts | 263 ++++ .../calendar-year-grid.component.ts | 68 + .../calendar/calendar-year-grid/index.ts | 1 + .../content/calendar/calendar.component.html | 114 ++ .../content/calendar/calendar.component.scss | 39 + .../calendar/calendar.component.spec.ts | 1018 ++++++++++++++ .../content/calendar/calendar.component.ts | 570 ++++++++ .../content/calendar/calendar.stories.ts | 667 ++++++++++ tedi/components/content/calendar/index.ts | 3 + tedi/components/content/calendar/types.ts | 3 + tedi/components/content/index.ts | 1 + .../date-field-modal.component.spec.ts | 105 ++ .../date-field-modal.component.ts | 126 ++ .../form/date-field/date-field.component.html | 70 + .../form/date-field/date-field.component.scss | 13 + .../date-field/date-field.component.spec.ts | 1167 +++++++++++++++++ .../form/date-field/date-field.component.ts | 618 +++++++++ .../form/date-field/date-field.stories.ts | 897 +++++++++++++ .../date-input/date-input.component.html | 47 + .../date-input/date-input.component.scss | 120 ++ .../date-input/date-input.component.spec.ts | 321 +++++ .../date-input/date-input.component.ts | 122 ++ .../form/date-field/date-input/index.ts | 2 + tedi/components/form/date-field/index.ts | 7 + .../form/date-picker/date-picker.stories.ts | 8 +- .../form/form-field/form-field.component.html | 2 +- tedi/components/form/index.ts | 1 + .../form/select/select.component.scss | 2 +- .../components/overlay/modal/modal.stories.ts | 168 ++- tedi/index.ts | 1 + tedi/services/translation/translations.ts | 31 + tedi/utils/date.util.spec.ts | 498 ++++++- tedi/utils/date.util.ts | 374 +++++- tedi/utils/index.ts | 5 + tedi/utils/matchers.util.spec.ts | 211 +++ tedi/utils/matchers.util.ts | 132 ++ 53 files changed, 10238 insertions(+), 47 deletions(-) create mode 100644 tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html create mode 100644 tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss create mode 100644 tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts create mode 100644 tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts create mode 100644 tedi/components/content/calendar/calendar-day-grid/index.ts create mode 100644 tedi/components/content/calendar/calendar-header/calendar-header.component.html create mode 100644 tedi/components/content/calendar/calendar-header/calendar-header.component.scss create mode 100644 tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts create mode 100644 tedi/components/content/calendar/calendar-header/calendar-header.component.ts create mode 100644 tedi/components/content/calendar/calendar-header/index.ts create mode 100644 tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.html create mode 100644 tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.scss create mode 100644 tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.spec.ts create mode 100644 tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.ts create mode 100644 tedi/components/content/calendar/calendar-month-grid/index.ts create mode 100644 tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.html create mode 100644 tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.scss create mode 100644 tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.spec.ts create mode 100644 tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.ts create mode 100644 tedi/components/content/calendar/calendar-year-grid/index.ts create mode 100644 tedi/components/content/calendar/calendar.component.html create mode 100644 tedi/components/content/calendar/calendar.component.scss create mode 100644 tedi/components/content/calendar/calendar.component.spec.ts create mode 100644 tedi/components/content/calendar/calendar.component.ts create mode 100644 tedi/components/content/calendar/calendar.stories.ts create mode 100644 tedi/components/content/calendar/index.ts create mode 100644 tedi/components/content/calendar/types.ts create mode 100644 tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts create mode 100644 tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts create mode 100644 tedi/components/form/date-field/date-field.component.html create mode 100644 tedi/components/form/date-field/date-field.component.scss create mode 100644 tedi/components/form/date-field/date-field.component.spec.ts create mode 100644 tedi/components/form/date-field/date-field.component.ts create mode 100644 tedi/components/form/date-field/date-field.stories.ts create mode 100644 tedi/components/form/date-field/date-input/date-input.component.html create mode 100644 tedi/components/form/date-field/date-input/date-input.component.scss create mode 100644 tedi/components/form/date-field/date-input/date-input.component.spec.ts create mode 100644 tedi/components/form/date-field/date-input/date-input.component.ts create mode 100644 tedi/components/form/date-field/date-input/index.ts create mode 100644 tedi/components/form/date-field/index.ts create mode 100644 tedi/utils/index.ts create mode 100644 tedi/utils/matchers.util.spec.ts create mode 100644 tedi/utils/matchers.util.ts diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html new file mode 100644 index 000000000..e62f0bcb6 --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html @@ -0,0 +1,53 @@ + + + + @if (showWeekNumbers()) { + + } + @for (name of weekdayNames(); track $index) { + + } + + + + @for (row of grid(); track rowKey(row, $index)) { + + @if (showWeekNumbers()) { + + } + @for (day of row; track cellKey(day, $index)) { + + } + + } + +
+ {{ name }} +
+ @if (day) { + + } +
diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss new file mode 100644 index 000000000..a3bb9c81f --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss @@ -0,0 +1,187 @@ +.tedi-calendar-day-grid { + width: max-content; + border-spacing: 0; + border-collapse: collapse; + + &__header { + display: grid; + grid-template-columns: repeat(7, var(--form-calendar-date-width)); + } + + &__weekday { + display: flex; + align-items: center; + justify-content: center; + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + font-size: var(--body-small-regular-size); + font-weight: 400; + color: var(--general-text-tertiary); + text-align: center; + text-transform: uppercase; + border-bottom: var(--tedi-borders-01) solid var(--general-border-primary); + } + + &__week-number-header { + display: flex; + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + + &:not(:first-child) { + border-bottom: var(--tedi-borders-01) solid var(--general-border-primary); + } + } + + &__row { + display: grid; + grid-template-columns: repeat(7, var(--form-calendar-date-width)); + } + + &--with-week-numbers { + + .tedi-calendar-day-grid__header, + .tedi-calendar-day-grid__row { + grid-template-columns: repeat(8, var(--form-calendar-date-width)); + } + } + + &__cell { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + } + + &__week-number { + display: flex; + align-items: center; + justify-content: center; + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + font-size: var(--body-small-regular-size); + color: var(--general-text-tertiary); + border-right: var(--tedi-borders-01) solid var(--general-border-primary); + } + + &__day { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: var(--form-calendar-date-width); + height: var(--form-calendar-date-width); + font-size: var(--body-regular-size); + color: var(--general-text-primary); + cursor: pointer; + background: none; + border: none; + border-radius: var(--button-radius-sm); + + &:hover { + background: var(--form-datepicker-date-hover); + } + + &:active { + background: var(--form-datepicker-date-active); + } + + &:focus-visible { + z-index: 1; + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + + &--outside { + color: var(--form-datepicker-date-text-muted); + + &:hover { + color: var(--general-text-primary); + } + } + + &--today::before { + position: absolute; + inset: 0; + pointer-events: none; + content: ""; + border: var(--tedi-borders-01) solid var(--form-datepicker-today-border); + border-radius: 50%; + } + + &--available-day { + color: var(--form-datepicker-date-text-available); + background: var(--form-datepicker-date-available); + + &:hover { + color: var(--general-text-primary); + background: var(--form-datepicker-date-hover); + } + } + + &--selected { + color: var(--form-datepicker-date-text-selected); + background: var(--form-datepicker-date-selected); + + &:hover { + color: var(--form-datepicker-date-text-selected); + background: var(--form-datepicker-date-selected-hover); + } + + &.tedi-calendar-day-grid__day--today::before { + border-color: var(--form-datepicker-today-border-secondary); + } + } + + &--range-start, + &--range-end { + color: var(--form-datepicker-date-text-selected); + background: var(--form-datepicker-date-selected); + } + + &--range-start { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &--range-end { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &--range-middle, + &--range-preview-middle { + color: var(--general-text-primary); + background: var(--form-datepicker-date-active); + border-radius: 0; + } + + &--range-preview-start, + &--range-preview-end { + color: var(--general-text-primary); + background: var(--form-datepicker-date-active); + } + + &--range-preview-start { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &--range-preview-end { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &--unavailable-day { + color: var(--general-text-primary); + text-decoration: line-through; + opacity: 0.3; + } + + &--disabled, + &[aria-disabled="true"] { + pointer-events: none; + cursor: not-allowed; + opacity: 0.3; + } + } +} diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts new file mode 100644 index 000000000..a10d07c6a --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts @@ -0,0 +1,488 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { CalendarDayGridComponent } from "./calendar-day-grid.component"; +import { DateRange } from "../../../../utils/date.util"; +import { Matcher } from "../../../../utils/matchers.util"; + +describe("CalendarDayGridComponent", () => { + let fixture: ComponentFixture; + let component: CalendarDayGridComponent; + + const MAY_2024 = new Date(2024, 4, 15); + + function createComponent(): void { + TestBed.configureTestingModule({ + imports: [CalendarDayGridComponent], + }); + fixture = TestBed.createComponent(CalendarDayGridComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("month", MAY_2024); + fixture.componentRef.setInput("firstDayOfWeek", 1); + fixture.detectChanges(); + } + + function buttons(): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-day-grid__day")) + .map((d) => d.nativeElement as HTMLButtonElement); + } + + function buttonForDay(day: Date): HTMLButtonElement | null { + return ( + fixture.debugElement.nativeElement as HTMLElement + ).querySelector(`[data-date-key="${day.getTime()}"]`); + } + + function hasAnyPreviewClass(): boolean { + return ( + buttons().some((b) => + b.classList.contains("tedi-calendar-day-grid__day--range-preview-end"), + ) || + buttons().some((b) => + b.classList.contains( + "tedi-calendar-day-grid__day--range-preview-middle", + ), + ) + ); + } + + beforeEach(() => { + createComponent(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("grid layout", () => { + it("renders 6 rows of 7 cells", () => { + const rows = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid__row"), + ); + expect(rows.length).toBe(6); + for (const row of rows) { + const cells = row.queryAll(By.css(".tedi-calendar-day-grid__cell")); + expect(cells.length).toBe(7); + } + }); + + it("renders 7 weekday headers respecting firstDayOfWeek=1 (Mon-first)", () => { + const headers = fixture.debugElement + .queryAll(By.css(".tedi-calendar-day-grid__weekday")) + .map((d) => (d.nativeElement.textContent as string).trim()); + expect(headers.length).toBe(7); + const monFirst = headers[0]; + + fixture.componentRef.setInput("firstDayOfWeek", 0); + fixture.detectChanges(); + const headersSunFirst = fixture.debugElement + .queryAll(By.css(".tedi-calendar-day-grid__weekday")) + .map((d) => (d.nativeElement.textContent as string).trim()); + expect(headersSunFirst[0]).not.toEqual(monFirst); + expect(headersSunFirst[1]).toEqual(monFirst); + }); + + it("renders outside-month days as buttons when showOutsideDays=true", () => { + const outside = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid__day--outside"), + ); + expect(outside.length).toBeGreaterThan(0); + }); + + it("renders outside-month cells as empty when showOutsideDays=false", () => { + fixture.componentRef.setInput("showOutsideDays", false); + fixture.detectChanges(); + const outside = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid__day--outside"), + ); + expect(outside.length).toBe(0); + + const emptyCells = fixture.debugElement + .queryAll(By.css(".tedi-calendar-day-grid__cell")) + .filter( + (cell) => + cell.queryAll(By.css(".tedi-calendar-day-grid__day")).length === 0, + ); + expect(emptyCells.length).toBeGreaterThan(0); + }); + + it("renders week-number column when showWeekNumbers=true", () => { + expect( + fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid__week-number"), + ).length, + ).toBe(0); + + fixture.componentRef.setInput("showWeekNumbers", true); + fixture.detectChanges(); + + const weekNumbers = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid__week-number"), + ); + expect(weekNumbers.length).toBe(6); + for (const weekNumber of weekNumbers) { + const value = Number( + (weekNumber.nativeElement.textContent as string).trim(), + ); + expect(Number.isInteger(value)).toBe(true); + expect(value).toBeGreaterThan(0); + } + }); + }); + + describe("selection — single mode", () => { + it("marks the selected date with --selected", () => { + fixture.componentRef.setInput("value", new Date(2024, 4, 15)); + fixture.detectChanges(); + + const selected = buttonForDay(new Date(2024, 4, 15)); + expect(selected).not.toBeNull(); + expect(selected?.classList).toContain( + "tedi-calendar-day-grid__day--selected", + ); + expect(selected?.getAttribute("aria-selected")).toBe("true"); + }); + }); + + describe("selection — multiple mode", () => { + it("marks all dates in the value array", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("value", [ + new Date(2024, 4, 10), + new Date(2024, 4, 20), + ]); + fixture.detectChanges(); + + expect( + buttonForDay(new Date(2024, 4, 10))?.classList.contains( + "tedi-calendar-day-grid__day--selected", + ), + ).toBe(true); + expect( + buttonForDay(new Date(2024, 4, 20))?.classList.contains( + "tedi-calendar-day-grid__day--selected", + ), + ).toBe(true); + expect( + buttonForDay(new Date(2024, 4, 15))?.classList.contains( + "tedi-calendar-day-grid__day--selected", + ), + ).toBe(false); + }); + }); + + describe("selection — range mode", () => { + it("marks --range-start when only from is set", () => { + fixture.componentRef.setInput("mode", "range"); + const range: DateRange = { from: new Date(2024, 4, 10) }; + fixture.componentRef.setInput("value", range); + fixture.detectChanges(); + + const fromBtn = buttonForDay(new Date(2024, 4, 10)); + expect(fromBtn?.classList).toContain( + "tedi-calendar-day-grid__day--range-start", + ); + expect(fromBtn?.classList).toContain( + "tedi-calendar-day-grid__day--selected", + ); + }); + + it("marks --range-start, --range-end and --range-middle when from+to are set", () => { + fixture.componentRef.setInput("mode", "range"); + const range: DateRange = { + from: new Date(2024, 4, 10), + to: new Date(2024, 4, 14), + }; + fixture.componentRef.setInput("value", range); + fixture.detectChanges(); + + expect( + buttonForDay(new Date(2024, 4, 10))?.classList.contains( + "tedi-calendar-day-grid__day--range-start", + ), + ).toBe(true); + expect( + buttonForDay(new Date(2024, 4, 14))?.classList.contains( + "tedi-calendar-day-grid__day--range-end", + ), + ).toBe(true); + for (const d of [11, 12, 13]) { + expect( + buttonForDay(new Date(2024, 4, d))?.classList.contains( + "tedi-calendar-day-grid__day--range-middle", + ), + ).toBe(true); + } + }); + }); + + describe("disabled cells via disabledMatchers", () => { + it("renders disabled buttons", () => { + const matchers: Matcher[] = [new Date(2024, 4, 15)]; + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + + const btn = buttonForDay(new Date(2024, 4, 15)); + expect(btn?.getAttribute("aria-disabled")).toBe("true"); + expect(btn?.classList).toContain("tedi-calendar-day-grid__day--disabled"); + }); + }); + + describe("availableDays / unavailableDays", () => { + it("disables non-available days when availableDays is provided", () => { + const available = [new Date(2024, 4, 10), new Date(2024, 4, 11)]; + fixture.componentRef.setInput("availableDays", available); + fixture.detectChanges(); + + const includedBtn = buttonForDay(new Date(2024, 4, 10)); + expect(includedBtn?.getAttribute("aria-disabled")).toBeNull(); + expect(includedBtn?.classList).toContain( + "tedi-calendar-day-grid__day--available-day", + ); + + const excludedBtn = buttonForDay(new Date(2024, 4, 15)); + expect(excludedBtn?.getAttribute("aria-disabled")).toBe("true"); + }); + + it("disables days flagged by unavailableDays", () => { + const unavailable = (d: Date): boolean => d.getDay() === 0; + fixture.componentRef.setInput("unavailableDays", unavailable); + fixture.detectChanges(); + + const sunday = new Date(2024, 4, 12); + const monday = new Date(2024, 4, 13); + const sundayBtn = buttonForDay(sunday); + const mondayBtn = buttonForDay(monday); + + expect(sundayBtn?.getAttribute("aria-disabled")).toBe("true"); + expect(sundayBtn?.classList).toContain( + "tedi-calendar-day-grid__day--unavailable-day", + ); + expect(mondayBtn?.getAttribute("aria-disabled")).toBeNull(); + }); + }); + + describe("range hover preview", () => { + it("applies --range-preview-middle and --range-preview-end on mouseenter and clears on mouseleave", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", { + from: new Date(2024, 4, 10), + }); + fixture.detectChanges(); + + component.handleMouseEnter(new Date(2024, 4, 14)); + fixture.detectChanges(); + + for (const d of [11, 12, 13]) { + expect( + buttonForDay(new Date(2024, 4, d))?.classList.contains( + "tedi-calendar-day-grid__day--range-preview-middle", + ), + ).toBe(true); + } + expect( + buttonForDay(new Date(2024, 4, 14))?.classList.contains( + "tedi-calendar-day-grid__day--range-preview-end", + ), + ).toBe(true); + + component.handleMouseLeave(); + fixture.detectChanges(); + + for (const d of [11, 12, 13, 14]) { + const btn = buttonForDay(new Date(2024, 4, d)); + expect( + btn?.classList.contains( + "tedi-calendar-day-grid__day--range-preview-middle", + ), + ).toBe(false); + expect( + btn?.classList.contains( + "tedi-calendar-day-grid__day--range-preview-end", + ), + ).toBe(false); + } + }); + + it("does not apply preview classes when not in range mode", () => { + fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); + fixture.detectChanges(); + component.handleMouseEnter(new Date(2024, 4, 14)); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(false); + }); + }); + + describe("daySelect", () => { + it("emits the date when an enabled cell is clicked", () => { + const emit = jest.spyOn(component.daySelect, "emit"); + const day = new Date(2024, 4, 15); + buttonForDay(day)?.click(); + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls[0][0]).toBeInstanceOf(Date); + expect((emit.mock.calls[0][0] as Date).getTime()).toBe(day.getTime()); + }); + + it("does not emit when a disabled cell is clicked", () => { + fixture.componentRef.setInput("disabledMatchers", [new Date(2024, 4, 15)]); + fixture.detectChanges(); + + const emit = jest.spyOn(component.daySelect, "emit"); + buttonForDay(new Date(2024, 4, 15))?.click(); + expect(emit).not.toHaveBeenCalled(); + }); + }); + + describe("inputDisabled", () => { + it("disables every button when true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + + const all = buttons(); + expect(all.length).toBeGreaterThan(0); + for (const btn of all) { + expect(btn.getAttribute("aria-disabled")).toBe("true"); + } + }); + }); + + describe("focusable cell (roving tabindex)", () => { + it("makes exactly one cell tabbable", () => { + const tabbable = buttons().filter((b) => b.getAttribute("tabindex") === "0"); + expect(tabbable.length).toBe(1); + }); + }); + + describe("keyboard focus → hover preview", () => { + it("applies preview classes on focus and clears them on blur in range mode", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); + fixture.detectChanges(); + + component.handleFocus(new Date(2024, 4, 14)); + fixture.detectChanges(); + expect( + buttonForDay(new Date(2024, 4, 14))?.classList.contains( + "tedi-calendar-day-grid__day--range-preview-end", + ), + ).toBe(true); + + component.handleBlur(); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(false); + }); + + it("does not apply preview classes on focus when not in range mode", () => { + fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); + fixture.detectChanges(); + component.handleFocus(new Date(2024, 4, 14)); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(false); + component.handleBlur(); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(false); + }); + + it("ignores focus on null day", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); + fixture.detectChanges(); + component.handleFocus(null); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(false); + }); + + it("ignores mouseenter on null day", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); + fixture.detectChanges(); + component.handleMouseEnter(null); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(false); + }); + }); + + describe("range mode tolerance", () => { + it("treats a Date value as no-range for range-mode rendering", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", new Date(2024, 4, 12)); + fixture.detectChanges(); + + const btn = buttonForDay(new Date(2024, 4, 12)); + expect(btn?.classList.contains("tedi-calendar-day-grid__day--range-start")).toBe(false); + }); + + it("does not preview when hovering on the from date itself", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); + fixture.detectChanges(); + + component.handleMouseEnter(new Date(2024, 4, 10)); + fixture.detectChanges(); + + expect( + buttonForDay(new Date(2024, 4, 10))?.classList.contains( + "tedi-calendar-day-grid__day--range-preview-end", + ), + ).toBe(false); + }); + + it("supports user clicking earlier than from — committed range swaps via orderedRange", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", { + from: new Date(2024, 4, 14), + to: new Date(2024, 4, 10), + }); + fixture.detectChanges(); + + for (const d of [11, 12, 13]) { + expect( + buttonForDay(new Date(2024, 4, d))?.classList.contains( + "tedi-calendar-day-grid__day--range-middle", + ), + ).toBe(true); + } + }); + }); + + describe("focusable cell fallback when current month is not today's month", () => { + it("falls back to the first day of the rendered month when today is elsewhere", () => { + fixture.componentRef.setInput("month", new Date(2030, 0, 15)); + fixture.detectChanges(); + + const tabbable = fixture.debugElement + .queryAll(By.css(".tedi-calendar-day-grid__day")) + .map((d) => d.nativeElement as HTMLButtonElement) + .filter((b) => b.getAttribute("tabindex") === "0"); + expect(tabbable.length).toBe(1); + expect(tabbable[0].textContent?.trim()).toBe("1"); + }); + }); + + describe("availableDays as predicate function", () => { + it("disables days that fail the predicate", () => { + fixture.componentRef.setInput( + "availableDays", + (d: Date) => d.getDate() === 15, + ); + fixture.detectChanges(); + + expect(buttonForDay(new Date(2024, 4, 15))?.getAttribute("aria-disabled")).toBeNull(); + expect(buttonForDay(new Date(2024, 4, 16))?.getAttribute("aria-disabled")).toBe("true"); + }); + }); + + describe("weekNumber()", () => { + it("returns the ISO week of the first non-null day in the row", () => { + const grid = component.grid(); + const firstRow = grid[0]; + const nonNullDay = firstRow.find((d): d is Date => d !== null); + if (!nonNullDay) throw new Error("Expected a non-null day in first row"); + expect(component.weekNumber(firstRow)).toBeGreaterThan(0); + }); + + it("returns null for an all-null row", () => { + expect(component.weekNumber([null, null, null, null, null, null, null])).toBeNull(); + }); + }); +}); diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts new file mode 100644 index 000000000..7b57ed944 --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts @@ -0,0 +1,314 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + model, + output, + ViewEncapsulation, +} from "@angular/core"; +import { + buildMonthGrid, + DateRange, + getISOWeek, + getWeekdayNames, + isAfterDay, + isBeforeDay, + isSameDay, + isSameMonth, +} from "../../../../utils/date.util"; +import { matchAny, Matcher } from "../../../../utils/matchers.util"; +import { DateFieldMode } from "../types"; + +type DayPredicate = (date: Date) => boolean; +type DayAvailabilityInput = Date[] | DayPredicate | undefined; +type CalendarValue = Date | Date[] | DateRange | null; + +@Component({ + selector: "tedi-calendar-day-grid", + standalone: true, + templateUrl: "./calendar-day-grid.component.html", + styleUrl: "./calendar-day-grid.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CalendarDayGridComponent { + readonly month = input.required(); + readonly mode = input("single"); + readonly value = input(null); + readonly localeCode = input("et-EE"); + readonly firstDayOfWeek = input.required(); + readonly showOutsideDays = input(true); + readonly showWeekNumbers = input(false); + readonly disabledMatchers = input([]); + readonly availableDays = input(undefined); + readonly unavailableDays = input(undefined); + readonly inputDisabled = input(false); + + readonly daySelect = output(); + + readonly hoveredDate = model(null); + + readonly weekdayNames = computed(() => + getWeekdayNames(this.localeCode(), "narrow", this.firstDayOfWeek()), + ); + + readonly grid = computed(() => + buildMonthGrid( + this.month(), + this.firstDayOfWeek(), + this.showOutsideDays(), + ), + ); + + private readonly availablePredicate = computed(() => + this.toPredicate(this.availableDays()), + ); + private readonly unavailablePredicate = computed(() => + this.toPredicate(this.unavailableDays()), + ); + + private readonly focusableKey = computed(() => { + const month = this.month(); + const grid = this.grid(); + const today = new Date(); + if (isSameMonth(today, month)) { + const todayInGrid = this.findInGrid(grid, (d) => isSameDay(d, today)); + if (todayInGrid && !this.isDisabled(todayInGrid)) { + return this.dayKey(todayInGrid); + } + } + const firstSelectable = this.findInGrid( + grid, + (d) => isSameMonth(d, month) && !this.isDisabled(d), + ); + return firstSelectable ? this.dayKey(firstSelectable) : null; + }); + + cellState(day: Date | null): string { + if (!day) return ""; + const modifiers: string[] = []; + this.collectBaseModifiers(day, modifiers); + this.collectRangeModifiers(day, modifiers); + this.collectAvailabilityModifiers(day, modifiers); + if (this.isDisabled(day)) modifiers.push("disabled"); + return [ + "tedi-calendar-day-grid__day", + ...modifiers.map((m) => `tedi-calendar-day-grid__day--${m}`), + ].join(" "); + } + + private collectBaseModifiers(day: Date, modifiers: string[]): void { + if (!isSameMonth(day, this.month())) modifiers.push("outside"); + if (isSameDay(day, new Date())) modifiers.push("today"); + if (this.isSelected(day)) modifiers.push("selected"); + } + + private collectRangeModifiers(day: Date, modifiers: string[]): void { + if (this.mode() !== "range") return; + const range = this.rangeValue(); + if (!range) return; + if (range.to) { + this.collectCommittedRangeModifiers(day, range, modifiers); + return; + } + this.collectPreviewRangeModifiers(day, range, modifiers); + } + + private collectCommittedRangeModifiers( + day: Date, + range: DateRange, + modifiers: string[], + ): void { + if (isSameDay(day, range.from)) modifiers.push("range-start"); + if (range.to && isSameDay(day, range.to)) modifiers.push("range-end"); + if (this.isInCommittedRangeMiddle(day, range)) { + modifiers.push("range-middle"); + } + } + + private collectPreviewRangeModifiers( + day: Date, + range: DateRange, + modifiers: string[], + ): void { + const hovered = this.hoveredDate(); + if (!hovered || isSameDay(hovered, range.from)) { + if (isSameDay(day, range.from)) modifiers.push("range-start"); + return; + } + const hoverIsAfter = isAfterDay(hovered, range.from); + if (isSameDay(day, range.from)) { + modifiers.push(hoverIsAfter ? "range-start" : "range-end"); + } else if (isSameDay(day, hovered)) { + modifiers.push(hoverIsAfter ? "range-preview-end" : "range-preview-start"); + } else if (this.isInPreviewRangeMiddle(day, range)) { + modifiers.push("range-preview-middle"); + } + } + + private collectAvailabilityModifiers(day: Date, modifiers: string[]): void { + if (this.availableDays() !== undefined && this.availablePredicate()(day)) { + modifiers.push("available-day"); + } + if ( + this.unavailableDays() !== undefined && + this.unavailablePredicate()(day) + ) { + modifiers.push("unavailable-day"); + } + } + + isSelected(day: Date | null): boolean { + if (!day) return false; + const value = this.value(); + if (value === null) return false; + const mode = this.mode(); + if (mode === "single") { + return value instanceof Date && isSameDay(day, value); + } + if (mode === "multiple") { + return Array.isArray(value) && value.some((d) => isSameDay(d, day)); + } + if (mode === "range") { + const range = this.asRange(value); + if (!range) return false; + if (isSameDay(day, range.from)) return true; + if (range.to && isSameDay(day, range.to)) return true; + } + return false; + } + + isDisabled(day: Date | null): boolean { + if (!day) return true; + if (this.inputDisabled()) return true; + if (matchAny(day, this.disabledMatchers())) return true; + if ( + this.availableDays() !== undefined && + !this.availablePredicate()(day) + ) { + return true; + } + if ( + this.unavailableDays() !== undefined && + this.unavailablePredicate()(day) + ) { + return true; + } + return false; + } + + isFocusable(day: Date | null): boolean { + if (!day) return false; + const key = this.focusableKey(); + return key !== null && this.dayKey(day) === key; + } + + weekNumber(row: (Date | null)[]): number | null { + const reference = row.find((d): d is Date => d !== null); + if (!reference) return null; + return getISOWeek(reference); + } + + rowKey(row: (Date | null)[], index: number): number { + const reference = row.find((d): d is Date => d !== null); + return reference ? reference.getTime() : index; + } + + cellKey(day: Date | null, index: number): string { + return day ? `d-${day.getTime()}` : `e-${index}`; + } + + handleClick(day: Date | null): void { + if (!day) return; + if (this.isDisabled(day)) return; + this.daySelect.emit(day); + } + + handleMouseEnter(day: Date | null): void { + if (!day) return; + if (this.mode() !== "range") return; + this.hoveredDate.set(day); + } + + handleMouseLeave(): void { + if (this.mode() !== "range") return; + this.hoveredDate.set(null); + } + + handleFocus(day: Date | null): void { + if (!day) return; + if (this.mode() !== "range") return; + this.hoveredDate.set(day); + } + + handleBlur(): void { + if (this.mode() !== "range") return; + this.hoveredDate.set(null); + } + + private toPredicate(input: DayAvailabilityInput): DayPredicate { + if (input === undefined) return () => false; + if (typeof input === "function") return input; + return (d: Date) => input.some((entry) => isSameDay(entry, d)); + } + + private rangeValue(): DateRange | null { + if (this.mode() !== "range") return null; + return this.asRange(this.value()); + } + + private asRange(value: CalendarValue): DateRange | null { + if (value && !Array.isArray(value) && !(value instanceof Date)) { + return value; + } + return null; + } + + private isInCommittedRangeMiddle(day: Date, range: DateRange): boolean { + if (!range.to) return false; + const [start, end] = this.orderedRange(range.from, range.to); + return isAfterDay(day, start) && isBeforeDay(day, end); + } + + private isInPreviewRangeMiddle(day: Date, range: DateRange): boolean { + if (range.to) return false; + const hovered = this.hoveredDate(); + if (!hovered) return false; + if (isSameDay(hovered, range.from)) return false; + const [start, end] = this.orderedRange(range.from, hovered); + return isAfterDay(day, start) && isBeforeDay(day, end); + } + + private isPreviewRangeEnd(day: Date, range: DateRange): boolean { + if (range.to) return false; + const hovered = this.hoveredDate(); + if (!hovered) return false; + if (isSameDay(hovered, range.from)) return false; + return isSameDay(day, hovered); + } + + private orderedRange(a: Date, b: Date): [Date, Date] { + return isBeforeDay(a, b) ? [a, b] : [b, a]; + } + + private findInGrid( + grid: (Date | null)[][], + predicate: (day: Date) => boolean, + ): Date | null { + for (const row of grid) { + for (const cell of row) { + if (cell && predicate(cell)) return cell; + } + } + return null; + } + + private dayKey(day: Date): number { + return new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + ).getTime(); + } +} diff --git a/tedi/components/content/calendar/calendar-day-grid/index.ts b/tedi/components/content/calendar/calendar-day-grid/index.ts new file mode 100644 index 000000000..d6fcfd7d7 --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/index.ts @@ -0,0 +1 @@ +export { CalendarDayGridComponent } from "./calendar-day-grid.component"; diff --git a/tedi/components/content/calendar/calendar-header/calendar-header.component.html b/tedi/components/content/calendar/calendar-header/calendar-header.component.html new file mode 100644 index 000000000..2d175b162 --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.html @@ -0,0 +1,191 @@ +
+ @if (showNavigation()) { + + } + +
+ @switch (view()) { + @case ("days") { + @if (monthYearSelectType() === "dropdown") { + + + + @for (option of monthOptions(); track option.index) { +
  • + {{ option.label }} +
  • + } +
    +
    + } @else { + + } + + @if (monthYearSelectType() === "dropdown") { + + + + @for (option of yearOptions(); track option.year) { +
  • + {{ option.year }} +
  • + } +
    +
    + } @else { + + } + } + @case ("months") { + @if (monthYearSelectType() === "dropdown") { + + + + @for (option of yearOptions(); track option.year) { +
  • + {{ option.year }} +
  • + } +
    +
    + } @else { + + } + } + @case ("years") { + + {{ yearRangeLabel() }} + + } + } +
    + + @if (showNavigation()) { + + } +
    diff --git a/tedi/components/content/calendar/calendar-header/calendar-header.component.scss b/tedi/components/content/calendar/calendar-header/calendar-header.component.scss new file mode 100644 index 000000000..1a2bb3457 --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.scss @@ -0,0 +1,90 @@ +.tedi-calendar-header { + display: flex; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: space-between; + + &__title { + display: flex; + flex: 1; + gap: var(--layout-grid-gutters-08); + align-items: center; + justify-content: center; + } + + &__nav-button { + flex-shrink: 0; + + &.tedi-button tedi-icon { + font-size: var(--icon-03); + } + } + + &__select, + &__label-button { + display: inline-flex; + gap: var(--layout-grid-gutters-04); + align-items: center; + padding-left: var(--layout-grid-gutters-04); + font-size: var(--body-regular-size); + font-weight: 500; + color: var(--general-text-primary); + text-transform: capitalize; + cursor: pointer; + background: transparent; + border: none; + border-radius: var(--button-radius-sm); + + &:hover:not(:disabled) { + color: var(--button-main-neutral-text-hover); + background: var(--button-main-neutral-icon-only-background-hover); + + .tedi-calendar-header__select-arrow.tedi-icon { + color: inherit; + } + } + + &[aria-expanded="true"] { + color: var(--button-main-neutral-text-active); + background: var(--button-main-neutral-icon-only-background-active); + + .tedi-calendar-header__select-arrow.tedi-icon { + color: inherit; + } + } + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + &__select-arrow.tedi-icon { + flex-shrink: 0; + font-size: var(--tedi-size-09); + color: var(--general-icon-tertiary); + } + + &__dropdown { + max-height: 15rem; + } + + &__static-label { + font-size: var(--body-regular-size); + color: var(--general-text-primary); + } + + &--disabled { + .tedi-calendar-header__select, + .tedi-calendar-header__label-button { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + } + } +} diff --git a/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts b/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts new file mode 100644 index 000000000..573f25fe6 --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts @@ -0,0 +1,451 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { CalendarHeaderComponent } from "./calendar-header.component"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; +import { Matcher } from "../../../../utils/matchers.util"; +import { getMonthNames } from "../../../../utils/date.util"; + +class TranslationMock { + translate(key: string): string { + return key; + } + track(key: string) { + return () => key; + } +} + +describe("CalendarHeaderComponent", () => { + let fixture: ComponentFixture; + let component: CalendarHeaderComponent; + + const MAY_2024 = new Date(2024, 4, 1); + + function createComponent(): void { + TestBed.configureTestingModule({ + imports: [CalendarHeaderComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + fixture = TestBed.createComponent(CalendarHeaderComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("currentMonth", MAY_2024); + fixture.componentRef.setInput("view", "days"); + fixture.detectChanges(); + } + + function navButtons(): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-header__nav-button")) + .map((d) => d.nativeElement as HTMLButtonElement); + } + + function selectTriggers(): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-header__select")) + .map((d) => d.nativeElement as HTMLButtonElement); + } + + function labelButtons(): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-header__label-button")) + .map((d) => d.nativeElement as HTMLButtonElement); + } + + function titleText(): string { + const triggers = [ + ...selectTriggers(), + ...labelButtons(), + ...fixture.debugElement + .queryAll(By.css(".tedi-calendar-header__static-label")) + .map((d) => d.nativeElement as HTMLElement), + ]; + return triggers + .map((el) => (el.textContent ?? "").replace(/arrow_drop_down/g, "")) + .map((t) => t.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" "); + } + + beforeEach(() => { + createComponent(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("navigation buttons", () => { + it("renders prev/next buttons when showNavigation=true (default)", () => { + expect(navButtons().length).toBe(2); + }); + + it("omits prev/next buttons when showNavigation=false", () => { + fixture.componentRef.setInput("showNavigation", false); + fixture.detectChanges(); + expect(navButtons().length).toBe(0); + }); + + it("emits prevClick when prev button is clicked", () => { + const emit = jest.spyOn(component.prevClick, "emit"); + navButtons()[0].click(); + expect(emit).toHaveBeenCalledTimes(1); + }); + + it("emits nextClick when next button is clicked", () => { + const emit = jest.spyOn(component.nextClick, "emit"); + navButtons()[1].click(); + expect(emit).toHaveBeenCalledTimes(1); + }); + }); + + describe("label rendering per view", () => { + it("days view shows long month name and 4-digit year in et-EE", () => { + const months = getMonthNames("et-EE", "long"); + const text = titleText(); + expect(text).toContain(months[4]); + expect(text).toContain("2024"); + }); + + it("months view shows year only", () => { + fixture.componentRef.setInput("view", "months"); + fixture.detectChanges(); + const text = titleText(); + expect(text).toBe("2024"); + }); + + it("years view shows a YYYY-YYYY range derived from yearPageStart + yearPageSize - 1", () => { + fixture.componentRef.setInput("view", "years"); + fixture.componentRef.setInput("yearPageStart", 2020); + fixture.componentRef.setInput("yearPageSize", 12); + fixture.detectChanges(); + expect(titleText()).toBe("2020-2031"); + }); + + it("years view derives yearPageStart from currentMonth when not provided", () => { + fixture.componentRef.setInput("view", "years"); + fixture.componentRef.setInput("yearPageSize", 12); + fixture.detectChanges(); + // currentMonth year is 2024, default start = 2019, end = 2030 + expect(titleText()).toBe("2019-2030"); + }); + + it("years view label is not a button (static)", () => { + fixture.componentRef.setInput("view", "years"); + fixture.detectChanges(); + expect(selectTriggers().length).toBe(0); + expect(labelButtons().length).toBe(0); + const staticLabel = fixture.debugElement.query( + By.css(".tedi-calendar-header__static-label"), + ); + expect(staticLabel).toBeTruthy(); + }); + }); + + describe("dropdown vs grid mode", () => { + it("renders dropdown selects in dropdown mode (default) for days view", () => { + expect(selectTriggers().length).toBe(2); + expect(labelButtons().length).toBe(0); + }); + + it("renders label buttons in grid mode for days view", () => { + fixture.componentRef.setInput("monthYearSelectType", "grid"); + fixture.detectChanges(); + expect(selectTriggers().length).toBe(0); + expect(labelButtons().length).toBe(2); + }); + + it("renders one dropdown in months view (dropdown mode)", () => { + fixture.componentRef.setInput("view", "months"); + fixture.detectChanges(); + expect(selectTriggers().length).toBe(1); + }); + + it("renders one label button in months view (grid mode)", () => { + fixture.componentRef.setInput("view", "months"); + fixture.componentRef.setInput("monthYearSelectType", "grid"); + fixture.detectChanges(); + expect(labelButtons().length).toBe(1); + }); + }); + + describe("dropdown selection emission", () => { + function monthItems(): HTMLLIElement[] { + return fixture.debugElement + .queryAll( + By.css(".tedi-calendar-header__dropdown--month li[tedi-dropdown-item]"), + ) + .map((d) => d.nativeElement as HTMLLIElement); + } + + function yearItems(): HTMLLIElement[] { + return fixture.debugElement + .queryAll( + By.css(".tedi-calendar-header__dropdown--year li[tedi-dropdown-item]"), + ) + .map((d) => d.nativeElement as HTMLLIElement); + } + + it("emits monthChange with startOfMonth(picked) when a month item is clicked", () => { + const emit = jest.spyOn(component.monthChange, "emit"); + // Items are indexed 0..11; index 7 = August. + monthItems()[7].click(); + fixture.detectChanges(); + expect(emit).toHaveBeenCalledTimes(1); + const arg = emit.mock.calls[0][0] as Date; + expect(arg).toBeInstanceOf(Date); + expect(arg.getFullYear()).toBe(2024); + expect(arg.getMonth()).toBe(7); + expect(arg.getDate()).toBe(1); + expect(arg.getHours()).toBe(0); + }); + + it("does not emit monthChange when value is undefined", () => { + const emit = jest.spyOn(component.monthChange, "emit"); + component.handleMonthSelect(undefined); + expect(emit).not.toHaveBeenCalled(); + }); + + it("emits yearChange with Jan 1 of picked year when a year item is clicked", () => { + const emit = jest.spyOn(component.yearChange, "emit"); + const item = yearItems().find( + (el) => el.textContent?.trim() === "2030", + ); + expect(item).toBeTruthy(); + item!.click(); + fixture.detectChanges(); + expect(emit).toHaveBeenCalledTimes(1); + const arg = emit.mock.calls[0][0] as Date; + expect(arg.getFullYear()).toBe(2030); + expect(arg.getMonth()).toBe(0); + expect(arg.getDate()).toBe(1); + }); + + it("does not emit yearChange when value is undefined", () => { + const emit = jest.spyOn(component.yearChange, "emit"); + component.handleYearSelect(undefined); + expect(emit).not.toHaveBeenCalled(); + }); + }); + + describe("grid mode viewChange emission", () => { + beforeEach(() => { + fixture.componentRef.setInput("monthYearSelectType", "grid"); + fixture.detectChanges(); + }); + + it("clicking month label in grid mode emits viewChange('months')", () => { + const emit = jest.spyOn(component.viewChange, "emit"); + labelButtons()[0].click(); + expect(emit).toHaveBeenCalledWith("months"); + }); + + it("clicking year label in grid mode (days view) emits viewChange('years')", () => { + const emit = jest.spyOn(component.viewChange, "emit"); + labelButtons()[1].click(); + expect(emit).toHaveBeenCalledWith("years"); + }); + + it("clicking year label in months view emits viewChange('years')", () => { + fixture.componentRef.setInput("view", "months"); + fixture.detectChanges(); + const emit = jest.spyOn(component.viewChange, "emit"); + labelButtons()[0].click(); + expect(emit).toHaveBeenCalledWith("years"); + }); + }); + + describe("fully-disabled-month detection (dropdown items)", () => { + function disabledMonthIndices(): number[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-header__dropdown--month li")) + .map((d, i) => ({ + disabled: (d.nativeElement as HTMLElement).getAttribute( + "aria-disabled", + ), + i, + })) + .filter((x) => x.disabled === "true") + .map((x) => x.i); + } + + it("marks a month disabled when every day matches a disabledMatcher", () => { + const matchers: Matcher[] = [ + { from: new Date(2024, 4, 1), to: new Date(2024, 4, 31) }, + ]; + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + + expect(disabledMonthIndices()).toContain(4); + }); + + it("does not mark a month disabled when only some days are disabled", () => { + const matchers: Matcher[] = [ + { from: new Date(2024, 4, 1), to: new Date(2024, 4, 15) }, + ]; + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + + expect(disabledMonthIndices()).not.toContain(4); + }); + + it("uses isMonthDisabled predicate", () => { + fixture.componentRef.setInput( + "isMonthDisabled", + (m: Date) => m.getMonth() === 0, + ); + fixture.detectChanges(); + + expect(disabledMonthIndices()).toContain(0); + }); + }); + + describe("fully-disabled-year detection (dropdown items)", () => { + function yearItems(): HTMLElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-header__dropdown--year li")) + .map((d) => d.nativeElement as HTMLElement); + } + + it("marks a year disabled when every month is fully disabled", () => { + const matchers: Matcher[] = [ + { from: new Date(2023, 0, 1), to: new Date(2023, 11, 31) }, + ]; + fixture.componentRef.setInput("minYear", 2022); + fixture.componentRef.setInput("maxYear", 2025); + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + + const items = yearItems(); + const disabled2023 = items.find((el) => el.textContent?.trim() === "2023"); + const disabled2024 = items.find((el) => el.textContent?.trim() === "2024"); + expect(disabled2023?.getAttribute("aria-disabled")).toBe("true"); + expect(disabled2024?.getAttribute("aria-disabled")).not.toBe("true"); + }); + + it("uses isYearDisabled predicate", () => { + fixture.componentRef.setInput("minYear", 2023); + fixture.componentRef.setInput("maxYear", 2025); + fixture.componentRef.setInput( + "isYearDisabled", + (y: Date) => y.getFullYear() === 2024, + ); + fixture.detectChanges(); + + const items = yearItems(); + const item2024 = items.find((el) => el.textContent?.trim() === "2024"); + expect(item2024?.getAttribute("aria-disabled")).toBe("true"); + }); + }); + + describe("inputDisabled", () => { + it("disables both nav buttons", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const navs = navButtons(); + expect(navs[0].disabled).toBe(true); + expect(navs[1].disabled).toBe(true); + }); + + it("disables select triggers in dropdown mode", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const triggers = selectTriggers(); + for (const trigger of triggers) { + expect(trigger.disabled).toBe(true); + } + }); + + it("disables label buttons in grid mode", () => { + fixture.componentRef.setInput("monthYearSelectType", "grid"); + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + for (const btn of labelButtons()) { + expect(btn.disabled).toBe(true); + } + }); + + it("does not emit on prev/next when inputDisabled is true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const prevEmit = jest.spyOn(component.prevClick, "emit"); + const nextEmit = jest.spyOn(component.nextClick, "emit"); + navButtons()[0].click(); + navButtons()[1].click(); + expect(prevEmit).not.toHaveBeenCalled(); + expect(nextEmit).not.toHaveBeenCalled(); + }); + }); + + describe("prev/next disabled logic", () => { + it("days view: disables prev when the previous month is fully matched", () => { + const matchers: Matcher[] = [ + { from: new Date(2024, 3, 1), to: new Date(2024, 3, 30) }, + ]; + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + expect(navButtons()[0].disabled).toBe(true); + }); + + it("days view: enables prev when not fully matched", () => { + expect(navButtons()[0].disabled).toBe(false); + }); + + it("days view: disables next when the next month is fully matched", () => { + const matchers: Matcher[] = [ + { from: new Date(2024, 5, 1), to: new Date(2024, 5, 30) }, + ]; + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + expect(navButtons()[1].disabled).toBe(true); + }); + + it("months view: disables prev when previous year is fully disabled", () => { + fixture.componentRef.setInput("view", "months"); + fixture.componentRef.setInput( + "isYearDisabled", + (y: Date) => y.getFullYear() === 2023, + ); + fixture.detectChanges(); + expect(navButtons()[0].disabled).toBe(true); + }); + + it("years view: disables prev when previous page would go below minYear", () => { + fixture.componentRef.setInput("view", "years"); + fixture.componentRef.setInput("yearPageStart", 2020); + fixture.componentRef.setInput("yearPageSize", 12); + fixture.componentRef.setInput("minYear", 2015); + fixture.detectChanges(); + expect(navButtons()[0].disabled).toBe(true); + }); + + it("years view: enables prev when previous page is within minYear", () => { + fixture.componentRef.setInput("view", "years"); + fixture.componentRef.setInput("yearPageStart", 2020); + fixture.componentRef.setInput("yearPageSize", 12); + fixture.componentRef.setInput("minYear", 2000); + fixture.detectChanges(); + expect(navButtons()[0].disabled).toBe(false); + }); + + it("years view: disables next when next page exceeds maxYear", () => { + fixture.componentRef.setInput("view", "years"); + fixture.componentRef.setInput("yearPageStart", 2020); + fixture.componentRef.setInput("yearPageSize", 12); + fixture.componentRef.setInput("maxYear", 2030); + fixture.detectChanges(); + expect(navButtons()[1].disabled).toBe(true); + }); + + it("years view: enables next when next page is within maxYear", () => { + fixture.componentRef.setInput("view", "years"); + fixture.componentRef.setInput("yearPageStart", 2020); + fixture.componentRef.setInput("yearPageSize", 12); + fixture.componentRef.setInput("maxYear", 2050); + fixture.detectChanges(); + expect(navButtons()[1].disabled).toBe(false); + }); + }); +}); diff --git a/tedi/components/content/calendar/calendar-header/calendar-header.component.ts b/tedi/components/content/calendar/calendar-header/calendar-header.component.ts new file mode 100644 index 000000000..d1282eb1b --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.ts @@ -0,0 +1,236 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + ViewEncapsulation, +} from "@angular/core"; +import { ButtonComponent } from "../../../buttons/button/button.component"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { DropdownComponent } from "../../../overlay/dropdown/dropdown.component"; +import { DropdownTriggerDirective } from "../../../overlay/dropdown/dropdown-trigger/dropdown-trigger.directive"; +import { DropdownContentComponent } from "../../../overlay/dropdown/dropdown-content/dropdown-content.component"; +import { DropdownItemComponent } from "../../../overlay/dropdown/dropdown-item/dropdown-item.component"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { + addMonths, + addYears, + getDaysInMonth, + getMonthNames, + startOfMonth, +} from "../../../../utils/date.util"; +import { matchAny, Matcher } from "../../../../utils/matchers.util"; +import { CalendarView } from "../types"; + +type MonthYearSelectType = "dropdown" | "grid"; +type MonthPredicate = (month: Date) => boolean; +type YearPredicate = (year: Date) => boolean; + +type MonthOption = { index: number; label: string; disabled: boolean }; +type YearOption = { year: number; disabled: boolean }; + +@Component({ + selector: "tedi-calendar-header", + standalone: true, + templateUrl: "./calendar-header.component.html", + styleUrl: "./calendar-header.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ButtonComponent, + IconComponent, + DropdownComponent, + DropdownTriggerDirective, + DropdownContentComponent, + DropdownItemComponent, + ], +}) +export class CalendarHeaderComponent { + readonly currentMonth = input.required(); + readonly view = input.required(); + readonly localeCode = input("et-EE"); + readonly showNavigation = input(true); + readonly monthYearSelectType = input("dropdown"); + readonly disabledMatchers = input([]); + readonly isMonthDisabled = input(() => false); + readonly isYearDisabled = input(() => false); + readonly minYear = input(new Date().getFullYear() - 100); + readonly maxYear = input(new Date().getFullYear() + 100); + readonly yearPageStart = input(null); + readonly yearPageSize = input(12); + readonly numberOfMonths = input(1); + readonly inputDisabled = input(false); + + readonly prevClick = output(); + readonly nextClick = output(); + readonly monthChange = output(); + readonly yearChange = output(); + readonly viewChange = output(); + + readonly translationService = inject(TediTranslationService); + + readonly monthNames = computed(() => + getMonthNames(this.localeCode(), "long"), + ); + + readonly resolvedYearPageStart = computed(() => { + const explicit = this.yearPageStart(); + if (explicit !== null) return explicit; + return this.currentMonth().getFullYear() - 5; + }); + + readonly yearRangeEnd = computed( + () => this.resolvedYearPageStart() + this.yearPageSize() - 1, + ); + + readonly yearRangeLabel = computed( + () => `${this.resolvedYearPageStart()}-${this.yearRangeEnd()}`, + ); + + readonly currentYear = computed(() => this.currentMonth().getFullYear()); + + readonly currentMonthIndex = computed(() => this.currentMonth().getMonth()); + + readonly currentMonthLabel = computed(() => { + const names = this.monthNames(); + return names[this.currentMonthIndex()] ?? ""; + }); + + readonly monthOptions = computed(() => { + const year = this.currentYear(); + const names = this.monthNames(); + return names.map((label, index) => ({ + index, + label, + disabled: this.isMonthFullyDisabled(new Date(year, index, 1)), + })); + }); + + readonly yearOptions = computed(() => { + const min = this.minYear(); + const max = this.maxYear(); + const result: YearOption[] = []; + for (let year = min; year <= max; year++) { + result.push({ + year, + disabled: this.isYearFullyDisabled(year), + }); + } + return result; + }); + + readonly prevDisabled = computed(() => { + if (this.inputDisabled()) return true; + const view = this.view(); + if (view === "days") { + const prev = addMonths(this.currentMonth(), -1); + return this.isMonthFullyDisabled(prev); + } + if (view === "months") { + const prev = addYears(this.currentMonth(), -1); + return this.isYearFullyDisabled(prev.getFullYear()); + } + return this.resolvedYearPageStart() - this.yearPageSize() < this.minYear(); + }); + + readonly nextDisabled = computed(() => { + if (this.inputDisabled()) return true; + const view = this.view(); + if (view === "days") { + const next = addMonths(this.currentMonth(), this.numberOfMonths()); + return this.isMonthFullyDisabled(next); + } + if (view === "months") { + const next = addYears(this.currentMonth(), 1); + return this.isYearFullyDisabled(next.getFullYear()); + } + return this.yearRangeEnd() + 1 > this.maxYear(); + }); + + readonly prevAriaLabel = computed(() => { + const view = this.view(); + if (view === "years") { + return this.translationService.translate("date-picker.previous-years"); + } + return this.translationService.translate("date-picker.go-prev-month"); + }); + + readonly nextAriaLabel = computed(() => { + const view = this.view(); + if (view === "years") { + return this.translationService.translate("date-picker.next-years"); + } + return this.translationService.translate("date-picker.go-next-month"); + }); + + readonly selectMonthLabel = computed(() => + this.translationService.translate("date-picker.select-month"), + ); + + readonly selectYearLabel = computed(() => + this.translationService.translate("date-picker.select-year"), + ); + + handlePrev(): void { + if (this.prevDisabled()) return; + this.prevClick.emit(); + } + + handleNext(): void { + if (this.nextDisabled()) return; + this.nextClick.emit(); + } + + handleMonthSelect(value?: string): void { + if (!value) return; + const index = Number(value); + if (!Number.isFinite(index)) return; + const next = new Date(this.currentYear(), index, 1); + this.monthChange.emit(startOfMonth(next)); + } + + handleYearSelect(value?: string): void { + if (!value) return; + const year = Number(value); + if (!Number.isFinite(year)) return; + this.yearChange.emit(new Date(year, 0, 1)); + } + + handleMonthLabelClick(): void { + if (this.inputDisabled()) return; + this.viewChange.emit("months"); + } + + handleYearLabelClick(): void { + if (this.inputDisabled()) return; + this.viewChange.emit("years"); + } + + private isMonthFullyDisabled(month: Date): boolean { + if (this.isMonthDisabled()(month)) return true; + const matchers = this.disabledMatchers(); + if (matchers.length === 0) return false; + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + const days = getDaysInMonth(year, monthIndex); + for (let day = 1; day <= days; day++) { + if (!matchAny(new Date(year, monthIndex, day), matchers)) { + return false; + } + } + return true; + } + + private isYearFullyDisabled(year: number): boolean { + const yearStart = new Date(year, 0, 1); + if (this.isYearDisabled()(yearStart)) return true; + for (let month = 0; month < 12; month++) { + if (!this.isMonthFullyDisabled(new Date(year, month, 1))) { + return false; + } + } + return true; + } +} diff --git a/tedi/components/content/calendar/calendar-header/index.ts b/tedi/components/content/calendar/calendar-header/index.ts new file mode 100644 index 000000000..7d9d762f5 --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/index.ts @@ -0,0 +1 @@ +export { CalendarHeaderComponent } from "./calendar-header.component"; diff --git a/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.html b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.html new file mode 100644 index 000000000..39ee0ade1 --- /dev/null +++ b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.html @@ -0,0 +1,20 @@ +
    +
    + @for (name of monthNames(); track $index; let i = $index) { +
    + +
    + } +
    +
    diff --git a/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.scss b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.scss new file mode 100644 index 000000000..065be96fb --- /dev/null +++ b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.scss @@ -0,0 +1,77 @@ +.tedi-calendar-month-grid { + width: 100%; + + &__row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--layout-grid-gutters-08); + } + + &__cell { + display: flex; + } + + &__month { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + padding: var(--form-checkbox-radio-card-radio-padding-y) + var(--form-checkbox-radio-card-radio-padding-x); + font-size: var(--body-regular-size); + color: var(--form-checkbox-radio-card-primary-default-text); + cursor: pointer; + background: var(--form-checkbox-radio-card-secondary-default-background); + border: var(--tedi-borders-01) solid + var(--form-checkbox-radio-card-secondary-default-border); + border-radius: var(--form-checkbox-radio-card-radius); + + &:hover { + color: var(--form-checkbox-radio-card-secondary-hover-text); + background: var(--form-checkbox-radio-card-secondary-hover-background); + border-color: var(--form-checkbox-radio-card-secondary-hover-border); + } + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + + &--current { + border-color: var(--form-datepicker-today-border); + } + + &--selected { + color: var(--form-checkbox-radio-card-secondary-selected-text); + background: var(--form-checkbox-radio-card-secondary-selected-background); + border-color: var(--form-checkbox-radio-card-secondary-selected-border); + box-shadow: 0 0 0 1px + var(--form-checkbox-radio-card-secondary-selected-border); + } + + &--disabled, + &[aria-disabled="true"] { + color: var(--form-checkbox-radio-card-secondary-disabled-default-text); + pointer-events: none; + cursor: not-allowed; + background: var( + --form-checkbox-radio-card-secondary-disabled-default-background + ); + border-color: var( + --form-checkbox-radio-card-secondary-disabled-default-border + ); + } + + &--selected#{&}--disabled { + color: var(--form-checkbox-radio-card-secondary-disabled-selected-text); + background: var( + --form-checkbox-radio-card-secondary-disabled-selected-background + ); + border-color: var( + --form-checkbox-radio-card-secondary-disabled-selected-border + ); + box-shadow: 0 0 0 1px + var(--form-checkbox-radio-card-secondary-disabled-selected-border); + } + } +} diff --git a/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.spec.ts b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.spec.ts new file mode 100644 index 000000000..f4bf160fd --- /dev/null +++ b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.spec.ts @@ -0,0 +1,223 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { CalendarMonthGridComponent } from "./calendar-month-grid.component"; +import { getMonthNames } from "../../../../utils/date.util"; + +describe("CalendarMonthGridComponent", () => { + let fixture: ComponentFixture; + let component: CalendarMonthGridComponent; + + const YEAR = 2024; + + function createComponent(): void { + TestBed.configureTestingModule({ + imports: [CalendarMonthGridComponent], + }); + fixture = TestBed.createComponent(CalendarMonthGridComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("year", YEAR); + fixture.detectChanges(); + } + + function buttons(): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-month-grid__month")) + .map((d) => d.nativeElement as HTMLButtonElement); + } + + beforeEach(() => { + createComponent(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("grid layout", () => { + it("renders 12 cells", () => { + const cells = fixture.debugElement.queryAll( + By.css(".tedi-calendar-month-grid__cell"), + ); + expect(cells.length).toBe(12); + expect(buttons().length).toBe(12); + }); + + it("renders the grid container with role=grid and a single role=row", () => { + const grid = fixture.debugElement.query( + By.css(".tedi-calendar-month-grid"), + ); + expect(grid.nativeElement.getAttribute("role")).toBe("grid"); + const rows = fixture.debugElement.queryAll( + By.css(".tedi-calendar-month-grid__row"), + ); + expect(rows.length).toBe(1); + expect(rows[0].nativeElement.getAttribute("role")).toBe("row"); + }); + }); + + describe("month name formatting", () => { + it("renders et-EE short names by default", () => { + const expected = getMonthNames("et-EE", "short"); + const labels = buttons().map((b) => (b.textContent ?? "").trim()); + expect(labels).toEqual(expected); + }); + + it("renders en-US long names when localeCode and monthNameFormat change", () => { + fixture.componentRef.setInput("localeCode", "en-US"); + fixture.componentRef.setInput("monthNameFormat", "long"); + fixture.detectChanges(); + + const expected = getMonthNames("en-US", "long"); + const labels = buttons().map((b) => (b.textContent ?? "").trim()); + expect(labels).toEqual(expected); + expect(labels[0]).toBe("January"); + expect(labels[11]).toBe("December"); + }); + }); + + describe("selectedMonth highlighting", () => { + it("highlights only the matching month with --selected", () => { + fixture.componentRef.setInput("selectedMonth", new Date(YEAR, 4, 15)); + fixture.detectChanges(); + + const all = buttons(); + const selected = all.filter((b) => + b.classList.contains("tedi-calendar-month-grid__month--selected"), + ); + expect(selected.length).toBe(1); + expect(selected[0]).toBe(all[4]); + expect(selected[0].getAttribute("aria-selected")).toBe("true"); + }); + + it("highlights nothing when selectedMonth is null", () => { + const selected = buttons().filter((b) => + b.classList.contains("tedi-calendar-month-grid__month--selected"), + ); + expect(selected.length).toBe(0); + }); + + it("does not highlight when selectedMonth is in a different year", () => { + fixture.componentRef.setInput("selectedMonth", new Date(2023, 4, 15)); + fixture.detectChanges(); + + const selected = buttons().filter((b) => + b.classList.contains("tedi-calendar-month-grid__month--selected"), + ); + expect(selected.length).toBe(0); + }); + }); + + describe("isMonthDisabled predicate", () => { + it("disables months matching the predicate", () => { + fixture.componentRef.setInput( + "isMonthDisabled", + (m: Date) => m.getMonth() === 0 || m.getMonth() === 1, + ); + fixture.detectChanges(); + + const all = buttons(); + expect(all[0].getAttribute("aria-disabled")).toBe("true"); + expect(all[0].classList).toContain( + "tedi-calendar-month-grid__month--disabled", + ); + expect(all[1].getAttribute("aria-disabled")).toBe("true"); + expect(all[2].getAttribute("aria-disabled")).toBeNull(); + }); + + it("does not emit on click of a disabled month", () => { + fixture.componentRef.setInput( + "isMonthDisabled", + (m: Date) => m.getMonth() === 3, + ); + fixture.detectChanges(); + + const emit = jest.spyOn(component.monthSelect, "emit"); + buttons()[3].click(); + expect(emit).not.toHaveBeenCalled(); + }); + }); + + describe("inputDisabled", () => { + it("disables every button when true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + + const all = buttons(); + expect(all.length).toBe(12); + for (const btn of all) { + expect(btn.getAttribute("aria-disabled")).toBe("true"); + expect(btn.classList).toContain( + "tedi-calendar-month-grid__month--disabled", + ); + } + }); + + it("does not emit on click when inputDisabled is true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + + const emit = jest.spyOn(component.monthSelect, "emit"); + buttons()[5].click(); + expect(emit).not.toHaveBeenCalled(); + }); + }); + + describe("monthSelect emission", () => { + it("emits startOfMonth Date for an enabled click", () => { + const emit = jest.spyOn(component.monthSelect, "emit"); + buttons()[6].click(); + + expect(emit).toHaveBeenCalledTimes(1); + const emitted = emit.mock.calls[0][0] as Date; + expect(emitted).toBeInstanceOf(Date); + expect(emitted.getFullYear()).toBe(YEAR); + expect(emitted.getMonth()).toBe(6); + expect(emitted.getDate()).toBe(1); + }); + + it("emits January (index 0) with the correct year", () => { + const emit = jest.spyOn(component.monthSelect, "emit"); + buttons()[0].click(); + + const emitted = emit.mock.calls[0][0] as Date; + expect(emitted.getFullYear()).toBe(YEAR); + expect(emitted.getMonth()).toBe(0); + expect(emitted.getDate()).toBe(1); + }); + + it("emits December (index 11) with the correct year", () => { + const emit = jest.spyOn(component.monthSelect, "emit"); + buttons()[11].click(); + + const emitted = emit.mock.calls[0][0] as Date; + expect(emitted.getFullYear()).toBe(YEAR); + expect(emitted.getMonth()).toBe(11); + expect(emitted.getDate()).toBe(1); + }); + }); + + describe("--current modifier", () => { + it("applies to today's month when year matches the current year", () => { + const now = new Date(); + fixture.componentRef.setInput("year", now.getFullYear()); + fixture.detectChanges(); + + const all = buttons(); + const current = all.filter((b) => + b.classList.contains("tedi-calendar-month-grid__month--current"), + ); + expect(current.length).toBe(1); + expect(current[0]).toBe(all[now.getMonth()]); + }); + + it("does not apply when year is different from the current year", () => { + fixture.componentRef.setInput("year", new Date().getFullYear() + 5); + fixture.detectChanges(); + + const current = buttons().filter((b) => + b.classList.contains("tedi-calendar-month-grid__month--current"), + ); + expect(current.length).toBe(0); + }); + }); +}); diff --git a/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.ts b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.ts new file mode 100644 index 000000000..4df64497e --- /dev/null +++ b/tedi/components/content/calendar/calendar-month-grid/calendar-month-grid.component.ts @@ -0,0 +1,60 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + ViewEncapsulation, +} from "@angular/core"; +import { getMonthNames, isSameMonth } from "../../../../utils/date.util"; + +type MonthPredicate = (month: Date) => boolean; + +@Component({ + selector: "tedi-calendar-month-grid", + standalone: true, + templateUrl: "./calendar-month-grid.component.html", + styleUrl: "./calendar-month-grid.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CalendarMonthGridComponent { + readonly year = input.required(); + readonly selectedMonth = input(null); + readonly localeCode = input("et-EE"); + readonly monthNameFormat = input<"long" | "short">("short"); + readonly isMonthDisabled = input(() => false); + readonly inputDisabled = input(false); + + readonly monthSelect = output(); + + readonly monthNames = computed(() => + getMonthNames(this.localeCode(), this.monthNameFormat()), + ); + + private readonly today = new Date(); + + monthDate(index: number): Date { + return new Date(this.year(), index, 1); + } + + isSelected(index: number): boolean { + const selected = this.selectedMonth(); + if (!selected) return false; + return isSameMonth(selected, this.monthDate(index)); + } + + isCurrent(index: number): boolean { + return isSameMonth(this.today, this.monthDate(index)); + } + + isDisabled(index: number): boolean { + if (this.inputDisabled()) return true; + return this.isMonthDisabled()(this.monthDate(index)); + } + + handleClick(index: number): void { + if (this.isDisabled(index)) return; + this.monthSelect.emit(this.monthDate(index)); + } +} diff --git a/tedi/components/content/calendar/calendar-month-grid/index.ts b/tedi/components/content/calendar/calendar-month-grid/index.ts new file mode 100644 index 000000000..a15f1d83a --- /dev/null +++ b/tedi/components/content/calendar/calendar-month-grid/index.ts @@ -0,0 +1 @@ +export { CalendarMonthGridComponent } from "./calendar-month-grid.component"; diff --git a/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.html b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.html new file mode 100644 index 000000000..aa7fc2933 --- /dev/null +++ b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.html @@ -0,0 +1,20 @@ +
    +
    + @for (entry of years(); track entry.year) { +
    + +
    + } +
    +
    diff --git a/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.scss b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.scss new file mode 100644 index 000000000..12b97070f --- /dev/null +++ b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.scss @@ -0,0 +1,77 @@ +.tedi-calendar-year-grid { + width: 100%; + + &__row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--layout-grid-gutters-08); + } + + &__cell { + display: flex; + } + + &__year { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + padding: var(--form-checkbox-radio-card-radio-padding-y) + var(--form-checkbox-radio-card-radio-padding-x); + font-size: var(--body-regular-size); + color: var(--form-checkbox-radio-card-primary-default-text); + cursor: pointer; + background: var(--form-checkbox-radio-card-secondary-default-background); + border: var(--tedi-borders-01) solid + var(--form-checkbox-radio-card-secondary-default-border); + border-radius: var(--form-checkbox-radio-card-radius); + + &:hover { + color: var(--form-checkbox-radio-card-secondary-hover-text); + background: var(--form-checkbox-radio-card-secondary-hover-background); + border-color: var(--form-checkbox-radio-card-secondary-hover-border); + } + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + + &--current { + border-color: var(--form-datepicker-today-border); + } + + &--selected { + color: var(--form-checkbox-radio-card-secondary-selected-text); + background: var(--form-checkbox-radio-card-secondary-selected-background); + border-color: var(--form-checkbox-radio-card-secondary-selected-border); + box-shadow: 0 0 0 1px + var(--form-checkbox-radio-card-secondary-selected-border); + } + + &--disabled, + &[aria-disabled="true"] { + color: var(--form-checkbox-radio-card-secondary-disabled-default-text); + pointer-events: none; + cursor: not-allowed; + background: var( + --form-checkbox-radio-card-secondary-disabled-default-background + ); + border-color: var( + --form-checkbox-radio-card-secondary-disabled-default-border + ); + } + + &--selected#{&}--disabled { + color: var(--form-checkbox-radio-card-secondary-disabled-selected-text); + background: var( + --form-checkbox-radio-card-secondary-disabled-selected-background + ); + border-color: var( + --form-checkbox-radio-card-secondary-disabled-selected-border + ); + box-shadow: 0 0 0 1px + var(--form-checkbox-radio-card-secondary-disabled-selected-border); + } + } +} diff --git a/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.spec.ts b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.spec.ts new file mode 100644 index 000000000..8e10d8304 --- /dev/null +++ b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.spec.ts @@ -0,0 +1,263 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { CalendarYearGridComponent } from "./calendar-year-grid.component"; + +describe("CalendarYearGridComponent", () => { + let fixture: ComponentFixture; + let component: CalendarYearGridComponent; + + const PAGE_START = 2020; + + function createComponent(): void { + TestBed.configureTestingModule({ + imports: [CalendarYearGridComponent], + }); + fixture = TestBed.createComponent(CalendarYearGridComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("pageStart", PAGE_START); + fixture.detectChanges(); + } + + function buttons(): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-year-grid__year")) + .map((d) => d.nativeElement as HTMLButtonElement); + } + + beforeEach(() => { + createComponent(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("grid layout", () => { + it("renders exactly pageSize cells (default 12)", () => { + const cells = fixture.debugElement.queryAll( + By.css(".tedi-calendar-year-grid__cell"), + ); + expect(cells.length).toBe(12); + expect(buttons().length).toBe(12); + }); + + it("renders the grid container with role=grid and a single role=row", () => { + const grid = fixture.debugElement.query( + By.css(".tedi-calendar-year-grid"), + ); + expect(grid.nativeElement.getAttribute("role")).toBe("grid"); + const rows = fixture.debugElement.queryAll( + By.css(".tedi-calendar-year-grid__row"), + ); + expect(rows.length).toBe(1); + expect(rows[0].nativeElement.getAttribute("role")).toBe("row"); + }); + + it("renders years sequentially starting at pageStart", () => { + const labels = buttons().map((b) => (b.textContent ?? "").trim()); + expect(labels).toEqual([ + "2020", + "2021", + "2022", + "2023", + "2024", + "2025", + "2026", + "2027", + "2028", + "2029", + "2030", + "2031", + ]); + }); + + it("renders custom pageSize cells", () => { + fixture.componentRef.setInput("pageSize", 16); + fixture.detectChanges(); + + const all = buttons(); + expect(all.length).toBe(16); + const labels = all.map((b) => (b.textContent ?? "").trim()); + expect(labels[0]).toBe(String(PAGE_START)); + expect(labels[15]).toBe(String(PAGE_START + 15)); + }); + }); + + describe("selectedYear highlighting", () => { + it("highlights only the matching year with --selected", () => { + fixture.componentRef.setInput( + "selectedYear", + new Date(PAGE_START + 3, 5, 15), + ); + fixture.detectChanges(); + + const all = buttons(); + const selected = all.filter((b) => + b.classList.contains("tedi-calendar-year-grid__year--selected"), + ); + expect(selected.length).toBe(1); + expect(selected[0]).toBe(all[3]); + expect(selected[0].getAttribute("aria-selected")).toBe("true"); + }); + + it("highlights nothing when selectedYear is null", () => { + const selected = buttons().filter((b) => + b.classList.contains("tedi-calendar-year-grid__year--selected"), + ); + expect(selected.length).toBe(0); + }); + + it("highlights nothing when selectedYear is outside the page range", () => { + fixture.componentRef.setInput("selectedYear", new Date(1999, 0, 1)); + fixture.detectChanges(); + + const selected = buttons().filter((b) => + b.classList.contains("tedi-calendar-year-grid__year--selected"), + ); + expect(selected.length).toBe(0); + }); + }); + + describe("isYearDisabled predicate", () => { + it("disables years matching the predicate", () => { + fixture.componentRef.setInput( + "isYearDisabled", + (y: Date) => y.getFullYear() === PAGE_START || y.getFullYear() === PAGE_START + 1, + ); + fixture.detectChanges(); + + const all = buttons(); + expect(all[0].getAttribute("aria-disabled")).toBe("true"); + expect(all[0].classList).toContain( + "tedi-calendar-year-grid__year--disabled", + ); + expect(all[1].getAttribute("aria-disabled")).toBe("true"); + expect(all[2].getAttribute("aria-disabled")).toBeNull(); + }); + + it("does not emit on click of a disabled year", () => { + fixture.componentRef.setInput( + "isYearDisabled", + (y: Date) => y.getFullYear() === PAGE_START + 3, + ); + fixture.detectChanges(); + + const emit = jest.spyOn(component.yearSelect, "emit"); + buttons()[3].click(); + expect(emit).not.toHaveBeenCalled(); + }); + }); + + describe("inputDisabled", () => { + it("disables every button when true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + + const all = buttons(); + expect(all.length).toBe(12); + for (const btn of all) { + expect(btn.getAttribute("aria-disabled")).toBe("true"); + expect(btn.classList).toContain( + "tedi-calendar-year-grid__year--disabled", + ); + } + }); + + it("does not emit on click when inputDisabled is true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + + const emit = jest.spyOn(component.yearSelect, "emit"); + buttons()[5].click(); + expect(emit).not.toHaveBeenCalled(); + }); + }); + + describe("yearSelect emission", () => { + it("emits January 1st Date for an enabled click", () => { + const emit = jest.spyOn(component.yearSelect, "emit"); + buttons()[6].click(); + + expect(emit).toHaveBeenCalledTimes(1); + const emitted = emit.mock.calls[0][0] as Date; + expect(emitted).toBeInstanceOf(Date); + expect(emitted.getFullYear()).toBe(PAGE_START + 6); + expect(emitted.getMonth()).toBe(0); + expect(emitted.getDate()).toBe(1); + }); + + it("emits the first year (pageStart) correctly", () => { + const emit = jest.spyOn(component.yearSelect, "emit"); + buttons()[0].click(); + + const emitted = emit.mock.calls[0][0] as Date; + expect(emitted.getFullYear()).toBe(PAGE_START); + expect(emitted.getMonth()).toBe(0); + expect(emitted.getDate()).toBe(1); + }); + + it("emits the last year on the page correctly", () => { + const emit = jest.spyOn(component.yearSelect, "emit"); + buttons()[11].click(); + + const emitted = emit.mock.calls[0][0] as Date; + expect(emitted.getFullYear()).toBe(PAGE_START + 11); + expect(emitted.getMonth()).toBe(0); + expect(emitted.getDate()).toBe(1); + }); + }); + + describe("--current modifier", () => { + it("applies to today's year when within the page range", () => { + const now = new Date(); + fixture.componentRef.setInput("pageStart", now.getFullYear() - 2); + fixture.detectChanges(); + + const all = buttons(); + const current = all.filter((b) => + b.classList.contains("tedi-calendar-year-grid__year--current"), + ); + expect(current.length).toBe(1); + expect(current[0]).toBe(all[2]); + }); + + it("does not apply when current year is outside the page range", () => { + fixture.componentRef.setInput("pageStart", 1900); + fixture.detectChanges(); + + const current = buttons().filter((b) => + b.classList.contains("tedi-calendar-year-grid__year--current"), + ); + expect(current.length).toBe(0); + }); + }); + + describe("aria attributes use null when inactive", () => { + it("omits aria-selected attribute on non-selected cells", () => { + fixture.componentRef.setInput( + "selectedYear", + new Date(PAGE_START + 2, 0, 1), + ); + fixture.detectChanges(); + + const all = buttons(); + expect(all[2].getAttribute("aria-selected")).toBe("true"); + expect(all[0].hasAttribute("aria-selected")).toBe(false); + expect(all[1].hasAttribute("aria-selected")).toBe(false); + expect(all[5].hasAttribute("aria-selected")).toBe(false); + }); + + it("omits aria-disabled attribute on non-disabled cells", () => { + fixture.componentRef.setInput( + "isYearDisabled", + (y: Date) => y.getFullYear() === PAGE_START + 1, + ); + fixture.detectChanges(); + + const all = buttons(); + expect(all[1].getAttribute("aria-disabled")).toBe("true"); + expect(all[0].hasAttribute("aria-disabled")).toBe(false); + expect(all[2].hasAttribute("aria-disabled")).toBe(false); + }); + }); +}); diff --git a/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.ts b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.ts new file mode 100644 index 000000000..e9ec0fa43 --- /dev/null +++ b/tedi/components/content/calendar/calendar-year-grid/calendar-year-grid.component.ts @@ -0,0 +1,68 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + ViewEncapsulation, +} from "@angular/core"; +import { isSameYear } from "../../../../utils/date.util"; + +type YearPredicate = (year: Date) => boolean; + +type YearEntry = { year: number; yearDate: Date }; + +@Component({ + selector: "tedi-calendar-year-grid", + standalone: true, + templateUrl: "./calendar-year-grid.component.html", + styleUrl: "./calendar-year-grid.component.scss", + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CalendarYearGridComponent { + readonly pageStart = input.required(); + readonly pageSize = input(12); + readonly selectedYear = input(null); + readonly isYearDisabled = input(() => false); + readonly inputDisabled = input(false); + + readonly yearSelect = output(); + + readonly years = computed(() => { + const start = this.pageStart(); + const size = this.pageSize(); + const entries: YearEntry[] = []; + for (let i = 0; i < size; i++) { + const year = start + i; + entries.push({ year, yearDate: new Date(year, 0, 1) }); + } + return entries; + }); + + private readonly today = new Date(); + + private yearDate(year: number): Date { + return new Date(year, 0, 1); + } + + isSelected(year: number): boolean { + const selected = this.selectedYear(); + if (!selected) return false; + return isSameYear(selected, this.yearDate(year)); + } + + isCurrent(year: number): boolean { + return isSameYear(this.today, this.yearDate(year)); + } + + isDisabled(year: number): boolean { + if (this.inputDisabled()) return true; + return this.isYearDisabled()(this.yearDate(year)); + } + + handleClick(year: number): void { + if (this.isDisabled(year)) return; + this.yearSelect.emit(this.yearDate(year)); + } +} diff --git a/tedi/components/content/calendar/calendar-year-grid/index.ts b/tedi/components/content/calendar/calendar-year-grid/index.ts new file mode 100644 index 000000000..552734039 --- /dev/null +++ b/tedi/components/content/calendar/calendar-year-grid/index.ts @@ -0,0 +1 @@ +export { CalendarYearGridComponent } from "./calendar-year-grid.component"; diff --git a/tedi/components/content/calendar/calendar.component.html b/tedi/components/content/calendar/calendar.component.html new file mode 100644 index 000000000..41e36a060 --- /dev/null +++ b/tedi/components/content/calendar/calendar.component.html @@ -0,0 +1,114 @@ +@switch (view()) { + @case ("days") { +
    + @for (monthDate of monthDates(); track $index; let i = $index) { +
    + + +
    + } +
    + } + @case ("months") { +
    + + +
    + } + @case ("years") { +
    + + +
    + } +} + + diff --git a/tedi/components/content/calendar/calendar.component.scss b/tedi/components/content/calendar/calendar.component.scss new file mode 100644 index 000000000..6cc3e4d6c --- /dev/null +++ b/tedi/components/content/calendar/calendar.component.scss @@ -0,0 +1,39 @@ +.tedi-calendar { + display: flex; + flex-direction: column; + width: fit-content; + min-width: var(--tedi-containers-03); + + &__months { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: center; + } + + &__month { + display: flex; + flex-direction: column; + padding: var(--card-padding-md-default); + } + + &__footer { + padding-top: var(--layout-grid-gutters-08); + margin: 0 var(--card-padding-md-default) var(--card-padding-xs); + border-top: var(--tedi-borders-01) solid var(--general-border-primary); + + &:empty { + display: none; + } + } + + &--bordered { + border: var(--tedi-borders-01) solid var(--card-border-primary); + border-radius: var(--card-radius-rounded); + } + + &--disabled { + pointer-events: none; + opacity: 0.6; + } +} diff --git a/tedi/components/content/calendar/calendar.component.spec.ts b/tedi/components/content/calendar/calendar.component.spec.ts new file mode 100644 index 000000000..3b28acfb1 --- /dev/null +++ b/tedi/components/content/calendar/calendar.component.spec.ts @@ -0,0 +1,1018 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { CalendarComponent } from "./calendar.component"; +import { DateRange } from "../../../utils/date.util"; +import { Matcher } from "../../../utils/matchers.util"; +import { TediTranslationService } from "../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../tokens/translation.token"; + +class TranslationMock { + translate(key: string): string { + return key; + } + track(key: string) { + return () => key; + } +} + +const MAY_15_2024 = new Date(2024, 4, 15); + +@Component({ + standalone: true, + imports: [CalendarComponent, ReactiveFormsModule], + template: ``, +}) +class FormHostComponent { + control = new FormControl(null); +} + +@Component({ + standalone: true, + imports: [CalendarComponent], + template: ` + + + + `, +}) +class FooterHostComponent {} + +function configureBaseModule(): void { + TestBed.configureTestingModule({ + imports: [CalendarComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); +} + +function createComponent(): ComponentFixture { + configureBaseModule(); + const fixture = TestBed.createComponent(CalendarComponent); + fixture.componentRef.setInput("currentMonth", MAY_15_2024); + fixture.detectChanges(); + return fixture; +} + +function dayButtons( + fixture: ComponentFixture, +): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-day-grid__day")) + .map((d) => d.nativeElement as HTMLButtonElement); +} + +function dayButtonForDate( + fixture: ComponentFixture, + date: Date, +): HTMLButtonElement | null { + const key = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ).getTime(); + return (fixture.debugElement.nativeElement as HTMLElement).querySelector( + `[data-date-key="${key}"]`, + ); +} + +function monthButtons( + fixture: ComponentFixture, +): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-month-grid__month")) + .map((d) => d.nativeElement as HTMLButtonElement); +} + +function yearButtons( + fixture: ComponentFixture, +): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-year-grid__year")) + .map((d) => d.nativeElement as HTMLButtonElement); +} + +function navButtons( + fixture: ComponentFixture, +): HTMLButtonElement[] { + return fixture.debugElement + .queryAll(By.css(".tedi-calendar-header__nav-button")) + .map((d) => d.nativeElement as HTMLButtonElement); +} + +describe("CalendarComponent", () => { + describe("default rendering", () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = createComponent(); + }); + + it("creates the component", () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it("renders the day grid by default", () => { + const grid = fixture.debugElement.query( + By.css(".tedi-calendar-day-grid"), + ); + expect(grid).toBeTruthy(); + }); + + it("renders a single month by default", () => { + const grids = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid"), + ); + expect(grids.length).toBe(1); + }); + + it("renders the header", () => { + const header = fixture.debugElement.query( + By.css(".tedi-calendar-header"), + ); + expect(header).toBeTruthy(); + }); + }); + + describe("writeValue", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + }); + + it("syncs value AND currentMonth when given a Date", () => { + const target = new Date(2025, 6, 20); + component.writeValue(target); + fixture.detectChanges(); + expect(component.value()).toEqual(target); + expect(component.currentMonth().getFullYear()).toBe(2025); + expect(component.currentMonth().getMonth()).toBe(6); + expect(component.currentMonth().getDate()).toBe(1); + }); + + it("clears value when given null", () => { + component.writeValue(new Date(2024, 4, 1)); + component.writeValue(null); + expect(component.value()).toBeNull(); + }); + + it("syncs to first entry for multiple mode", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.detectChanges(); + const a = new Date(2025, 2, 5); + const b = new Date(2026, 8, 10); + component.writeValue([a, b]); + expect(component.currentMonth().getFullYear()).toBe(2025); + expect(component.currentMonth().getMonth()).toBe(2); + }); + + it("syncs to range.from for range mode", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.detectChanges(); + const range: DateRange = { + from: new Date(2027, 1, 10), + to: new Date(2027, 1, 20), + }; + component.writeValue(range); + expect(component.currentMonth().getFullYear()).toBe(2027); + expect(component.currentMonth().getMonth()).toBe(1); + }); + }); + + describe("header navigation", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + }); + + it("prev button moves currentMonth back by one month in days view", () => { + navButtons(fixture)[0].click(); + fixture.detectChanges(); + expect(component.currentMonth().getMonth()).toBe(3); + }); + + it("next button moves currentMonth forward by one month in days view", () => { + navButtons(fixture)[1].click(); + fixture.detectChanges(); + expect(component.currentMonth().getMonth()).toBe(5); + }); + + it("prev button moves by one year in months view", () => { + component.view.set("months"); + fixture.detectChanges(); + navButtons(fixture)[0].click(); + fixture.detectChanges(); + expect(component.currentMonth().getFullYear()).toBe(2023); + }); + + it("next button advances yearPageStart in years view", () => { + component.view.set("years"); + fixture.detectChanges(); + const initial = component.yearPageStart(); + navButtons(fixture)[1].click(); + fixture.detectChanges(); + expect(component.yearPageStart()).toBe(initial + 12); + }); + }); + + describe("view propagation", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + }); + + it("header viewChange propagates to view()", () => { + component.handleHeaderViewChange("months"); + fixture.detectChanges(); + expect(component.view()).toBe("months"); + const monthGrid = fixture.debugElement.query( + By.css(".tedi-calendar-month-grid"), + ); + expect(monthGrid).toBeTruthy(); + }); + + it("switching to years recomputes the year page bracket", () => { + fixture.componentRef.setInput("currentMonth", new Date(2030, 0, 1)); + fixture.detectChanges(); + component.handleHeaderViewChange("years"); + fixture.detectChanges(); + expect(component.yearPageStart()).toBe(2025); + }); + }); + + describe("header month/year change", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + }); + + it("updates currentMonth when header emits monthChange", () => { + component.handleHeaderMonthChange(new Date(2024, 9, 1)); + fixture.detectChanges(); + expect(component.currentMonth().getMonth()).toBe(9); + expect(component.currentMonth().getFullYear()).toBe(2024); + }); + + it("updates currentMonth year when header emits yearChange", () => { + component.handleHeaderYearChange(new Date(2030, 0, 1)); + fixture.detectChanges(); + expect(component.currentMonth().getFullYear()).toBe(2030); + expect(component.currentMonth().getMonth()).toBe(4); + }); + }); + + describe("single mode selection", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + }); + + it("emits select and updates value when a day is clicked", () => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + const emit = jest.spyOn(component.select, "emit"); + + const target = new Date(2024, 4, 10); + const btn = dayButtonForDate(fixture, target); + btn?.click(); + fixture.detectChanges(); + + expect(emit).toHaveBeenCalledTimes(1); + const payload = emit.mock.calls[0][0] as { + date: Date; + day: Date; + }; + expect((payload.date as Date).getTime()).toBe(target.getTime()); + expect(payload.day.getTime()).toBe(target.getTime()); + expect((component.value() as Date).getTime()).toBe(target.getTime()); + expect(onChange).toHaveBeenCalledWith(target); + }); + }); + + describe("multiple mode selection", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + fixture.componentRef.setInput("mode", "multiple"); + fixture.detectChanges(); + }); + + it("toggles a date in the array", () => { + const a = new Date(2024, 4, 10); + dayButtonForDate(fixture, a)?.click(); + fixture.detectChanges(); + expect(component.value()).toEqual([a]); + + const b = new Date(2024, 4, 11); + dayButtonForDate(fixture, b)?.click(); + fixture.detectChanges(); + expect((component.value() as Date[]).length).toBe(2); + + dayButtonForDate(fixture, a)?.click(); + fixture.detectChanges(); + expect((component.value() as Date[]).length).toBe(1); + expect(((component.value() as Date[])[0]).getTime()).toBe(b.getTime()); + }); + + it("prevents clearing the last entry when required=true", () => { + fixture.componentRef.setInput("required", true); + fixture.detectChanges(); + + const a = new Date(2024, 4, 10); + dayButtonForDate(fixture, a)?.click(); + fixture.detectChanges(); + const before = component.value(); + + dayButtonForDate(fixture, a)?.click(); + fixture.detectChanges(); + + expect(component.value()).toEqual(before); + }); + }); + + describe("range mode selection", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + fixture.componentRef.setInput("mode", "range"); + fixture.detectChanges(); + }); + + it("first click sets {from}", () => { + const day = new Date(2024, 4, 10); + dayButtonForDate(fixture, day)?.click(); + fixture.detectChanges(); + expect(component.value()).toEqual({ from: day }); + }); + + it("second click after first sets {from, to}", () => { + const a = new Date(2024, 4, 10); + const b = new Date(2024, 4, 20); + dayButtonForDate(fixture, a)?.click(); + fixture.detectChanges(); + dayButtonForDate(fixture, b)?.click(); + fixture.detectChanges(); + const v = component.value() as DateRange; + expect(v.from.getTime()).toBe(a.getTime()); + expect(v.to?.getTime()).toBe(b.getTime()); + }); + + it("swaps from/to when second click is before first", () => { + const a = new Date(2024, 4, 20); + const b = new Date(2024, 4, 10); + dayButtonForDate(fixture, a)?.click(); + fixture.detectChanges(); + dayButtonForDate(fixture, b)?.click(); + fixture.detectChanges(); + const v = component.value() as DateRange; + expect(v.from.getTime()).toBe(b.getTime()); + expect(v.to?.getTime()).toBe(a.getTime()); + }); + + it("third click starts a new range", () => { + const a = new Date(2024, 4, 10); + const b = new Date(2024, 4, 20); + const c = new Date(2024, 4, 25); + dayButtonForDate(fixture, a)?.click(); + fixture.detectChanges(); + dayButtonForDate(fixture, b)?.click(); + fixture.detectChanges(); + dayButtonForDate(fixture, c)?.click(); + fixture.detectChanges(); + const v = component.value() as DateRange; + expect(v.from.getTime()).toBe(c.getTime()); + expect(v.to).toBeUndefined(); + }); + }); + + describe("selectionLevel='months'", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + fixture.componentRef.setInput("selectionLevel", "months"); + fixture.detectChanges(); + }); + + it("opens on the month grid when selectionLevel='months'", () => { + expect(component.view()).toBe("months"); + const monthGrid = fixture.debugElement.query( + By.css(".tedi-calendar-month-grid"), + ); + expect(monthGrid).toBeTruthy(); + const dayGrid = fixture.debugElement.query( + By.css(".tedi-calendar-day-grid"), + ); + expect(dayGrid).toBeNull(); + }); + + it("month click commits the value (single)", () => { + const emit = jest.spyOn(component.select, "emit"); + monthButtons(fixture)[0].click(); + fixture.detectChanges(); + expect(emit).toHaveBeenCalledTimes(1); + const v = component.value() as Date; + expect(v.getFullYear()).toBe(2024); + expect(v.getMonth()).toBe(0); + }); + }); + + describe("selectionLevel='years'", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + fixture.componentRef.setInput("selectionLevel", "years"); + fixture.detectChanges(); + }); + + it("opens on the year grid when selectionLevel='years'", () => { + expect(component.view()).toBe("years"); + const yearGrid = fixture.debugElement.query( + By.css(".tedi-calendar-year-grid"), + ); + expect(yearGrid).toBeTruthy(); + const dayGrid = fixture.debugElement.query( + By.css(".tedi-calendar-day-grid"), + ); + expect(dayGrid).toBeNull(); + }); + + it("year click commits new Date(year, 0, 1)", () => { + const buttons = yearButtons(fixture); + const targetButton = buttons.find( + (b) => b.textContent?.trim() === "2024", + ); + expect(targetButton).toBeTruthy(); + targetButton!.click(); + fixture.detectChanges(); + const v = component.value() as Date; + expect(v.getFullYear()).toBe(2024); + expect(v.getMonth()).toBe(0); + expect(v.getDate()).toBe(1); + }); + }); + + describe("drill-down navigation", () => { + let fixture: ComponentFixture; + let component: CalendarComponent; + + beforeEach(() => { + fixture = createComponent(); + component = fixture.componentInstance; + }); + + it("month click in view='months', selectionLevel='days' drills to days", () => { + component.view.set("months"); + fixture.detectChanges(); + monthButtons(fixture)[7].click(); + fixture.detectChanges(); + expect(component.view()).toBe("days"); + expect(component.currentMonth().getMonth()).toBe(7); + expect(component.value()).toBeNull(); + }); + + it("year click in view='years', selectionLevel='days' drills back to days", () => { + component.view.set("years"); + fixture.detectChanges(); + const button = yearButtons(fixture).find( + (b) => b.textContent?.trim() === "2025", + ); + button!.click(); + fixture.detectChanges(); + expect(component.view()).toBe("days"); + expect(component.currentMonth().getFullYear()).toBe(2025); + expect(component.value()).toBeNull(); + }); + + it("year click in view='years', selectionLevel='months' drills to months", () => { + fixture.componentRef.setInput("selectionLevel", "months"); + fixture.detectChanges(); + component.view.set("years"); + fixture.detectChanges(); + const button = yearButtons(fixture).find( + (b) => b.textContent?.trim() === "2026", + ); + button!.click(); + fixture.detectChanges(); + expect(component.view()).toBe("months"); + expect(component.currentMonth().getFullYear()).toBe(2026); + }); + }); + + describe("disabled propagation", () => { + it("inputDisabled disables every day cell", () => { + const fixture = createComponent(); + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const days = dayButtons(fixture); + expect(days.length).toBeGreaterThan(0); + expect(days.every((b) => b.getAttribute("aria-disabled") === "true")).toBe(true); + }); + + it("inputDisabled disables every month cell", () => { + const fixture = createComponent(); + fixture.componentInstance.view.set("months"); + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const buttons = monthButtons(fixture); + expect(buttons.length).toBe(12); + expect(buttons.every((b) => b.getAttribute("aria-disabled") === "true")).toBe(true); + }); + + it("inputDisabled disables every year cell", () => { + const fixture = createComponent(); + fixture.componentInstance.view.set("years"); + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const buttons = yearButtons(fixture); + expect(buttons.every((b) => b.getAttribute("aria-disabled") === "true")).toBe(true); + }); + }); + + describe("effective month/year disabled predicates", () => { + it("month is fully disabled when all days are matched", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + const matchers: Matcher[] = [ + { from: new Date(2024, 4, 1), to: new Date(2024, 4, 31) }, + ]; + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + const pred = component.effectiveIsMonthDisabled(); + expect(pred(new Date(2024, 4, 1))).toBe(true); + expect(pred(new Date(2024, 5, 1))).toBe(false); + }); + + it("year is fully disabled when every month is fully disabled", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + const matchers: Matcher[] = [ + { from: new Date(2024, 0, 1), to: new Date(2024, 11, 31) }, + ]; + fixture.componentRef.setInput("disabledMatchers", matchers); + fixture.detectChanges(); + const pred = component.effectiveIsYearDisabled(); + expect(pred(new Date(2024, 0, 1))).toBe(true); + expect(pred(new Date(2025, 0, 1))).toBe(false); + }); + + it("custom shouldDisableMonth predicate is honored", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + fixture.componentRef.setInput( + "shouldDisableMonth", + (m: Date) => m.getMonth() === 0, + ); + fixture.detectChanges(); + const pred = component.effectiveIsMonthDisabled(); + expect(pred(new Date(2024, 0, 1))).toBe(true); + expect(pred(new Date(2024, 4, 1))).toBe(false); + }); + }); + + describe("footer content projection", () => { + it("renders projected footer content", () => { + TestBed.configureTestingModule({ + imports: [FooterHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + const fixture = TestBed.createComponent(FooterHostComponent); + fixture.detectChanges(); + const footer = fixture.debugElement.query(By.css(".footer-btn")); + expect(footer).toBeTruthy(); + expect(footer.nativeElement.textContent.trim()).toBe("Done"); + }); + }); + + describe("ControlValueAccessor + reactive forms", () => { + it("writes a value through the FormControl and selecting a date updates the control", () => { + TestBed.configureTestingModule({ + imports: [FormHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + const fixture = TestBed.createComponent(FormHostComponent); + const host = fixture.componentInstance; + fixture.detectChanges(); + + host.control.setValue(MAY_15_2024); + fixture.detectChanges(); + const selectedCell = fixture.debugElement.query( + By.css(".tedi-calendar-day-grid__day--selected"), + ); + expect(selectedCell).toBeTruthy(); + + const target = new Date(2024, 4, 22); + dayButtonForDate(fixture, target)?.click(); + fixture.detectChanges(); + + expect((host.control.value as Date).getTime()).toBe(target.getTime()); + }); + + it("setDisabledState disables every cell", () => { + TestBed.configureTestingModule({ + imports: [FormHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + const fixture = TestBed.createComponent(FormHostComponent); + fixture.detectChanges(); + fixture.componentInstance.control.disable(); + fixture.detectChanges(); + const buttons = dayButtons(fixture); + expect(buttons.every((b) => b.getAttribute("aria-disabled") === "true")).toBe(true); + }); + }); + + describe("keyboard navigation", () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = createComponent(); + }); + + it("ArrowLeft on a focused day focuses the previous day", () => { + const start = new Date(2024, 4, 10); + const startBtn = dayButtonForDate(fixture, start); + startBtn?.focus(); + const event = new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + cancelable: true, + }); + startBtn?.dispatchEvent(event); + fixture.detectChanges(); + const prev = new Date(2024, 4, 9); + // Microtask-based focus lookup is synchronous in spec for our purposes. + return Promise.resolve().then(() => { + expect(document.activeElement).toBe(dayButtonForDate(fixture, prev)); + }); + }); + + it("ArrowLeft preventDefault is called for known key", () => { + const start = new Date(2024, 4, 10); + const btn = dayButtonForDate(fixture, start); + btn?.focus(); + const event = new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + cancelable: true, + }); + const prevented = !btn?.dispatchEvent(event); + expect(prevented).toBe(true); + }); + + it("Enter on a focused day commits selection (native button behavior)", () => { + const component = fixture.componentInstance; + const target = new Date(2024, 4, 7); + const btn = dayButtonForDate(fixture, target); + btn?.click(); + fixture.detectChanges(); + expect((component.value() as Date).getTime()).toBe(target.getTime()); + }); + + it("ArrowRight crossing into next month advances currentMonth", () => { + const component = fixture.componentInstance; + const lastDay = new Date(2024, 4, 31); + const btn = dayButtonForDate(fixture, lastDay); + btn?.focus(); + const event = new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + }); + btn?.dispatchEvent(event); + fixture.detectChanges(); + expect(component.currentMonth().getMonth()).toBe(5); + }); + + it("ArrowRight in months view focuses the next month", () => { + fixture.componentInstance.view.set("months"); + fixture.detectChanges(); + const buttons = monthButtons(fixture); + buttons[0].focus(); + const event = new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + }); + buttons[0].dispatchEvent(event); + fixture.detectChanges(); + expect(document.activeElement).toBe(buttons[1]); + }); + + it("ArrowDown in months view focuses three rows down", () => { + fixture.componentInstance.view.set("months"); + fixture.detectChanges(); + const buttons = monthButtons(fixture); + buttons[0].focus(); + const event = new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }); + buttons[0].dispatchEvent(event); + fixture.detectChanges(); + expect(document.activeElement).toBe(buttons[3]); + }); + + it("ArrowRight in years view focuses the next year", () => { + fixture.componentInstance.view.set("years"); + fixture.detectChanges(); + const buttons = yearButtons(fixture); + buttons[0].focus(); + const event = new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + }); + buttons[0].dispatchEvent(event); + fixture.detectChanges(); + expect(document.activeElement).toBe(buttons[1]); + }); + }); + + describe("multi-month", () => { + it("renders N day grids when numberOfMonths > 1", () => { + const fixture = createComponent(); + fixture.componentRef.setInput("numberOfMonths", 2); + fixture.detectChanges(); + const grids = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid"), + ); + expect(grids.length).toBe(2); + }); + }); + + describe("more keyboard navigation", () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = createComponent(); + }); + + function dispatchKey( + el: HTMLElement | null, + key: string, + shiftKey = false, + ): boolean { + if (!el) throw new Error("element missing"); + el.focus(); + const event = new KeyboardEvent("keydown", { + key, + shiftKey, + bubbles: true, + cancelable: true, + }); + return el.dispatchEvent(event); + } + + it("ArrowUp moves focus up one week", () => { + const start = new Date(2024, 4, 15); + const ok = !dispatchKey(dayButtonForDate(fixture, start), "ArrowUp"); + expect(ok).toBe(true); + }); + + it("ArrowDown moves focus down one week", () => { + const start = new Date(2024, 4, 15); + const ok = !dispatchKey(dayButtonForDate(fixture, start), "ArrowDown"); + expect(ok).toBe(true); + }); + + it("Home key navigates to start of week", () => { + const start = new Date(2024, 4, 15); + const ok = !dispatchKey(dayButtonForDate(fixture, start), "Home"); + expect(ok).toBe(true); + }); + + it("End key navigates to end of week", () => { + const start = new Date(2024, 4, 15); + const ok = !dispatchKey(dayButtonForDate(fixture, start), "End"); + expect(ok).toBe(true); + }); + + it("PageUp moves to previous month", () => { + const component = fixture.componentInstance; + const start = new Date(2024, 4, 15); + dispatchKey(dayButtonForDate(fixture, start), "PageUp"); + fixture.detectChanges(); + expect(component.currentMonth().getMonth()).toBe(3); + }); + + it("PageDown moves to next month", () => { + const component = fixture.componentInstance; + const start = new Date(2024, 4, 15); + dispatchKey(dayButtonForDate(fixture, start), "PageDown"); + fixture.detectChanges(); + expect(component.currentMonth().getMonth()).toBe(5); + }); + + it("Shift+PageUp moves to previous year", () => { + const component = fixture.componentInstance; + const start = new Date(2024, 4, 15); + dispatchKey(dayButtonForDate(fixture, start), "PageUp", true); + fixture.detectChanges(); + expect(component.currentMonth().getFullYear()).toBe(2023); + }); + + it("Shift+PageDown moves to next year", () => { + const component = fixture.componentInstance; + const start = new Date(2024, 4, 15); + dispatchKey(dayButtonForDate(fixture, start), "PageDown", true); + fixture.detectChanges(); + expect(component.currentMonth().getFullYear()).toBe(2025); + }); + + it("unrecognized key in days view is a no-op", () => { + const start = new Date(2024, 4, 15); + const propagated = dispatchKey( + dayButtonForDate(fixture, start), + "Tab", + ); + expect(propagated).toBe(true); + }); + + it("ignores keydown when effectiveDisabled is true", () => { + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const start = new Date(2024, 4, 15); + const btn = dayButtonForDate(fixture, start); + const propagated = dispatchKey(btn, "ArrowLeft"); + expect(propagated).toBe(true); + }); + + it("months view: ArrowLeft at index 0 prevents default but does not focus", () => { + fixture.componentInstance.view.set("months"); + fixture.detectChanges(); + const buttons = monthButtons(fixture); + const propagated = dispatchKey(buttons[0], "ArrowLeft"); + expect(propagated).toBe(false); + }); + + it("years view: PageUp pages back", () => { + const component = fixture.componentInstance; + component.view.set("years"); + fixture.detectChanges(); + const initial = component.yearPageStart(); + const buttons = yearButtons(fixture); + dispatchKey(buttons[0], "PageUp"); + fixture.detectChanges(); + expect(component.yearPageStart()).toBe(initial - 12); + }); + + it("years view: PageDown pages forward", () => { + const component = fixture.componentInstance; + component.view.set("years"); + fixture.detectChanges(); + const initial = component.yearPageStart(); + const buttons = yearButtons(fixture); + dispatchKey(buttons[0], "PageDown"); + fixture.detectChanges(); + expect(component.yearPageStart()).toBe(initial + 12); + }); + + it("years view: ArrowUp moves up three rows", () => { + fixture.componentInstance.view.set("years"); + fixture.detectChanges(); + const buttons = yearButtons(fixture); + dispatchKey(buttons[5], "ArrowUp"); + expect(document.activeElement).toBe(buttons[2]); + }); + }); + + describe("multi-mode month/year selection at corresponding selectionLevel", () => { + it("selectionLevel='months', mode='multiple' toggles months", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + fixture.componentRef.setInput("selectionLevel", "months"); + fixture.componentRef.setInput("mode", "multiple"); + fixture.detectChanges(); + + monthButtons(fixture)[0].click(); + fixture.detectChanges(); + monthButtons(fixture)[1].click(); + fixture.detectChanges(); + expect((component.value() as Date[]).length).toBe(2); + + monthButtons(fixture)[0].click(); + fixture.detectChanges(); + expect((component.value() as Date[]).length).toBe(1); + }); + + it("selectionLevel='months', mode='range' produces a 2-month range", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + fixture.componentRef.setInput("selectionLevel", "months"); + fixture.componentRef.setInput("mode", "range"); + fixture.detectChanges(); + + monthButtons(fixture)[2].click(); + fixture.detectChanges(); + monthButtons(fixture)[5].click(); + fixture.detectChanges(); + const v = component.value() as DateRange; + expect(v.from.getMonth()).toBe(2); + expect(v.to?.getMonth()).toBe(5); + }); + + it("selectionLevel='years', mode='multiple' toggles years", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + fixture.componentRef.setInput("selectionLevel", "years"); + fixture.componentRef.setInput("mode", "multiple"); + fixture.detectChanges(); + + yearButtons(fixture)[0].click(); + fixture.detectChanges(); + yearButtons(fixture)[1].click(); + fixture.detectChanges(); + expect((component.value() as Date[]).length).toBe(2); + }); + + it("required prevents clearing for selectionLevel='months', mode='multiple'", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + fixture.componentRef.setInput("selectionLevel", "months"); + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("required", true); + fixture.detectChanges(); + + monthButtons(fixture)[0].click(); + fixture.detectChanges(); + const before = component.value(); + monthButtons(fixture)[0].click(); + fixture.detectChanges(); + expect(component.value()).toEqual(before); + }); + }); + + describe("prev/next via internal handlers", () => { + it("prev in years view decrements yearPageStart", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + component.view.set("years"); + fixture.detectChanges(); + const initial = component.yearPageStart(); + navButtons(fixture)[0].click(); + fixture.detectChanges(); + expect(component.yearPageStart()).toBe(initial - 12); + }); + + it("next in months view advances by year", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + component.view.set("months"); + fixture.detectChanges(); + navButtons(fixture)[1].click(); + fixture.detectChanges(); + expect(component.currentMonth().getFullYear()).toBe(2025); + }); + + it("handlePrev/handleNext are no-ops when disabled", () => { + const fixture = createComponent(); + const component = fixture.componentInstance; + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + const initial = component.currentMonth().getMonth(); + component.handlePrev(); + component.handleNext(); + expect(component.currentMonth().getMonth()).toBe(initial); + }); + }); +}); diff --git a/tedi/components/content/calendar/calendar.component.ts b/tedi/components/content/calendar/calendar.component.ts new file mode 100644 index 000000000..dd497cf00 --- /dev/null +++ b/tedi/components/content/calendar/calendar.component.ts @@ -0,0 +1,570 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + forwardRef, + HostListener, + inject, + Injector, + input, + model, + output, + signal, + ViewEncapsulation, +} from "@angular/core"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { + addDays, + addMonths, + addYears, + DateRange, + getDaysInMonth, + getFirstDayOfWeek, + isBeforeDay, + isSameDay, + startOfMonth, + toggleDateInArray, +} from "../../../utils/date.util"; +import { matchAny, Matcher } from "../../../utils/matchers.util"; +import { CalendarDayGridComponent } from "./calendar-day-grid/calendar-day-grid.component"; +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"; + +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; + +@Component({ + selector: "tedi-calendar", + standalone: true, + templateUrl: "./calendar.component.html", + styleUrl: "./calendar.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + CalendarHeaderComponent, + CalendarDayGridComponent, + CalendarMonthGridComponent, + CalendarYearGridComponent, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalendarComponent), + multi: true, + }, + ], + host: { + class: "tedi-calendar", + "[class.tedi-calendar--disabled]": "effectiveDisabled()", + "[class.tedi-calendar--multi-month]": "numberOfMonths() > 1", + "[class.tedi-calendar--bordered]": "bordered()", + }, +}) +export class CalendarComponent implements ControlValueAccessor { + readonly view = model("days"); + readonly currentMonth = model(new Date()); + readonly value = model(null); + + readonly mode = input("single"); + readonly selectionLevel = input("days"); + readonly localeCode = input("et-EE"); + readonly showOutsideDays = input(true); + readonly showWeekNumbers = input(false); + readonly showNavigation = input(true); + readonly bordered = input(true); + readonly disabledMatchers = input([]); + readonly availableDays = input(undefined); + readonly unavailableDays = input(undefined); + readonly monthYearSelectType = input<"dropdown" | "grid">("dropdown"); + readonly required = input(false); + readonly numberOfMonths = input(1); + readonly inputDisabled = input(false); + readonly shouldDisableMonth = input(undefined); + readonly shouldDisableYear = input(undefined); + readonly minYear = input(null); + readonly maxYear = input(null); + + // eslint-disable-next-line @angular-eslint/no-output-native -- 'select' is mandated by the DateField spec for parity with TEDI React + readonly select = output<{ date: CalendarValue; day: Date }>(); + + private readonly hostEl = inject(ElementRef); + private readonly injector = inject(Injector); + + readonly yearPageSize = YEAR_PAGE_SIZE; + + private readonly cvaDisabled = signal(false); + private readonly internalYearPageStart = signal(null); + readonly hoveredDay = signal(null); + + private onChange: (value: CalendarValue) => void = () => {}; + private onTouched: () => void = () => {}; + + constructor() { + effect( + () => { + const level = this.selectionLevel(); + this.view.set(level); + }, + { allowSignalWrites: true }, + ); + } + + readonly effectiveDisabled = computed( + () => this.inputDisabled() || this.cvaDisabled(), + ); + + readonly firstDayOfWeek = computed(() => + getFirstDayOfWeek(this.localeCode()), + ); + + readonly monthDates = computed(() => + Array.from({ length: this.numberOfMonths() }, (_, i) => + addMonths(this.currentMonth(), i), + ), + ); + + readonly resolvedMinYear = computed(() => { + const explicit = this.minYear(); + if (explicit !== null) return explicit; + return new Date().getFullYear() - 10; + }); + + readonly resolvedMaxYear = computed(() => { + const explicit = this.maxYear(); + if (explicit !== null) return explicit; + return new Date().getFullYear() + 10; + }); + + readonly yearPageStart = computed(() => { + const explicit = this.internalYearPageStart(); + if (explicit !== null) return explicit; + return this.currentMonth().getFullYear() - 5; + }); + + readonly selectedSingle = computed(() => { + const v = this.value(); + if (this.mode() !== "single") return null; + return v instanceof Date ? v : null; + }); + + readonly computedDisabledMatchers = computed(() => { + const matchers: Matcher[] = [...this.disabledMatchers()]; + const available = this.availableDays(); + if (available !== undefined) { + const predicate = + typeof available === "function" + ? available + : (d: Date) => available.some((entry) => isSameDay(entry, d)); + matchers.push((d: Date) => !predicate(d)); + } + const unavailable = this.unavailableDays(); + if (unavailable !== undefined) { + const predicate = + typeof unavailable === "function" + ? unavailable + : (d: Date) => + unavailable.some((entry) => isSameDay(entry, d)); + matchers.push(predicate); + } + return matchers; + }); + + readonly effectiveIsMonthDisabled = computed(() => { + const custom = this.shouldDisableMonth(); + const matchers = this.computedDisabledMatchers(); + return (month: Date) => { + if (custom?.(month)) return true; + if (matchers.length === 0) return false; + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + const days = getDaysInMonth(year, monthIndex); + for (let d = 1; d <= days; d++) { + if (!matchAny(new Date(year, monthIndex, d), matchers)) return false; + } + return true; + }; + }); + + readonly effectiveIsYearDisabled = computed(() => { + const customYear = this.shouldDisableYear(); + const monthPredicate = this.effectiveIsMonthDisabled(); + return (year: Date) => { + if (customYear?.(year)) return true; + const y = year.getFullYear(); + for (let m = 0; m < 12; m++) { + if (!monthPredicate(new Date(y, m, 1))) return false; + } + return true; + }; + }); + + writeValue(value: CalendarValue): void { + this.value.set(value); + const anchor = this.deriveAnchor(value); + if (anchor) { + this.currentMonth.set(startOfMonth(anchor)); + } + } + + registerOnChange(fn: (value: CalendarValue) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this.cvaDisabled.set(disabled); + } + + handlePrev(): void { + if (this.effectiveDisabled()) return; + const view = this.view(); + if (view === "days") { + this.currentMonth.set(addMonths(this.currentMonth(), -1)); + } else if (view === "months") { + this.currentMonth.set(addYears(this.currentMonth(), -1)); + } else { + this.internalYearPageStart.set(this.yearPageStart() - this.yearPageSize); + } + } + + handleNext(): void { + if (this.effectiveDisabled()) return; + const view = this.view(); + if (view === "days") { + this.currentMonth.set(addMonths(this.currentMonth(), 1)); + } else if (view === "months") { + this.currentMonth.set(addYears(this.currentMonth(), 1)); + } else { + this.internalYearPageStart.set(this.yearPageStart() + this.yearPageSize); + } + } + + handleHeaderMonthChange(monthStartDate: Date, index = 0): void { + const headerDate = new Date( + monthStartDate.getFullYear(), + monthStartDate.getMonth(), + 1, + ); + this.currentMonth.set(addMonths(headerDate, -index)); + } + + handleHeaderYearChange(yearStartDate: Date, index = 0): void { + const headerMonth = addMonths(this.currentMonth(), index).getMonth(); + const headerDate = new Date(yearStartDate.getFullYear(), headerMonth, 1); + this.currentMonth.set(addMonths(headerDate, -index)); + } + + handleHeaderViewChange(nextView: CalendarView): void { + this.view.set(nextView); + if (nextView === "years") { + const currentYear = this.currentMonth().getFullYear(); + this.internalYearPageStart.set(currentYear - 5); + } + } + + handleDaySelect(day: Date): void { + const next = this.applyModeSelection(day); + if (next === undefined) return; + this.commit(next, day); + } + + handleMonthSelect(monthStartDate: Date): void { + if (this.selectionLevel() === "months") { + const next = this.applyModeSelection(monthStartDate); + if (next === undefined) return; + this.commit(next, monthStartDate); + return; + } + + this.currentMonth.set(monthStartDate); + this.view.set("days"); + } + + handleYearSelect(yearStartDate: Date): void { + if (this.selectionLevel() === "years") { + const next = this.applyModeSelection(yearStartDate); + if (next === undefined) return; + this.commit(next, yearStartDate); + return; + } + + this.currentMonth.set( + new Date(yearStartDate.getFullYear(), this.currentMonth().getMonth(), 1), + ); + this.view.set(this.selectionLevel() === "months" ? "months" : "days"); + } + + private applyModeSelection(day: Date): CalendarValue | undefined { + const mode = this.mode(); + const current = this.value(); + if (mode === "single") return day; + if (mode === "multiple") { + const arr = Array.isArray(current) ? current : []; + const next = toggleDateInArray(arr, day); + if (next.length === 0 && this.required()) return undefined; + return next; + } + if (mode === "range") { + const partial = this.partialRange(current); + if (partial) { + return isBeforeDay(day, partial.from) + ? { from: day, to: partial.from } + : { from: partial.from, to: day }; + } + return { from: day }; + } + return current; + } + + @HostListener("keydown", ["$event"]) + handleKeydown(event: KeyboardEvent): void { + if (this.effectiveDisabled()) return; + const target = event.target as HTMLElement | null; + if (!target) return; + + const view = this.view(); + if (view === "days") { + this.handleDaysKeydown(event, target); + } else if (view === "months") { + this.handleMonthsKeydown(event, target); + } else { + this.handleYearsKeydown(event, target); + } + } + + private handleDaysKeydown(event: KeyboardEvent, target: HTMLElement): void { + const dayKey = target.getAttribute("data-date-key"); + if (dayKey === null) return; + const currentDate = new Date(Number(dayKey)); + if (Number.isNaN(currentDate.getTime())) return; + + const nextDate = this.computeDayKeyTarget(event, currentDate); + if (!nextDate) return; + event.preventDefault(); + + this.adjustCurrentMonthForFocus(nextDate); + this.focusDayCell(nextDate); + } + + private computeDayKeyTarget( + event: KeyboardEvent, + currentDate: Date, + ): Date | null { + const arrowStep: Record = { + ArrowLeft: -1, + ArrowRight: 1, + ArrowUp: -7, + ArrowDown: 7, + }; + if (event.key in arrowStep) { + const step = arrowStep[event.key]; + return this.skipDisabledDays( + addDays(currentDate, step), + step, + Math.ceil(366 / Math.abs(step)), + ); + } + if (event.key === "Home" || event.key === "End") { + const offset = (currentDate.getDay() - this.firstDayOfWeek() + 7) % 7; + const rowStart = addDays(currentDate, -offset); + const rowEnd = addDays(currentDate, 6 - offset); + return event.key === "Home" + ? this.skipDisabledDays(rowStart, 1, 7) + : this.skipDisabledDays(rowEnd, -1, 7); + } + if (event.key === "PageUp") { + return event.shiftKey + ? addYears(currentDate, -1) + : addMonths(currentDate, -1); + } + if (event.key === "PageDown") { + return event.shiftKey + ? addYears(currentDate, 1) + : addMonths(currentDate, 1); + } + return null; + } + + private skipDisabledDays( + start: Date, + step: number, + maxIterations: number, + ): Date | null { + const matchers = this.computedDisabledMatchers(); + let date = start; + for (let i = 0; i < maxIterations; i++) { + if (!matchAny(date, matchers)) return date; + date = addDays(date, step); + } + return null; + } + + private adjustCurrentMonthForFocus(nextDate: Date): void { + const anchor = this.currentMonth(); + const lastVisibleMonth = addMonths(anchor, this.numberOfMonths() - 1); + if (isBeforeDay(nextDate, startOfMonth(anchor))) { + this.currentMonth.set(startOfMonth(nextDate)); + return; + } + const afterLast = + nextDate.getFullYear() > lastVisibleMonth.getFullYear() || + (nextDate.getFullYear() === lastVisibleMonth.getFullYear() && + nextDate.getMonth() > lastVisibleMonth.getMonth()); + if (afterLast) { + const monthsAhead = + nextDate.getMonth() - + lastVisibleMonth.getMonth() + + 12 * (nextDate.getFullYear() - lastVisibleMonth.getFullYear()); + this.currentMonth.set(startOfMonth(addMonths(anchor, monthsAhead))); + } + } + + private handleMonthsKeydown( + event: KeyboardEvent, + target: HTMLElement, + ): void { + const buttons = this.queryGridButtons(".tedi-calendar-month-grid__month"); + const index = buttons.indexOf(target as HTMLButtonElement); + if (index === -1) return; + + let nextIndex: number | null = null; + switch (event.key) { + case "ArrowLeft": + nextIndex = index - 1; + break; + case "ArrowRight": + nextIndex = index + 1; + break; + case "ArrowUp": + nextIndex = index - 3; + break; + case "ArrowDown": + nextIndex = index + 3; + break; + default: + return; + } + + if (nextIndex === null) return; + if (nextIndex < 0 || nextIndex >= buttons.length) { + event.preventDefault(); + return; + } + event.preventDefault(); + buttons[nextIndex].focus(); + } + + private handleYearsKeydown( + event: KeyboardEvent, + target: HTMLElement, + ): void { + const buttons = this.queryGridButtons(".tedi-calendar-year-grid__year"); + const index = buttons.indexOf(target as HTMLButtonElement); + if (index === -1) return; + + switch (event.key) { + case "ArrowLeft": + this.focusGridIndex(buttons, index - 1); + event.preventDefault(); + return; + case "ArrowRight": + this.focusGridIndex(buttons, index + 1); + event.preventDefault(); + return; + case "ArrowUp": + this.focusGridIndex(buttons, index - 3); + event.preventDefault(); + return; + case "ArrowDown": + this.focusGridIndex(buttons, index + 3); + event.preventDefault(); + return; + case "PageUp": + event.preventDefault(); + this.internalYearPageStart.set( + this.yearPageStart() - this.yearPageSize, + ); + return; + case "PageDown": + event.preventDefault(); + this.internalYearPageStart.set( + this.yearPageStart() + this.yearPageSize, + ); + return; + default: + return; + } + } + + private focusGridIndex(buttons: HTMLButtonElement[], index: number): void { + if (index < 0 || index >= buttons.length) return; + buttons[index].focus(); + } + + private queryGridButtons(selector: string): HTMLButtonElement[] { + const host = this.hostEl.nativeElement as HTMLElement; + return Array.from(host.querySelectorAll(selector)); + } + + private focusDayCell(date: Date): void { + const key = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ).getTime(); + afterNextRender( + () => { + const host = this.hostEl.nativeElement as HTMLElement; + const inMonth = host.querySelector( + `[data-date-key="${key}"]:not(.tedi-calendar-day-grid__day--outside)`, + ); + const target = + inMonth ?? + host.querySelector(`[data-date-key="${key}"]`); + target?.focus(); + }, + { injector: this.injector }, + ); + } + + private commit(newValue: CalendarValue, day: Date): void { + this.value.set(newValue); + this.select.emit({ date: newValue, day }); + this.onChange(newValue); + this.onTouched(); + } + + private partialRange(value: CalendarValue): DateRange | null { + if ( + value && + !(value instanceof Date) && + !Array.isArray(value) && + value.to === undefined + ) { + return value; + } + return null; + } + + private deriveAnchor(value: CalendarValue): Date | null { + if (!value) return null; + if (value instanceof Date) return value; + if (Array.isArray(value)) { + return value.length > 0 ? value[0] : null; + } + return value.from; + } +} diff --git a/tedi/components/content/calendar/calendar.stories.ts b/tedi/components/content/calendar/calendar.stories.ts new file mode 100644 index 000000000..00378dbae --- /dev/null +++ b/tedi/components/content/calendar/calendar.stories.ts @@ -0,0 +1,667 @@ +import { + Meta, + StoryObj, + argsToTemplate, + moduleMetadata, +} from "@storybook/angular"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { CalendarComponent } from "./calendar.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { AlertComponent } from "../../notifications/alert/alert.component"; +import { TextComponent } from "../../base/text/text.component"; +import type { DateRange } from "../../../utils/date.util"; +import type { Matcher } from "../../../utils/matchers.util"; + +/** + * Zeroheight ↗ + * + * The Calendar is the standalone date selection surface used inside DateField, and can also be + * embedded directly. It supports `single`, `multiple` and `range` selection modes, three commit + * levels (`days`, `months`, `years`), available/unavailable day predicates, ISO week numbers, + * multi-month layouts, header dropdown vs. grid month-year selection, custom locales and a + * footer projection slot (`tediCalendarFooter`). + */ + +// Lock "today" for Chromatic stability. Patches the global Date constructor +// for this stories module so the calendar's internal `new Date()` checks +// (today indicator, focusable-day fallback) resolve to a fixed reference. +const FIXED_TODAY_MS = new Date(2026, 4, 18).getTime(); +const RealDate = Date; +class MockDate extends RealDate { + constructor(...args: unknown[]) { + if (args.length === 0) { + super(FIXED_TODAY_MS); + return; + } + super( + ...(args as ConstructorParameters), + ); + } + static override now(): number { + return FIXED_TODAY_MS; + } +} +(globalThis as unknown as { Date: typeof Date }).Date = MockDate as typeof Date; + +const today = new Date(2026, 4, 18); +const startOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1); +const inThreeDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3); +const inFiveDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 5); +const inSevenDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); +const inTenDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 10); +const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1); + +export default { + title: "TEDI-Ready/Components/Content/Calendar", + component: CalendarComponent, + decorators: [ + moduleMetadata({ + imports: [ + CalendarComponent, + ButtonComponent, + ReactiveFormsModule, + AlertComponent, + TextComponent, + ], + }), + ], + argTypes: { + mode: { + description: + "Selection mode. `single` selects one date, `multiple` toggles dates in an array, `range` builds a `{ from, to }` range across two clicks.", + control: { type: "radio" }, + options: ["single", "multiple", "range"], + table: { + category: "inputs", + type: { + summary: "DateFieldMode", + detail: "single \nmultiple \nrange", + }, + defaultValue: { summary: "single" }, + }, + }, + selectionLevel: { + description: + "Lowest level the user can commit to. `days` shows the day grid as the final step; `months` and `years` commit at that level instead.", + control: { type: "radio" }, + options: ["days", "months", "years"], + table: { + category: "inputs", + type: { + summary: "CalendarView", + detail: "days \nmonths \nyears", + }, + defaultValue: { summary: "days" }, + }, + }, + monthYearSelectType: { + description: + "How the header exposes month/year picking. `dropdown` shows two dropdowns; `grid` switches the body to a month or year grid when the header label is clicked.", + control: { type: "radio" }, + options: ["dropdown", "grid"], + table: { + category: "inputs", + type: { + summary: '"dropdown" | "grid"', + }, + defaultValue: { summary: "dropdown" }, + }, + }, + localeCode: { + description: + "BCP-47 locale used for weekday/month names and the first day of the week.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + defaultValue: { summary: "et-EE" }, + }, + }, + showOutsideDays: { + description: + "Render the trailing/leading days from the adjacent month inside the current month's grid.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + showWeekNumbers: { + description: "Render the ISO week number column at the start of each row.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + showNavigation: { + description: "Show the previous/next navigation buttons in the header.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + bordered: { + description: + "Render the calendar with its own outer border and rounded corners. Disable when embedding inside a surface that already has a border (e.g. the DateField overlay).", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + numberOfMonths: { + description: + "How many consecutive months to render side by side. Useful for date-range selection.", + control: { type: "number" }, + table: { + category: "inputs", + type: { summary: "number" }, + defaultValue: { summary: "1" }, + }, + }, + required: { + description: + "When `mode='multiple'`, prevents clearing the last selected date — at least one date must remain.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + inputDisabled: { + description: + "Disables all interactions. Combines with the reactive-forms disabled state.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + disabledMatchers: { + description: + "Array of matchers that mark dates as disabled. Each matcher can be a `Date`, `Date[]`, `{ before }`, `{ after }`, `{ before, after }`, `{ from, to? }`, `{ dayOfWeek: number[] }`, or a `(date) => boolean` function.", + table: { + category: "inputs", + type: { + summary: "Matcher[]", + detail: + "Date \nDate[] \n{ before: Date } \n{ after: Date } \n{ before: Date; after: Date } \n{ from: Date; to?: Date } \n{ dayOfWeek: number[] } \n(date: Date) => boolean", + }, + defaultValue: { summary: "[]" }, + }, + }, + availableDays: { + description: + "Whitelist of selectable days. Either an explicit `Date[]` or a predicate `(date) => boolean`. Days outside the whitelist are visually marked as unavailable.", + table: { + category: "inputs", + type: { summary: "Date[] | ((date: Date) => boolean)" }, + defaultValue: { summary: "undefined" }, + }, + }, + unavailableDays: { + description: + "Blacklist of explicitly unavailable days — `Date[]` or `(date) => boolean`. Takes precedence over `availableDays`.", + table: { + category: "inputs", + type: { summary: "Date[] | ((date: Date) => boolean)" }, + defaultValue: { summary: "undefined" }, + }, + }, + }, + parameters: { + backgrounds: { + values: [{ name: "default", value: "var(--card-background-primary)" }], + default: "default", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + mode: "single", + selectionLevel: "days", + monthYearSelectType: "dropdown", + localeCode: "et-EE", + showOutsideDays: true, + showWeekNumbers: false, + showNavigation: true, + numberOfMonths: 1, + required: false, + inputDisabled: false, + }, + render: (args) => ({ + props: { ...args, currentMonth: startOfThisMonth }, + template: ``, + }), +}; + +export const WithSelectedValue: Story = { + render: () => ({ + props: { + currentMonth: startOfThisMonth, + selected: inThreeDays, + }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: "Single-mode calendar with a starting value preselected.", + }, + }, + }, +}; + +export const MultipleSelectedDates: Story = { + render: () => { + const control = new FormControl([inThreeDays, inFiveDays, inTenDays]); + return { + props: { control, currentMonth: startOfThisMonth }, + template: ` + + +
    {{ control.value | json }}
    +
    + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`mode='multiple'` toggles dates in and out of a `Date[]` value. Click a selected day again to deselect it.", + }, + }, + }, +}; + +export const Range: Story = { + render: () => { + const range: DateRange = { from: inThreeDays, to: inTenDays }; + const control = new FormControl(range); + const controlMulti = new FormControl(range); + return { + props: { + control, + controlMulti, + currentMonth: startOfThisMonth, + }, + template: ` +
    + + +
    + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`mode='range'` builds a `{ from, to }` range. The first click sets `from`; the second click sets `to` (or replaces `from` if it falls earlier). Pair with `numberOfMonths` to render multiple consecutive months side by side — each gets its own header.", + }, + }, + }, +}; + +export const MonthView: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "With `selectionLevel='months'` the month grid is the commit level — clicking a month sets the value instead of drilling down to days.", + }, + }, + }, +}; + +export const YearView: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "With `selectionLevel='years'` the year grid is the commit level — clicking a year sets the value instead of drilling down further.", + }, + }, + }, +}; + +export const Availability: Story = { + render: () => { + const dateOffset = (offset: number): Date => + new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset); + const availableDays = [ + dateOffset(-1), + dateOffset(4), + dateOffset(5), + dateOffset(6), + ]; + const unavailableDays = [dateOffset(1), dateOffset(2), dateOffset(3)]; + return { + props: { + availableDays, + unavailableDays, + currentMonth: startOfThisMonth, + }, + template: ` +
    + + +
    + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`availableDays` whitelists specific days as selectable; `unavailableDays` does the inverse. Both accept a `Date[]` or a predicate. Left calendar marks four specific days as available; right marks three specific days as unavailable.", + }, + }, + }, +}; + +export const DisabledMatchers: Story = { + render: () => { + const matchers: Matcher[] = [ + { before: yesterday }, + { dayOfWeek: [0, 6] }, + (date: Date): boolean => date.getDate() === 15, + ]; + return { + props: { matchers, currentMonth: startOfThisMonth }, + template: ` + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`disabledMatchers` accepts a mix of matcher shapes. This story combines a `{ before }` matcher (all past dates), a `{ dayOfWeek }` matcher (Sun/Sat) and a predicate function (the 15th of any month).", + }, + }, + }, +}; + +export const WithWeeksCount: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` +
    + + +
    + `, + }), + parameters: { + docs: { + description: { + story: + "Set `showWeekNumbers=true` to render ISO 8601 week numbers in a leading column. Combines with `numberOfMonths` for multi-month range pickers.", + }, + }, + }, +}; + +export const HeaderDropdown: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "Default header — the month and year are picked from inline dropdowns. Useful when users expect fast keyboard-friendly selection.", + }, + }, + }, +}; + +export const HeaderGrid: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "`monthYearSelectType='grid'` replaces the header dropdowns with a clickable label that drills the body into a month or year grid.", + }, + }, + }, +}; + +export const NoControls: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "`showNavigation=false` hides the previous/next chevron buttons in the header — useful when the surrounding UI provides its own navigation, or when the calendar is shown in a read-only context.", + }, + }, + }, +}; + +export const WithLegend: Story = { + render: () => { + const availableDays = [inThreeDays, inFiveDays, inSevenDays, inTenDays]; + return { + props: { availableDays, currentMonth: startOfThisMonth }, + template: ` + +
    + + + Selected + + + + Available + +
    +
    + `, + }; + }, + parameters: { + docs: { + description: { + story: + "Project a legend into the `tediCalendarFooter` slot to explain colour-coded day states. Pairs naturally with `availableDays` / `unavailableDays`.", + }, + }, + }, +}; + +export const WithFooter: Story = { + render: () => { + const control = new FormControl(inThreeDays); + const clear = (): void => control.setValue(null); + return { + props: { control, currentMonth: startOfThisMonth, clear }, + template: ` + +
    + +
    +
    + `, + }; + }, + parameters: { + docs: { + description: { + story: + "Anything projected with the `tediCalendarFooter` attribute renders below the calendar body. Use it for clear/today shortcuts or for surfacing the current selection.", + }, + }, + }, +}; + +export const InputDisabled: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth, selected: inThreeDays }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "`inputDisabled=true` blocks all interactions and applies the disabled visual style. The reactive-forms `disabled` state has the same effect.", + }, + }, + }, +}; + +export const OutsideDaysHidden: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "Hide the trailing/leading days from neighbouring months by setting `showOutsideDays=false`. Their cells stay in the grid but are blank.", + }, + }, + }, +}; + +export const WithCustomLocale: Story = { + render: () => ({ + props: { currentMonth: startOfThisMonth }, + template: ` + + `, + }), + parameters: { + docs: { + description: { + story: + "Override `localeCode` (BCP-47) to switch month names, weekday names and the first day of the week. `en-US` starts the week on Sunday.", + }, + }, + }, +}; diff --git a/tedi/components/content/calendar/index.ts b/tedi/components/content/calendar/index.ts new file mode 100644 index 000000000..90a7c4320 --- /dev/null +++ b/tedi/components/content/calendar/index.ts @@ -0,0 +1,3 @@ +export { CalendarComponent } from "./calendar.component"; +export type { CalendarView, DateFieldMode, DateRange } from "./types"; +export type { Matcher } from "../../../utils/matchers.util"; diff --git a/tedi/components/content/calendar/types.ts b/tedi/components/content/calendar/types.ts new file mode 100644 index 000000000..0f3a57a03 --- /dev/null +++ b/tedi/components/content/calendar/types.ts @@ -0,0 +1,3 @@ +export type { DateRange } from "../../../utils/date.util"; +export type CalendarView = "days" | "months" | "years"; +export type DateFieldMode = "single" | "multiple" | "range"; diff --git a/tedi/components/content/index.ts b/tedi/components/content/index.ts index b60963560..b2799c696 100644 --- a/tedi/components/content/index.ts +++ b/tedi/components/content/index.ts @@ -1,3 +1,4 @@ export * from "./list/list.component"; export * from "./text-group/"; export * from "./carousel"; +export * from "./calendar"; diff --git a/tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts b/tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts new file mode 100644 index 000000000..46c51bdbf --- /dev/null +++ b/tedi/components/form/date-field/date-field-modal/date-field-modal.component.spec.ts @@ -0,0 +1,105 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + DateFieldModalComponent, + DateFieldModalData, +} from "./date-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"; + +class TranslationMock { + translate(key: string): string { + return key; + } + track(key: string) { + return () => key; + } +} + +function makeData( + overrides: Partial = {}, +): DateFieldModalData { + return { + value: null, + currentMonth: new Date(2026, 4, 1), + mode: "single", + selectionLevel: "days", + localeCode: "et-EE", + showOutsideDays: true, + numberOfMonths: 1, + monthYearSelectType: "dropdown", + required: false, + disabledMatchers: [], + availableDays: undefined, + shouldDisableMonth: undefined, + shouldDisableYear: undefined, + closeOnSelect: true, + ...overrides, + }; +} + +describe("DateFieldModalComponent", () => { + let fixture: ComponentFixture; + let component: DateFieldModalComponent; + let close: jest.Mock; + + function setup(data: DateFieldModalData = makeData()): void { + close = jest.fn(); + const ref = { close } as unknown as ModalRef; + TestBed.configureTestingModule({ + imports: [DateFieldModalComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + { provide: MODAL_DATA, useValue: data }, + { provide: ModalRef, useValue: ref }, + ], + }); + fixture = TestBed.createComponent(DateFieldModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it("renders", () => { + setup(); + expect(component).toBeTruthy(); + }); + + it("seeds draft from data.value", () => { + const value = new Date(2026, 4, 14); + 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); + component.draft.set(next); + component.confirm(); + expect(close).toHaveBeenCalledWith(next); + }); + + it("handleSelect commits when closeOnSelect is true", () => { + setup(makeData({ closeOnSelect: true })); + const next = new Date(2026, 4, 14); + component.draft.set(next); + component.handleSelect(); + expect(close).toHaveBeenCalledWith(next); + }); + + it("handleSelect does NOT close when closeOnSelect is false", () => { + setup(makeData({ closeOnSelect: false })); + const stagedDate = new Date(2026, 4, 14); + component.draft.set(stagedDate); + component.handleSelect(); + expect(close).not.toHaveBeenCalled(); + expect(component.draft()).toEqual(stagedDate); + }); +}); diff --git a/tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts b/tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts new file mode 100644 index 000000000..666fcd7cd --- /dev/null +++ b/tedi/components/form/date-field/date-field-modal/date-field-modal.component.ts @@ -0,0 +1,126 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, + ViewEncapsulation, +} from "@angular/core"; +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 { + CalendarView, + DateFieldMode, + DateRange, +} from "../../../content/calendar/types"; +import { Matcher } from "../../../../utils/matchers.util"; + +type DateFieldValue = Date | Date[] | DateRange | null; +type DayAvailabilityInput = Date[] | ((d: Date) => boolean) | undefined; +type MonthPredicate = (month: Date) => boolean; +type YearPredicate = (year: Date) => boolean; + +export interface DateFieldModalData { + value: DateFieldValue; + currentMonth: Date; + mode: DateFieldMode; + selectionLevel: CalendarView; + localeCode: string; + showOutsideDays: boolean; + numberOfMonths: number; + monthYearSelectType: "dropdown" | "grid"; + required: boolean; + disabledMatchers: Matcher[]; + availableDays: DayAvailabilityInput; + shouldDisableMonth: MonthPredicate | undefined; + shouldDisableYear: YearPredicate | undefined; + closeOnSelect: boolean; +} + +@Component({ + selector: "tedi-date-field-modal", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + ButtonComponent, + CalendarComponent, + ModalComponent, + ModalContentComponent, + ModalFooterComponent, + ModalHeaderComponent, + TediTranslationPipe, + ], + template: ` + + +

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

    +
    + + + + + + + +
    + `, + styles: [ + ` + .tedi-date-field-modal { + --_tedi-modal-body-padding: 0; + } + .tedi-date-field-modal__content { + display: flex; + justify-content: center; + } + `, + ], +}) +export class DateFieldModalComponent { + readonly data = inject(MODAL_DATA) as DateFieldModalData; + private readonly ref = inject(ModalRef); + + readonly draft = signal(this.data.value); + readonly month = signal(this.data.currentMonth); + + cancel(): void { + this.ref.close(undefined); + } + + confirm(): void { + this.ref.close(this.draft()); + } + + handleSelect(): void { + if (!this.data.closeOnSelect) return; + this.confirm(); + } +} diff --git a/tedi/components/form/date-field/date-field.component.html b/tedi/components/form/date-field/date-field.component.html new file mode 100644 index 000000000..59e531860 --- /dev/null +++ b/tedi/components/form/date-field/date-field.component.html @@ -0,0 +1,70 @@ + +@if (usePopover()) { + +
    + + + + +
    +
    +} diff --git a/tedi/components/form/date-field/date-field.component.scss b/tedi/components/form/date-field/date-field.component.scss new file mode 100644 index 000000000..1b7c56382 --- /dev/null +++ b/tedi/components/form/date-field/date-field.component.scss @@ -0,0 +1,13 @@ +.tedi-date-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); + } +} diff --git a/tedi/components/form/date-field/date-field.component.spec.ts b/tedi/components/form/date-field/date-field.component.spec.ts new file mode 100644 index 000000000..3ad5ae9b9 --- /dev/null +++ b/tedi/components/form/date-field/date-field.component.spec.ts @@ -0,0 +1,1167 @@ +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 { DateFieldComponent } from "./date-field.component"; +import { FormFieldComponent } from "../form-field/form-field.component"; +import { LabelComponent } from "../label/label.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.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: [DateFieldComponent], + 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: DateFieldComponent; + el: HTMLElement; + breakpoint: BreakpointServiceMock; + modalService: ModalServiceStub; +} { + const { breakpoint, modalService } = configureBaseModule(); + const fixture = TestBed.createComponent(DateFieldComponent); + fixture.componentRef.setInput("inputId", "test-date-field"); + 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, + }; +} + +describe("DateFieldComponent", () => { + it("creates the component", () => { + const { component } = createField(); + expect(component).toBeTruthy(); + }); + + describe("input wiring", () => { + 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-date-field"); + }); + + it("shows placeholder", () => { + const { el } = createField({ placeholder: "dd.mm.yyyy" }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.placeholder).toBe("dd.mm.yyyy"); + }); + + it("renders the formatted display value for a single Date", () => { + const { el, component, fixture } = createField({ mode: "single" }); + component.writeValue(new Date(2026, 4, 14)); + fixture.detectChanges(); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.value).toBe("14.05.2026"); + }); + }); + + describe("ControlValueAccessor", () => { + it("sets value via writeValue", () => { + const { component } = createField(); + const v = new Date(2026, 0, 1); + component.writeValue(v); + expect(component.value()).toBe(v); + }); + + it("handles null writeValue", () => { + const { component } = createField(); + component.writeValue(null); + expect(component.value()).toBeNull(); + }); + + it("sets cvaDisabled via setDisabledState", () => { + const { component, el, fixture } = createField(); + component.setDisabledState(true); + fixture.detectChanges(); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.disabled).toBe(true); + expect(component.fieldDisabled()).toBe(true); + }); + + it("calls onChange when value is committed", () => { + const { component } = createField(); + const onChange = jest.fn(); + component.registerOnChange(onChange); + + // Simulate manual input parse commit + component.handleInputChange("14.05.2026"); + + expect(onChange).toHaveBeenCalledTimes(1); + const arg = onChange.mock.calls[0][0] as Date; + expect(arg.getFullYear()).toBe(2026); + expect(arg.getMonth()).toBe(4); + expect(arg.getDate()).toBe(14); + }); + }); + + describe("FormFieldControl", () => { + it("exposes value signal", () => { + const { component } = createField(); + component.writeValue(new Date(2026, 0, 1)); + expect(component.value()).toBeInstanceOf(Date); + }); + + it("exposes disabled signal driven by inputDisabled", () => { + const { component, fixture } = createField(); + expect(component.disabled()).toBe(false); + fixture.componentRef.setInput("inputDisabled", true); + fixture.detectChanges(); + expect(component.disabled()).toBe(true); + }); + + it("setInvalidState toggles invalid signal", () => { + const { component } = createField(); + expect(component.invalid()).toBe(false); + component.setInvalidState(true); + expect(component.invalid()).toBe(true); + }); + + it("clearField clears value and emits null", () => { + const { component } = createField(); + const onChange = jest.fn(); + component.writeValue(new Date(2026, 0, 1)); + component.registerOnChange(onChange); + + component.clearField(); + expect(component.value()).toBeNull(); + expect(onChange).toHaveBeenCalledWith(null); + }); + }); + + describe("manual input parsing", () => { + it("parses a valid et-EE date and commits", () => { + const { component } = createField({ mode: "single" }); + component.handleInputChange("14.05.2026"); + const v = component.value() as Date; + expect(v).toBeInstanceOf(Date); + expect(v.getFullYear()).toBe(2026); + expect(v.getMonth()).toBe(4); + expect(v.getDate()).toBe(14); + }); + + it("silently ignores an unparseable value", () => { + const { component } = createField({ mode: "single" }); + component.writeValue(new Date(2024, 0, 1)); + component.handleInputChange("xxxxxxxx"); + const v = component.value() as Date; + expect(v.getFullYear()).toBe(2024); + }); + + it("rejects a parsed date that matches a disabled matcher", () => { + const matcher = { before: new Date(2030, 11, 31) }; + const { component } = createField({ + mode: "single", + disabled: matcher, + }); + component.handleInputChange("14.05.2026"); + expect(component.value()).toBeNull(); + }); + + it("does not parse non-single modes by default", () => { + const { component } = createField({ mode: "range" }); + component.handleInputChange("14.05.2026"); + expect(component.value()).toBeNull(); + }); + + it("uses custom parseDate when provided", () => { + const parsed = new Date(2030, 5, 15); + const customParse = jest.fn(() => parsed); + const { component } = createField({ + mode: "single", + parseDate: customParse, + }); + component.handleInputChange("anything"); + expect(customParse).toHaveBeenCalledWith("anything"); + expect(component.value()).toBe(parsed); + }); + + it("clears value when input is emptied", () => { + const { component } = createField({ mode: "single" }); + component.writeValue(new Date(2024, 0, 1)); + component.handleInputChange(""); + expect(component.value()).toBeNull(); + }); + + it("does not commit while readOnly", () => { + const { component } = createField({ mode: "single", readOnly: true }); + component.handleInputChange("14.05.2026"); + expect(component.value()).toBeNull(); + }); + }); + + describe("disabledMatchers computed", () => { + it("combines disabled, minDate, maxDate, disablePast, disableFuture", () => { + const min = new Date(2020, 0, 1); + const max = new Date(2030, 11, 31); + const single = new Date(2025, 0, 1); + const { component } = createField({ + disabled: single, + minDate: min, + maxDate: max, + disablePast: true, + disableFuture: true, + }); + const matchers = component.disabledMatchers(); + expect(matchers.length).toBe(5); + }); + + it("forwards minDate as a before-matcher", () => { + const { component } = createField({ + minDate: new Date(2022, 0, 1), + }); + const matchers = component.disabledMatchers(); + expect(matchers[0]).toEqual({ before: new Date(2022, 0, 1) }); + }); + + it("forwards an array of matchers", () => { + const arr = [new Date(2024, 0, 1), new Date(2024, 1, 1)]; + const { component } = createField({ disabled: arr }); + expect(component.disabledMatchers()).toEqual(arr); + }); + }); + + describe("native picker fallback", () => { + it("renders type=date when useNativePicker is true in single mode", () => { + const { el } = createField({ + useNativePicker: true, + mode: "single", + }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.type).toBe("date"); + }); + + it("does NOT switch to native picker when mode is multiple", () => { + const { el } = createField({ + useNativePicker: true, + mode: "multiple", + }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.type).toBe("text"); + }); + + it("renders nativeIsoValue from value() when in native-picker mode", () => { + const { el, component, fixture } = createField({ + useNativePicker: true, + mode: "single", + }); + component.writeValue(new Date(2026, 4, 14)); + fixture.detectChanges(); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.value).toBe("2026-05-14"); + }); + + it("suppresses overlay when native picker is active", () => { + const { component } = createField({ + useNativePicker: true, + mode: "single", + }); + expect(component.usePopover()).toBe(false); + }); + + it("calls showPicker on icon click when supported", () => { + const { el, component } = createField({ + useNativePicker: true, + mode: "single", + }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + const showPicker = jest.fn(); + (input as unknown as { showPicker: () => void }).showPicker = showPicker; + + component.handleIconClick(); + expect(showPicker).toHaveBeenCalledTimes(1); + }); + + it("focuses the input when showPicker is not supported", () => { + const { el, component } = createField({ + useNativePicker: true, + mode: "single", + }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + delete (input as unknown as { showPicker?: () => void }).showPicker; + const focusSpy = jest.spyOn(input, "focus"); + + component.handleIconClick(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("parses native ISO input on change", () => { + const { component } = createField({ + useNativePicker: true, + mode: "single", + }); + component.handleInputChange("2026-05-14"); + const v = component.value() as Date; + expect(v.getFullYear()).toBe(2026); + expect(v.getMonth()).toBe(4); + expect(v.getDate()).toBe(14); + }); + }); + + describe("overlay", () => { + it("enables the overlay path when calendar is enabled", () => { + const { component } = createField(); + expect(component.usePopover()).toBe(true); + }); + + it("opens the overlay on icon click", () => { + const { el, component } = createField(); + const iconBtn = el.querySelector( + ".tedi-date-input__icon", + ) as HTMLButtonElement; + iconBtn.click(); + expect(component.overlayOpen()).toBe(true); + }); + + it("closes the overlay on second icon click", () => { + const { el, component } = createField(); + const iconBtn = el.querySelector( + ".tedi-date-input__icon", + ) as HTMLButtonElement; + iconBtn.click(); + iconBtn.click(); + expect(component.overlayOpen()).toBe(false); + }); + + it("closes the overlay on outside click", () => { + const { component } = createField(); + component.overlayOpen.set(true); + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + + const outsideTarget = document.createElement("div"); + document.body.appendChild(outsideTarget); + const event = new MouseEvent("click"); + Object.defineProperty(event, "target", { value: outsideTarget }); + component.handleOverlayOutsideClick(event); + + expect(component.overlayOpen()).toBe(false); + expect(onTouched).toHaveBeenCalled(); + document.body.removeChild(outsideTarget); + }); + + it("ignores outside click events when the click is inside the host (origin)", () => { + const { el, component } = createField(); + component.overlayOpen.set(true); + const iconBtn = el.querySelector( + ".tedi-date-input__icon", + ) as HTMLButtonElement; + const event = new MouseEvent("click"); + Object.defineProperty(event, "target", { value: iconBtn }); + component.handleOverlayOutsideClick(event); + expect(component.overlayOpen()).toBe(true); + }); + + it("closes the overlay on Escape key", () => { + const { component } = createField(); + component.overlayOpen.set(true); + const event = new KeyboardEvent("keydown", { key: "Escape" }); + component.handleOverlayKeydown(event); + expect(component.overlayOpen()).toBe(false); + }); + + it("does not close on other keys", () => { + const { component } = createField(); + component.overlayOpen.set(true); + const event = new KeyboardEvent("keydown", { key: "Enter" }); + component.handleOverlayKeydown(event); + expect(component.overlayOpen()).toBe(true); + }); + }); + + describe("closeOnSelect heuristic", () => { + it("defaults to true for single mode", () => { + const { component } = createField({ mode: "single" }); + expect(component.closeOnSelectEffective()).toBe(true); + }); + + it("defaults to false for multiple mode", () => { + const { component } = createField({ mode: "multiple" }); + expect(component.closeOnSelectEffective()).toBe(false); + }); + + it("defaults to false for range mode", () => { + const { component } = createField({ mode: "range" }); + expect(component.closeOnSelectEffective()).toBe(false); + }); + + it("respects explicit closeOnSelect=true", () => { + const { component } = createField({ + mode: "multiple", + closeOnSelect: true, + }); + expect(component.closeOnSelectEffective()).toBe(true); + }); + + it("respects explicit closeOnSelect=false", () => { + const { component } = createField({ + mode: "single", + closeOnSelect: false, + }); + expect(component.closeOnSelectEffective()).toBe(false); + }); + }); + + describe("modal-below-breakpoint", () => { + it("opens a modal when below the configured breakpoint", () => { + const { component, modalService } = createField({ modal: "md" }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("sm"); + + const ref = { + closed: { subscribe: jest.fn() }, + }; + modalService.open.mockReturnValue(ref); + + component.handleIconClick(); + expect(modalService.open).toHaveBeenCalled(); + }); + + it("does NOT open a modal when above the configured breakpoint", () => { + const { component, modalService } = createField({ modal: "md" }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("xl"); + component.handleIconClick(); + expect(modalService.open).not.toHaveBeenCalled(); + }); + }); + + describe("readOnly", () => { + it("sets readonly attribute on the underlying input", () => { + const { el } = createField({ readOnly: true }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.readOnly).toBe(true); + }); + + it("does not commit on manual typing when readOnly", () => { + const { component } = createField({ readOnly: true, mode: "single" }); + component.handleInputChange("14.05.2026"); + expect(component.value()).toBeNull(); + }); + + it("still allows the icon button to be clickable (does not disable it)", () => { + const { el } = createField({ readOnly: true }); + const iconBtn = el.querySelector( + ".tedi-date-input__icon", + ) as HTMLButtonElement; + expect(iconBtn.disabled).toBe(false); + }); + }); + + describe("inputDisabled vs disabled matcher", () => { + it("inputDisabled marks the underlying input disabled", () => { + const { el } = createField({ inputDisabled: true }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.disabled).toBe(true); + }); + + it("matcher disabled does NOT mark the input disabled", () => { + const { el } = createField({ + disabled: { before: new Date(2030, 0, 1) }, + }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.disabled).toBe(false); + }); + }); + + describe("enableCalendar=false", () => { + it("does not enable the overlay path when calendar is disabled", () => { + const { component } = createField({ enableCalendar: false }); + expect(component.usePopover()).toBe(false); + }); + + it("still allows manual typing", () => { + const { component } = createField({ + enableCalendar: false, + mode: "single", + }); + component.handleInputChange("14.05.2026"); + expect(component.value()).toBeInstanceOf(Date); + }); + + it("renders the icon button disabled when enableCalendar=false", () => { + const { el } = createField({ enableCalendar: false }); + const icon = el.querySelector( + ".tedi-date-input__icon", + ) as HTMLButtonElement; + expect(icon).toBeTruthy(); + expect(icon.disabled).toBe(true); + }); + + it("handleIconClick is a no-op when enableCalendar=false", () => { + const { component } = createField({ enableCalendar: false }); + component.handleIconClick(); + expect(component.overlayOpen()).toBe(false); + }); + }); + + describe("multiple mode chips", () => { + it("renders chips for selected dates in multiple mode", () => { + const { el, component, fixture } = createField({ mode: "multiple" }); + component.value.set([new Date(2026, 4, 14), new Date(2026, 5, 1)]); + fixture.detectChanges(); + const chips = el.querySelectorAll("tedi-tag"); + expect(chips.length).toBe(2); + }); + + it("removes a date when the chip is removed", () => { + const { el, component, fixture } = createField({ mode: "multiple" }); + component.value.set([new Date(2026, 4, 14), new Date(2026, 5, 1)]); + fixture.detectChanges(); + const removeBtn = el.querySelector( + "tedi-tag .tedi-closing-button", + ) as HTMLButtonElement; + removeBtn.click(); + const v = component.value() as Date[]; + expect(v.length).toBe(1); + }); + }); + + describe("openChange output", () => { + it("emits openChange when overlay opens via icon click", () => { + const { el, component, fixture } = createField(); + const events: boolean[] = []; + component.openChange.subscribe((v) => events.push(v)); + const iconBtn = el.querySelector( + ".tedi-date-input__icon", + ) as HTMLButtonElement; + iconBtn.click(); + fixture.detectChanges(); + expect(events).toContain(true); + }); + + it("emits openChange=false when overlay closes", () => { + const { component, fixture } = createField(); + const events: boolean[] = []; + component.overlayOpen.set(true); + fixture.detectChanges(); + component.openChange.subscribe((v) => events.push(v)); + component.closeOverlay(); + fixture.detectChanges(); + expect(events).toContain(false); + }); + }); + + describe("valueChange (model output)", () => { + it("emits when value changes via commit", () => { + const { component } = createField({ mode: "single" }); + const events: Array = []; + component.value.subscribe((v) => events.push(v)); + component.handleInputChange("14.05.2026"); + const last = events[events.length - 1]; + expect(last).toBeInstanceOf(Date); + }); + }); + + describe("handleCalendarSelect", () => { + it("commits the calendar value and closes the overlay for single mode", () => { + const { component } = createField({ mode: "single" }); + const onChange = jest.fn(); + component.registerOnChange(onChange); + + component.overlayOpen.set(true); + + const fakeCalendar = { + value: () => new Date(2026, 4, 14), + }; + (component as unknown as { calendar: () => typeof fakeCalendar }).calendar = + () => fakeCalendar; + + component.handleCalendarSelect(); + expect(component.value()).toBeInstanceOf(Date); + expect(onChange).toHaveBeenCalled(); + expect(component.overlayOpen()).toBe(false); + }); + + it("does not close when mode is multiple", () => { + const { component } = createField({ mode: "multiple" }); + component.overlayOpen.set(true); + const fakeCalendar = { + value: () => [new Date(2026, 4, 14)], + }; + (component as unknown as { calendar: () => typeof fakeCalendar }).calendar = + () => fakeCalendar; + + component.handleCalendarSelect(); + expect(component.overlayOpen()).toBe(true); + }); + }); + + describe("handleCurrentMonthChange", () => { + it("updates the currentMonth signal", () => { + const { component } = createField(); + const target = new Date(2030, 5, 1); + component.handleCurrentMonthChange(target); + expect(component.currentMonth().getFullYear()).toBe(2030); + expect(component.currentMonth().getMonth()).toBe(5); + }); + }); + + describe("closeOverlay", () => { + it("flips overlayOpen to false when invoked while open", () => { + const { component } = createField(); + component.overlayOpen.set(true); + component.closeOverlay(); + expect(component.overlayOpen()).toBe(false); + }); + + it("no-ops when overlayOpen is false", () => { + const { component } = createField(); + const onTouched = jest.fn(); + component.registerOnTouched(onTouched); + component.closeOverlay(); + expect(onTouched).not.toHaveBeenCalled(); + }); + }); + + describe("initialMonth", () => { + it("uses initialMonth to seed currentMonth when value is null", () => { + const initial = new Date(2030, 2, 1); + const { component } = createField({ initialMonth: initial }); + expect(component.currentMonth().getFullYear()).toBe(2030); + expect(component.currentMonth().getMonth()).toBe(2); + }); + }); + + describe("range mode", () => { + it("formats a range with both endpoints", () => { + const { el, component, fixture } = createField({ mode: "range" }); + component.value.set({ + from: new Date(2026, 0, 1), + to: new Date(2026, 0, 5), + }); + fixture.detectChanges(); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.value).toContain("01.01.2026"); + expect(input.value).toContain("05.01.2026"); + }); + + it("formats a range with only `from`", () => { + const { el, component, fixture } = createField({ mode: "range" }); + component.value.set({ from: new Date(2026, 0, 1) }); + fixture.detectChanges(); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.value).toBe("01.01.2026"); + }); + }); + + describe("formatDate override", () => { + it("uses the consumer-provided formatter", () => { + const customFormat = jest.fn(() => "CUSTOM"); + const { el, component, fixture } = createField({ + mode: "single", + formatDate: customFormat, + }); + component.writeValue(new Date(2026, 4, 14)); + fixture.detectChanges(); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.value).toBe("CUSTOM"); + expect(customFormat).toHaveBeenCalled(); + }); + }); + + describe("native picker invalid input", () => { + it("ignores a malformed yyyy-MM-dd value", () => { + const { component } = createField({ + useNativePicker: true, + mode: "single", + }); + component.handleInputChange("garbage"); + expect(component.value()).toBeNull(); + }); + + it("ignores a non-existent date like 2026-02-30", () => { + const { component } = createField({ + useNativePicker: true, + mode: "single", + }); + component.handleInputChange("2026-02-30"); + expect(component.value()).toBeNull(); + }); + }); + + describe("breakpoint-aware resolution", () => { + it("uses the xxl branch when at xxl breakpoint", () => { + const { component } = createField({ + useNativePicker: { xs: false, xxl: true }, + }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("xxl"); + expect(component.useNativePickerResolved()).toBe(true); + }); + + it("falls back to xs when no other breakpoint matches", () => { + const { component } = createField({ + useNativePicker: { xs: true }, + }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("xs"); + expect(component.useNativePickerResolved()).toBe(true); + }); + + it("clamps numberOfMonths to 1 below md", () => { + const { component } = createField({ numberOfMonths: 2 }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("sm"); + expect(component.numberOfMonthsResolved()).toBe(1); + }); + + it("respects numberOfMonths above md", () => { + const { component } = createField({ numberOfMonths: 2 }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("lg"); + expect(component.numberOfMonthsResolved()).toBe(2); + }); + + it("modal=true forces modal mode at all breakpoints", () => { + const { component } = createField({ modal: true }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("xxl"); + expect(component.modalEnabled()).toBe(true); + }); + + it("modal=false disables modal mode at all breakpoints", () => { + const { component } = createField({ modal: false }); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint("xs"); + expect(component.modalEnabled()).toBe(false); + }); + }); + + describe("modal commit", () => { + it("commits the value returned from the modal", () => { + const { component, modalService } = createField({ modal: true }); + const subscribers: Array<(v: Date | null) => void> = []; + modalService.open.mockReturnValue({ + closed: { + subscribe: (fn: (v: Date | null) => void) => { + subscribers.push(fn); + return { unsubscribe: () => {} }; + }, + }, + }); + + component.handleIconClick(); + expect(modalService.open).toHaveBeenCalled(); + + // Simulate user confirms with a date + const confirmed = new Date(2026, 4, 14); + subscribers[0](confirmed); + expect(component.value()).toBe(confirmed); + expect(component.overlayOpen()).toBe(false); + }); + + it("does not commit when modal is cancelled (closed with undefined)", () => { + const { component, modalService } = createField({ modal: true }); + const subscribers: Array<(v: Date | undefined) => void> = []; + modalService.open.mockReturnValue({ + closed: { + subscribe: (fn: (v: Date | undefined) => void) => { + subscribers.push(fn); + return { unsubscribe: () => {} }; + }, + }, + }); + + component.writeValue(null); + component.handleIconClick(); + subscribers[0](undefined); + expect(component.value()).toBeNull(); + }); + }); + + describe("input click trigger", () => { + it("opens the overlay when calendarTrigger=input and input is clicked", () => { + const { el, component, fixture } = createField({ + calendarTrigger: "input", + }); + fixture.detectChanges(); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + input.click(); + expect(component.overlayOpen()).toBe(true); + }); + + it("does not open the overlay from input click when calendarTrigger=button", () => { + const { el, component } = createField({ calendarTrigger: "button" }); + const input = el.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + input.click(); + expect(component.overlayOpen()).toBe(false); + }); + }); + + describe("availableDays forwarded", () => { + it("passes availableDays through to the calendar input", () => { + const fn = (d: Date) => d.getDate() === 1; + const { component } = createField({ availableDays: fn }); + expect(component.availableDays()).toBe(fn); + }); + }); + + describe("clearField behavior", () => { + it("does nothing when disabled", () => { + const { component } = createField({ inputDisabled: true }); + component.value.set(new Date(2026, 4, 14)); + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.clearField(); + expect(component.value()).toBeInstanceOf(Date); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("does nothing when readOnly", () => { + const { component } = createField({ readOnly: true }); + component.value.set(new Date(2026, 4, 14)); + const onChange = jest.fn(); + component.registerOnChange(onChange); + component.clearField(); + expect(component.value()).toBeInstanceOf(Date); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe("native picker parse rejection", () => { + it("rejects when parts.length !== 3 (no dashes)", () => { + const { component } = createField({ + useNativePicker: true, + mode: "single", + }); + component.handleInputChange("nodashvalue"); + expect(component.value()).toBeNull(); + }); + + it("rejects non-numeric ISO parts", () => { + const { component } = createField({ + useNativePicker: true, + mode: "single", + }); + component.handleInputChange("abcd-ef-gh"); + expect(component.value()).toBeNull(); + }); + }); + + describe("multiple mode display", () => { + it("default format joins multiple dates with a comma — used for parser/format calls", () => { + // Force the defaultFormat array branch by calling the formatter on a non-multiple + // array value (range-mode parser returns a Date[] for example). + const customParse = () => [new Date(2026, 0, 1), new Date(2026, 0, 2)]; + const { component } = createField({ + mode: "range", + parseDate: customParse, + }); + component.handleInputChange("anything"); + // value should be the array + expect(component.value()).toEqual([ + new Date(2026, 0, 1), + new Date(2026, 0, 2), + ]); + }); + }); + + describe("breakpoint resolver — lower-tier branches", () => { + function makeFieldAt(breakpoint: string, inputs: Record) { + const r = createField(inputs); + const bp = TestBed.inject(BreakpointService) as unknown as BreakpointServiceMock; + bp.setBreakpoint(breakpoint); + return r; + } + + it("uses xl branch at xl breakpoint", () => { + const { component } = makeFieldAt("xl", { + useNativePicker: { xs: false, xl: true }, + }); + expect(component.useNativePickerResolved()).toBe(true); + }); + + it("uses lg branch at lg breakpoint", () => { + const { component } = makeFieldAt("lg", { + useNativePicker: { xs: false, lg: true }, + }); + expect(component.useNativePickerResolved()).toBe(true); + }); + + it("uses md branch at md breakpoint", () => { + const { component } = makeFieldAt("md", { + useNativePicker: { xs: false, md: true }, + }); + expect(component.useNativePickerResolved()).toBe(true); + }); + + it("uses sm branch at sm breakpoint", () => { + const { component } = makeFieldAt("sm", { + useNativePicker: { xs: false, sm: true }, + }); + expect(component.useNativePickerResolved()).toBe(true); + }); + }); + + describe("parsedValueIsDisabled with range matchers", () => { + it("rejects a range whose `from` falls in the disabled set", () => { + const customParse = () => ({ + from: new Date(2026, 4, 14), + to: new Date(2026, 4, 20), + }); + const matcher = new Date(2026, 4, 14); + const { component } = createField({ + mode: "range", + disabled: matcher, + parseDate: customParse, + }); + component.handleInputChange("anything"); + expect(component.value()).toBeNull(); + }); + + it("accepts a range without disabled overlap", () => { + const customParse = () => ({ + from: new Date(2026, 4, 14), + to: new Date(2026, 4, 20), + }); + const { component } = createField({ + mode: "range", + parseDate: customParse, + }); + component.handleInputChange("anything"); + expect(component.value()).toEqual({ + from: new Date(2026, 4, 14), + to: new Date(2026, 4, 20), + }); + }); + + it("rejects a range whose `to` falls in the disabled set", () => { + const customParse = () => ({ + from: new Date(2026, 4, 14), + to: new Date(2026, 4, 20), + }); + const matcher = new Date(2026, 4, 20); + const { component } = createField({ + mode: "range", + disabled: matcher, + parseDate: customParse, + }); + component.handleInputChange("anything"); + expect(component.value()).toBeNull(); + }); + + it("rejects an array value with any disabled date", () => { + const customParse = () => [new Date(2026, 4, 14)]; + const matcher = new Date(2026, 4, 14); + const { component } = createField({ + mode: "multiple", + disabled: matcher, + parseDate: customParse, + }); + component.handleInputChange("anything"); + expect(component.value()).toBeNull(); + }); + }); +}); + +@Component({ + standalone: true, + imports: [DateFieldComponent, ReactiveFormsModule], + template: ``, +}) +class ReactiveHostComponent { + control = new FormControl(null); +} + +describe("DateFieldComponent with ReactiveFormsModule", () => { + let fixture: ComponentFixture; + let host: ReactiveHostComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ReactiveHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + fixture = TestBed.createComponent(ReactiveHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("syncs FormControl value to the input display", () => { + host.control.setValue(new Date(2026, 4, 14)); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.value).toBe("14.05.2026"); + }); + + it("disables the input when FormControl is disabled", () => { + host.control.disable(); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector( + "input.tedi-date-input__input", + ) as HTMLInputElement; + expect(input.disabled).toBe(true); + }); + + it("syncs the parsed value back to the FormControl on input change", () => { + const fieldDebug = fixture.debugElement.query(By.directive(DateFieldComponent)); + const fieldInstance = fieldDebug.componentInstance as DateFieldComponent; + fieldInstance.handleInputChange("14.05.2026"); + fixture.detectChanges(); + expect(host.control.value).toBeInstanceOf(Date); + }); +}); + +@Component({ + standalone: true, + imports: [ + DateFieldComponent, + FormFieldComponent, + LabelComponent, + FeedbackTextComponent, + ReactiveFormsModule, + ], + template: ` + + + + + + `, +}) +class CompositeHostComponent { + control = new FormControl(null); +} + +describe("DateFieldComponent inside FormFieldComponent", () => { + let fixture: ComponentFixture; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CompositeHostComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + fixture = TestBed.createComponent(CompositeHostComponent); + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + it("renders date-field inside form-field", () => { + expect(el.querySelector("tedi-form-field")).toBeTruthy(); + expect(el.querySelector("tedi-date-field")).toBeTruthy(); + expect(el.querySelector("input.tedi-date-input__input")).toBeTruthy(); + }); + + it("renders projected label", () => { + const label = el.querySelector("[tedi-label]"); + expect(label).toBeTruthy(); + }); + + it("renders feedback text", () => { + expect(el.querySelector("tedi-feedback-text")).toBeTruthy(); + }); +}); + +// TODO: footer-projection cannot be asserted in jsdom; see date-field.stories.ts WithFooter + diff --git a/tedi/components/form/date-field/date-field.component.ts b/tedi/components/form/date-field/date-field.component.ts new file mode 100644 index 000000000..e564cd26b --- /dev/null +++ b/tedi/components/form/date-field/date-field.component.ts @@ -0,0 +1,618 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + forwardRef, + inject, + input, + model, + output, + signal, + viewChild, + ViewEncapsulation, +} from "@angular/core"; +import { + ConnectedPosition, + OverlayModule, +} from "@angular/cdk/overlay"; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { DateInputComponent, DateInputChip } from "./date-input/date-input.component"; +import { CalendarComponent } from "../../content/calendar/calendar.component"; +import { + CalendarView, + DateFieldMode, + DateRange, +} from "../../content/calendar/types"; +import { + FormFieldControl, + TEDI_FORM_FIELD_CONTROL, +} from "../form-field/form-field-control"; +import { + Breakpoint, + breakpointInput, + BreakpointInput, + BreakpointObject, + BreakpointService, +} from "../../../services/breakpoint/breakpoint.service"; +import { + formatLocaleDate, + isSameDay, + parseLocaleDate, + startOfMonth, +} from "../../../utils/date.util"; +import { matchAny, Matcher } from "../../../utils/matchers.util"; +import { ModalService } from "../../overlay/modal/modal.service"; +import { + DateFieldModalComponent, + DateFieldModalData, +} from "./date-field-modal/date-field-modal.component"; + +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 = BreakpointInput | Breakpoint; + +@Component({ + selector: "tedi-date-field", + standalone: true, + templateUrl: "./date-field.component.html", + styleUrl: "./date-field.component.scss", + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [ + CalendarComponent, + DateInputComponent, + OverlayModule, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateFieldComponent), + multi: true, + }, + { + provide: TEDI_FORM_FIELD_CONTROL, + useExisting: forwardRef(() => DateFieldComponent), + }, + ], + host: { + class: "tedi-date-field", + }, +}) +export class DateFieldComponent + implements ControlValueAccessor, FormFieldControl +{ + readonly inputId = input.required(); + readonly value = model(null); + readonly mode = input("single"); + readonly placeholder = input(""); + readonly disabledInput = input(undefined, { + // eslint-disable-next-line @angular-eslint/no-input-rename -- 'disabled' conflicts with FormFieldControl.disabled Signal required by the form-field-control contract; alias keeps the spec'd public binding name + alias: "disabled", + }); + readonly inputDisabled = input(false); + readonly readOnly = input(false); + readonly required = input(false); + readonly minDate = input(undefined); + readonly maxDate = input(undefined); + readonly disablePast = input(false); + readonly disableFuture = input(false); + readonly shouldDisableMonth = input(undefined); + readonly shouldDisableYear = input(undefined); + readonly availableDays = input(undefined); + readonly selectionLevel = input("days"); + readonly monthYearSelectType = input<"dropdown" | "grid">("dropdown"); + readonly initialMonth = input(undefined); + readonly localeCode = input("et-EE"); + readonly closeOnSelect = input(undefined); + readonly showOutsideDays = input(true); + readonly numberOfMonths = input( + { xs: 1 }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + readonly enableCalendar = input( + { xs: true }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + readonly calendarTrigger = input( + { xs: "button" as DateFieldCalendarTrigger }, + { + transform: (v: BreakpointInput) => + breakpointInput(v), + }, + ); + readonly useNativePicker = input( + { xs: false }, + { transform: (v: BreakpointInput) => breakpointInput(v) }, + ); + readonly modal = input("md"); + readonly formatDate = input(undefined); + readonly parseDate = input(undefined); + + readonly openChange = output(); + + private readonly breakpointService = inject(BreakpointService); + private readonly modalService = inject(ModalService); + private readonly hostEl = inject(ElementRef); + + readonly calendar = viewChild("calendar"); + readonly dateInput = viewChild.required("dateInput"); + + readonly currentMonth = signal(new Date()); + readonly overlayOpen = signal(false); + + readonly overlayPositions: ConnectedPosition[] = [ + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + offsetY: 4, + }, + { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + offsetY: -4, + }, + ]; + + private readonly cvaDisabled = signal(false); + private readonly formInvalid = signal(false); + + private onChange: (value: DateFieldValue) => void = () => {}; + private onTouched: () => void = () => {}; + + readonly fieldDisabled = computed( + () => this.inputDisabled() || this.cvaDisabled(), + ); + + readonly disabled = computed(() => this.fieldDisabled()); + + readonly invalid = computed(() => this.formInvalid()); + + readonly disabledMatchers = computed(() => { + const result: Matcher[] = []; + const explicit = this.disabledInput(); + if (Array.isArray(explicit)) { + result.push(...explicit); + } else if (explicit !== undefined) { + result.push(explicit); + } + const min = this.minDate(); + if (min) result.push({ before: min }); + const max = this.maxDate(); + if (max) result.push({ after: max }); + if (this.disablePast()) { + result.push({ before: this.startOfToday() }); + } + if (this.disableFuture()) { + result.push({ after: this.startOfToday() }); + } + return result; + }); + + readonly useNativePickerResolved = computed(() => + this.resolveBreakpointInput(this.useNativePicker()), + ); + + readonly useNativePickerEffective = computed( + () => this.useNativePickerResolved() && this.mode() === "single", + ); + + readonly numberOfMonthsResolved = computed(() => { + const raw = this.resolveBreakpointInput(this.numberOfMonths()); + const belowMd = this.breakpointService.isBelowBreakpoint("md")(); + if (belowMd) return 1; + return Math.max(1, raw); + }); + + readonly enableCalendarResolved = computed(() => + this.resolveBreakpointInput(this.enableCalendar()), + ); + + readonly calendarTriggerResolved = computed(() => + this.resolveBreakpointInput(this.calendarTrigger()), + ); + + readonly modalEnabled = computed(() => { + const m = this.modal(); + if (typeof m === "string") { + return this.breakpointService.isBelowBreakpoint(m)(); + } + return this.resolveBreakpointInput(breakpointInput(m)); + }); + + readonly closeOnSelectEffective = computed(() => { + const explicit = this.closeOnSelect(); + if (typeof explicit === "boolean") return explicit; + return this.mode() === "single"; + }); + + readonly showCalendar = computed( + () => this.enableCalendarResolved() && !this.useNativePickerEffective(), + ); + + readonly useModal = computed( + () => this.showCalendar() && this.modalEnabled(), + ); + + readonly usePopover = computed( + () => this.showCalendar() && !this.modalEnabled(), + ); + + readonly nativeIsoValue = computed(() => { + if (!this.useNativePickerEffective()) return ""; + const v = this.value(); + if (!(v instanceof Date)) return ""; + return this.toIsoDate(v); + }); + + readonly displayValue = computed(() => { + const v = this.value(); + if (this.mode() === "multiple") return ""; + if (v === null || v === undefined) return ""; + const customFormat = this.formatDate(); + if (customFormat) return customFormat(v); + return this.defaultFormat(v); + }); + + readonly chipsForMultipleMode = computed(() => { + if (this.mode() !== "multiple") return []; + const v = this.value(); + if (!Array.isArray(v)) return []; + const customFormat = this.formatDate(); + return v.map((d, index) => ({ + id: `${d.getTime()}-${index}`, + label: customFormat ? customFormat(d) : this.defaultFormat(d), + })); + }); + + readonly canClear = computed( + () => !!this.value() && !this.fieldDisabled() && !this.readOnly(), + ); + + readonly inputIsTrigger = computed( + () => + this.showCalendar() && this.calendarTriggerResolved() === "input", + ); + + private initialOpenEmit = true; + + constructor() { + effect(() => { + const v = this.value(); + const anchor = this.deriveAnchor(v) ?? this.initialMonth() ?? null; + if (anchor) { + this.currentMonth.set(startOfMonth(anchor)); + } + }); + + effect(() => { + const open = this.overlayOpen(); + if (this.initialOpenEmit) { + this.initialOpenEmit = false; + return; + } + this.openChange.emit(open); + }); + } + + writeValue(value: DateFieldValue): void { + this.value.set(value); + } + + registerOnChange(fn: (value: DateFieldValue) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.cvaDisabled.set(isDisabled); + } + + setInvalidState(isInvalid: boolean): void { + this.formInvalid.set(isInvalid); + } + + clearField(): void { + if (this.fieldDisabled() || this.readOnly()) return; + this.commitValue(null); + } + + handleClear(): void { + this.clearField(); + } + + handleIconClick(): void { + if (this.fieldDisabled()) return; + if (!this.enableCalendarResolved()) return; + + if (this.useNativePickerEffective()) { + this.openNativePicker(); + return; + } + + if (!this.showCalendar()) return; + + if (this.useModal()) { + this.openModal(); + return; + } + + if (this.overlayOpen()) { + this.overlayOpen.set(false); + this.onTouched(); + } else { + this.overlayOpen.set(true); + } + } + + handleInputChange(value: string): void { + if (this.readOnly() || this.fieldDisabled()) return; + + if (this.useNativePickerEffective()) { + this.handleNativeInputChange(value); + return; + } + + if (value === "") { + this.commitValue(null); + return; + } + + if (this.mode() === "single") { + this.handleSingleParseInput(value); + return; + } + + const customParse = this.parseDate(); + if (!customParse) return; + const parsed = customParse(value); + if (parsed === undefined) return; + if (this.parsedValueIsDisabled(parsed)) return; + this.commitValue(parsed); + } + + handleChipRemove(id: string): void { + if (this.fieldDisabled() || this.readOnly()) return; + const v = this.value(); + if (!Array.isArray(v)) return; + + const time = Number(id.split("-")[0]); + if (!Number.isFinite(time)) return; + + const target = new Date(time); + const next = v.filter((d) => !isSameDay(d, target)); + this.commitValue(next); + } + + handleInputClick(event: Event): void { + if (this.fieldDisabled()) return; + if (!this.inputIsTrigger()) return; + const target = event.target as HTMLElement | null; + if (!target) return; + if (!target.matches(".tedi-date-input__input")) return; + this.handleIconClick(); + } + + handleCalendarSelect(): void { + const calendar = this.calendar(); + if (!calendar) return; + const newValue = calendar.value(); + this.value.set(newValue); + this.onChange(newValue); + this.onTouched(); + if (this.closeOnSelectEffective()) { + this.closeOverlay(); + } + } + + handleCurrentMonthChange(month: Date): void { + this.currentMonth.set(month); + } + + closeOverlay(): void { + if (!this.overlayOpen()) return; + this.overlayOpen.set(false); + this.onTouched(); + this.focusIconButton(); + } + + handleOverlayOutsideClick(event: MouseEvent): void { + if (!this.overlayOpen()) return; + const host = this.hostEl.nativeElement as HTMLElement; + const target = event.target as Node | null; + if (target && host.contains(target)) return; + this.overlayOpen.set(false); + this.onTouched(); + } + + handleOverlayKeydown(event: KeyboardEvent): void { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.closeOverlay(); + } + } + + private focusIconButton(): void { + const host = this.hostEl.nativeElement as HTMLElement; + const icon = host.querySelector(".tedi-date-input__icon"); + icon?.focus(); + } + + private openNativePicker(): void { + const inputEl = this.queryNativeInput(); + if (!inputEl) return; + if (typeof inputEl.showPicker === "function") { + try { + inputEl.showPicker(); + return; + } catch { + /* showPicker may throw outside a user gesture — fall through */ + } + } + inputEl.focus(); + } + + private openModal(): void { + this.overlayOpen.set(true); + const data: DateFieldModalData = { + value: this.value(), + currentMonth: this.currentMonth(), + mode: this.mode(), + selectionLevel: this.selectionLevel(), + localeCode: this.localeCode(), + showOutsideDays: this.showOutsideDays(), + numberOfMonths: this.numberOfMonthsResolved(), + monthYearSelectType: this.monthYearSelectType(), + required: this.required(), + disabledMatchers: this.disabledMatchers(), + availableDays: this.availableDays(), + shouldDisableMonth: this.shouldDisableMonth(), + shouldDisableYear: this.shouldDisableYear(), + closeOnSelect: this.closeOnSelectEffective(), + }; + + const ref = this.modalService.open( + DateFieldModalComponent, + { + data, + size: "small", + width: "sm", + position: "center", + maxWidth: "var(--tedi-containers-03)", + }, + ); + + ref.closed.subscribe((result) => { + this.overlayOpen.set(false); + this.onTouched(); + if (result === undefined) return; + this.commitValue(result); + }); + } + + private handleNativeInputChange(value: string): void { + if (value === "") { + this.commitValue(null); + return; + } + const parts = value.split("-"); + if (parts.length !== 3) return; + const [yearStr, monthStr, dayStr] = parts; + const year = Number(yearStr); + const month = Number(monthStr); + const day = Number(dayStr); + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return; + } + const parsed = new Date(year, month - 1, day); + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day + ) { + return; + } + if (this.parsedValueIsDisabled(parsed)) return; + this.commitValue(parsed); + } + + private handleSingleParseInput(value: string): void { + const customParse = this.parseDate(); + const parsed = customParse + ? customParse(value) + : parseLocaleDate(value, this.localeCode()); + if (parsed === undefined || parsed === null) return; + if (this.parsedValueIsDisabled(parsed)) return; + this.commitValue(parsed); + } + + private parsedValueIsDisabled(value: DateFieldValue): boolean { + if (value === null) return false; + const matchers = this.disabledMatchers(); + if (matchers.length === 0) return false; + if (value instanceof Date) return matchAny(value, matchers); + if (Array.isArray(value)) return value.some((d) => matchAny(d, matchers)); + if (matchAny(value.from, matchers)) return true; + if (value.to && matchAny(value.to, matchers)) return true; + return false; + } + + private commitValue(next: DateFieldValue): void { + this.value.set(next); + this.onChange(next); + this.onTouched(); + const anchor = this.deriveAnchor(next); + if (anchor) { + this.currentMonth.set(startOfMonth(anchor)); + } + } + + private defaultFormat(value: Date | Date[] | DateRange): string { + const locale = this.localeCode(); + if (value instanceof Date) return formatLocaleDate(value, locale); + if (Array.isArray(value)) { + return value.map((d) => formatLocaleDate(d, locale)).join(", "); + } + if (value.to === undefined) return formatLocaleDate(value.from, locale); + return `${formatLocaleDate(value.from, locale)} – ${formatLocaleDate(value.to, locale)}`; + } + + private deriveAnchor(value: DateFieldValue): Date | null { + if (!value) return null; + if (value instanceof Date) return value; + if (Array.isArray(value)) return value.length > 0 ? value[0] : null; + return value.from; + } + + private resolveBreakpointInput(v: BreakpointObject): T { + if ( + v.xxl !== undefined && + this.breakpointService.isAboveBreakpoint("xxl")() + ) + return v.xxl; + if (v.xl !== undefined && this.breakpointService.isAboveBreakpoint("xl")()) + return v.xl; + if (v.lg !== undefined && this.breakpointService.isAboveBreakpoint("lg")()) + return v.lg; + if (v.md !== undefined && this.breakpointService.isAboveBreakpoint("md")()) + return v.md; + if (v.sm !== undefined && this.breakpointService.isAboveBreakpoint("sm")()) + return v.sm; + return v.xs; + } + + // Note: 'today' is captured per-getter call but disabledMatchers is a + // computed() — it will not re-evaluate at midnight rollover. For sessions + // spanning midnight the past/future windows may become stale until any + // other dependency changes. + private startOfToday(): Date { + const now = new Date(); + 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/tedi/components/form/date-field/date-field.stories.ts b/tedi/components/form/date-field/date-field.stories.ts new file mode 100644 index 000000000..37247ef55 --- /dev/null +++ b/tedi/components/form/date-field/date-field.stories.ts @@ -0,0 +1,897 @@ +import { + type Meta, + type StoryObj, + moduleMetadata, +} from "@storybook/angular"; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import { DateFieldComponent } from "./date-field.component"; +import { FormFieldComponent } from "../form-field/form-field.component"; +import { LabelComponent } from "../label/label.component"; +import { FeedbackTextComponent } from "../feedback-text/feedback-text.component"; +import { ButtonComponent } from "../../buttons/button/button.component"; +import { AlertComponent } from "../../notifications/alert/alert.component"; +import { TextComponent } from "../../base/text/text.component"; +import type { DateRange } from "../../content/calendar/types"; + +/** + * Zeroheight ↗ + * + * DateField is the form-control wrapper around the Calendar. It exposes a typed text input + * paired with a popover (or modal, below the `md` breakpoint by default) that renders the + * Calendar. It supports `single`, `multiple` and `range` modes, custom `formatDate`/`parseDate` + * callbacks, native OS picker fallback, and the same selection-level/header options as Calendar. + */ + +const today = new Date(); +const inThreeDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3); +const inTenDays = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 10); + +const pad = (n: number): string => n.toString().padStart(2, "0"); + +const formatUS = (value: Date | Date[] | DateRange | null): string => { + if (!value) return ""; + if (value instanceof Date) { + return `${pad(value.getMonth() + 1)}/${pad(value.getDate())}/${value.getFullYear()}`; + } + if (Array.isArray(value)) { + return value + .map((d) => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`) + .join(", "); + } + const from = `${pad(value.from.getMonth() + 1)}/${pad(value.from.getDate())}/${value.from.getFullYear()}`; + if (!value.to) return from; + const to = `${pad(value.to.getMonth() + 1)}/${pad(value.to.getDate())}/${value.to.getFullYear()}`; + return `${from} – ${to}`; +}; + +const parseUS = (value: string): Date | undefined => { + const match = value.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (!match) return undefined; + const month = Number(match[1]); + const day = Number(match[2]); + const year = Number(match[3]); + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined; + } + return date; +}; + +export default { + title: "TEDI-Ready/Components/Form/DateField", + component: DateFieldComponent, + decorators: [ + moduleMetadata({ + imports: [ + FormFieldComponent, + LabelComponent, + FeedbackTextComponent, + ButtonComponent, + AlertComponent, + TextComponent, + ReactiveFormsModule, + ], + }), + ], + argTypes: { + mode: { + description: + "Selection mode. `single` selects one date, `multiple` toggles dates in an array, `range` builds a `{ from, to }` range across two clicks.", + control: { type: "radio" }, + options: ["single", "multiple", "range"], + table: { + category: "inputs", + type: { + summary: "DateFieldMode", + detail: "single \nmultiple \nrange", + }, + defaultValue: { summary: "single" }, + }, + }, + selectionLevel: { + description: + "Lowest level the user can commit to. `days` shows the day grid as the final step; `months` and `years` commit at that level instead.", + control: { type: "radio" }, + options: ["days", "months", "years"], + table: { + category: "inputs", + type: { + summary: "CalendarView", + detail: "days \nmonths \nyears", + }, + defaultValue: { summary: "days" }, + }, + }, + monthYearSelectType: { + description: + "How the popover header exposes month/year picking. `dropdown` shows two dropdowns; `grid` drills into a month/year grid when the header label is clicked.", + control: { type: "radio" }, + options: ["dropdown", "grid"], + table: { + category: "inputs", + type: { summary: '"dropdown" | "grid"' }, + defaultValue: { summary: "dropdown" }, + }, + }, + localeCode: { + description: + "BCP-47 locale for weekday/month names, the first day of the week, and the default `formatDate`/`parseDate` behaviour.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + defaultValue: { summary: "et-EE" }, + }, + }, + placeholder: { + description: "Placeholder rendered in the input when there is no value.", + control: { type: "text" }, + table: { + category: "inputs", + type: { summary: "string" }, + defaultValue: { summary: '""' }, + }, + }, + inputDisabled: { + description: + "Disables the field entirely — input, icon button, and calendar.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + readOnly: { + description: + "Blocks typing into the input but leaves the calendar interactive — useful for guided picking.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + required: { + description: + "Marks the field as required. In `multiple` mode prevents clearing the last selected date.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + disablePast: { + description: "Disable all dates before today.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + disableFuture: { + description: "Disable all dates after today.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "false" }, + }, + }, + showOutsideDays: { + description: + "Render the trailing/leading days from the adjacent month inside the current month's grid.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + enableCalendar: { + description: + "Enables the calendar picker UI. When `false`, hides the icon button and disables the popover/modal — the user can only type a date.", + control: { type: "boolean" }, + table: { + category: "inputs", + type: { summary: "boolean" }, + defaultValue: { summary: "true" }, + }, + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + mode: "single", + selectionLevel: "days", + monthYearSelectType: "dropdown", + localeCode: "et-EE", + placeholder: "pp.kk.aaaa", + inputDisabled: false, + readOnly: false, + required: false, + disablePast: false, + disableFuture: false, + showOutsideDays: true, + }, + render: (args) => { + const control = new FormControl(null); + return { + props: { ...args, control }, + template: ` + + + + + + `, + }; + }, +}; + +export const WithSelectedValue: Story = { + render: () => { + const control = new FormControl(inThreeDays); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "Single-mode field with a starting value bound through `FormControl`. The popover opens at the selected month on first interaction.", + }, + }, + }, +}; + +export const Multiple: Story = { + render: () => { + const control = new FormControl([inThreeDays, inTenDays]); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`mode='multiple'` renders selected dates as removable chips inside the input. Clicking a chip's close icon removes that date from the array.", + }, + }, + }, +}; + +export const Range: Story = { + render: () => { + const control = new FormControl({ + from: inThreeDays, + to: inTenDays, + }); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`mode='range'` builds a `{ from, to }` value. The first click sets `from`; the second click sets `to` (or replaces `from` if it falls earlier).", + }, + }, + }, +}; + +export const DisablePast: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`disablePast=true` blocks every date strictly before today. Typed values that fall in the past are rejected on commit.", + }, + }, + }, +}; + +export const DisableFuture: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`disableFuture=true` blocks every date strictly after today. Useful for date-of-birth or historical-event inputs.", + }, + }, + }, +}; + +export const MinAndMaxDate: Story = { + render: () => { + const control = new FormControl(null); + const minDate = new Date(today.getFullYear(), today.getMonth(), 1); + const maxDate = new Date(today.getFullYear(), today.getMonth() + 1, 0); + return { + props: { control, minDate, maxDate }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`minDate` and `maxDate` constrain the selectable window. Both bounds are inclusive.", + }, + }, + }, +}; + +export const CustomFormatAndParse: Story = { + render: () => { + const control = new FormControl(inThreeDays); + return { + props: { control, formatUS, parseUS }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "Pass `formatDate` and `parseDate` callbacks to override the locale-driven default. Here the field uses US-style `MM/dd/yyyy` regardless of `localeCode`.", + }, + }, + }, +}; + +export const UseNativePicker: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`useNativePicker=true` swaps the popover for the browser's built-in `` UI. Only available in `single` mode. Accepts a `BreakpointInput` — see `UseNativePickerBreakpoint`.", + }, + }, + }, +}; + +export const UseNativePickerBreakpoint: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control, useNativePicker: { xs: true, md: false } }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`useNativePicker` accepts a `BreakpointInput`. Here the OS picker is used below `md`, and the custom popover takes over from `md` upward.", + }, + }, + }, +}; + +export const Modal: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`[modal]=\"true\"` always opens the calendar in a centered modal with explicit Cancel/Confirm buttons — independent of viewport size.", + }, + }, + }, +}; + +export const ModalBelowMd: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + viewport: { defaultViewport: "mobile1" }, + docs: { + description: { + story: + "`modal=\"md\"` (the default) renders the calendar in a modal below the `md` breakpoint and in a popover above it. Pass a different breakpoint name to shift the threshold.", + }, + }, + }, +}; + +export const EnableCalendarFalse: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`enableCalendar=false` hides the icon button and disables the popover/modal. The user can only type — typed input is parsed using the locale-aware default (or your `parseDate`).", + }, + }, + }, +}; + +export const CalendarTriggerInput: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`calendarTrigger=\"input\"` makes the entire input clickable to open the calendar — typing is still blocked by `readOnly`. Useful when the user is not expected to type a date.", + }, + }, + }, +}; + +export const ReadOnly: Story = { + render: () => { + const control = new FormControl(inThreeDays); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`readOnly=true` blocks typing into the input but keeps the icon button and calendar interactive — value can only be changed via the picker.", + }, + }, + }, +}; + +export const InputDisabled: Story = { + render: () => { + const control = new FormControl(inThreeDays); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`inputDisabled=true` disables the entire field — input, icon button, and calendar. Combines with the reactive-forms `disabled` state.", + }, + }, + }, +}; + +export const Required: Story = { + render: () => { + const control = new FormControl(null, { + validators: [Validators.required], + }); + control.markAsTouched(); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`required=true` plus `Validators.required` on the control surfaces the form-field invalid state once the control is touched.", + }, + }, + }, +}; + +export const SelectionLevelMonths: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`selectionLevel=\"months\"` commits at month granularity — the calendar shows the month grid as the final step.", + }, + }, + }, +}; + +export const SelectionLevelYears: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`selectionLevel=\"years\"` commits at year granularity — the year grid is the final step.", + }, + }, + }, +}; + +export const HeaderGrid: Story = { + render: () => { + const control = new FormControl(null); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "`monthYearSelectType=\"grid\"` replaces the header dropdowns with a clickable label that drills the body into a month or year grid.", + }, + }, + }, +}; + +export const WithReactiveForms: Story = { + render: () => { + const form = new FormGroup({ + start: new FormControl(inThreeDays, { + validators: [Validators.required], + }), + end: new FormControl(inTenDays), + }); + + return { + props: { form }, + template: ` +
    + + + + + + + + + + + + +
    {{ form.value | json }}
    +
    +
    + `, + }; + }, + parameters: { + docs: { + description: { + story: + "DateField implements `ControlValueAccessor`, so it slots into a `FormGroup` like any reactive control. The block below the fields echoes the live `form.value`.", + }, + }, + }, +}; + +export const WithFooter: Story = { + render: () => { + const control = new FormControl(inThreeDays); + const clear = (): void => control.setValue(null); + return { + props: { control, clear }, + template: ` + + + +
    + +
    +
    + +
    + `, + }; + }, + parameters: { + docs: { + description: { + story: + "Anything projected with the `tediCalendarFooter` attribute renders below the calendar body — useful for clear/today shortcuts. Footer projection works in popover mode; the modal variant does not currently receive projected footers.", + }, + }, + }, +}; + +export const CustomLocale: Story = { + render: () => { + const control = new FormControl(inThreeDays); + return { + props: { control }, + template: ` + + + + + + `, + }; + }, + parameters: { + docs: { + description: { + story: + "Override `localeCode` (BCP-47) to switch month/weekday names, the first day of the week, and the default `formatDate`/`parseDate` behaviour. `en-US` starts the week on Sunday.", + }, + }, + }, +}; diff --git a/tedi/components/form/date-field/date-input/date-input.component.html b/tedi/components/form/date-field/date-input/date-input.component.html new file mode 100644 index 000000000..907bd3b62 --- /dev/null +++ b/tedi/components/form/date-field/date-input/date-input.component.html @@ -0,0 +1,47 @@ +@if (hasChips()) { +
    + @for (chip of chips(); track chip.id) { + + {{ chip.label }} + + } +
    +} + +
    + @if (showClear()) { + + } + +
    diff --git a/tedi/components/form/date-field/date-input/date-input.component.scss b/tedi/components/form/date-field/date-input/date-input.component.scss new file mode 100644 index 000000000..b5d632601 --- /dev/null +++ b/tedi/components/form/date-field/date-input/date-input.component.scss @@ -0,0 +1,120 @@ +.tedi-date-input { + display: flex; + flex: 1; + gap: var(--form-field-inner-spacing); + align-items: center; + min-width: 0; + padding-right: var(--form-field-padding-x-md-default); + + &--with-chips { + flex-wrap: wrap; + align-items: center; + padding: var(--layout-grid-gutters-04) var(--form-field-padding-x-md-default) + var(--layout-grid-gutters-04) var(--form-field-padding-x-md-default); + } + + &__chips { + display: flex; + flex-wrap: wrap; + gap: var(--layout-grid-gutters-04); + align-items: center; + min-width: 0; + } + + &__input { + flex: 1; + 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"] { + &::-webkit-calendar-picker-indicator { + display: none; + appearance: none; + } + + &::-webkit-inner-spin-button { + display: none; + } + + &::-webkit-clear-button { + display: none; + } + } + + &__actions { + display: flex; + gap: var(--layout-grid-gutters-04); + align-items: center; + align-self: center; + justify-content: center; + min-width: 0; + } + + &__clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--button-xs-icon-size); + height: var(--form-field-button-height-sm); + padding: 0; + color: var(--general-icon-secondary); + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--button-radius-sm); + + &:hover { + color: var(--general-icon-primary); + background: var(--button-main-neutral-icon-only-background-hover); + } + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--button-xs-icon-size); + height: var(--form-field-button-height-sm); + padding: 0; + color: var(--general-icon-secondary); + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--button-radius-sm); + + &:hover { + color: var(--button-main-neutral-text-hover); + background: var(--button-main-neutral-icon-only-background-hover); + } + + &:active, + &--active { + color: var(--button-main-neutral-text-active); + background: var(--button-main-neutral-icon-only-background-active); + } + + &:focus-visible { + outline: var(--tedi-borders-02) solid var(--tedi-primary-500); + outline-offset: var(--tedi-borders-01); + } + + &:disabled { + color: var(--general-icon-disabled); + pointer-events: none; + cursor: not-allowed; + background: transparent; + } + } + +} diff --git a/tedi/components/form/date-field/date-input/date-input.component.spec.ts b/tedi/components/form/date-field/date-input/date-input.component.spec.ts new file mode 100644 index 000000000..95c90c828 --- /dev/null +++ b/tedi/components/form/date-field/date-input/date-input.component.spec.ts @@ -0,0 +1,321 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { DateInputComponent } from "./date-input.component"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; + +class TranslationMock { + translate(key: string): string { + return key; + } + track(key: string): () => string { + return () => key; + } +} + +describe("DateInputComponent", () => { + let fixture: ComponentFixture; + let component: DateInputComponent; + let el: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [DateInputComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], + }); + + fixture = TestBed.createComponent(DateInputComponent); + fixture.componentRef.setInput("inputId", "test-date-input"); + component = fixture.componentInstance; + el = fixture.nativeElement; + fixture.detectChanges(); + }); + + function getInput(): HTMLInputElement { + return el.querySelector("input.tedi-date-input__input") as HTMLInputElement; + } + + function getIconButton(): HTMLButtonElement { + return el.querySelector(".tedi-date-input__icon") as HTMLButtonElement; + } + + it("creates the component", () => { + expect(component).toBeTruthy(); + }); + + it("assigns the inputId to the underlying input", () => { + const input = getInput(); + expect(input.id).toBe("test-date-input"); + }); + + it("renders the value in single mode (type=text)", () => { + fixture.componentRef.setInput("value", "14.05.2026"); + fixture.detectChanges(); + + const input = getInput(); + expect(input.type).toBe("text"); + expect(input.value).toBe("14.05.2026"); + }); + + it("switches to type=date and uses nativeIsoValue when useNativePicker is true", () => { + fixture.componentRef.setInput("useNativePicker", true); + fixture.componentRef.setInput("value", "14.05.2026"); + fixture.componentRef.setInput("nativeIsoValue", "2026-05-14"); + fixture.detectChanges(); + + const input = getInput(); + expect(input.type).toBe("date"); + expect(input.value).toBe("2026-05-14"); + }); + + it("does not render chips when mode is single, even with chips provided", () => { + fixture.componentRef.setInput("mode", "single"); + fixture.componentRef.setInput("chips", [{ id: "a", label: "01.01.2026" }]); + fixture.detectChanges(); + + const chips = el.querySelector(".tedi-date-input__chips"); + expect(chips).toBeNull(); + expect(el.querySelectorAll("tedi-tag").length).toBe(0); + }); + + it("does not render chips when mode is multiple but chips list is empty", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("chips", []); + fixture.detectChanges(); + + const chips = el.querySelector(".tedi-date-input__chips"); + expect(chips).toBeNull(); + expect(el.querySelectorAll("tedi-tag").length).toBe(0); + }); + + it("renders chips as tedi-tag elements when mode='multiple' and chips list is non-empty", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("chips", [ + { id: "a", label: "01.01.2026" }, + { id: "b", label: "02.01.2026" }, + ]); + fixture.detectChanges(); + + const tags = el.querySelectorAll("tedi-tag"); + expect(tags.length).toBe(2); + expect(tags[0].textContent).toContain("01.01.2026"); + expect(tags[1].textContent).toContain("02.01.2026"); + }); + + it("emits chipRemove with the chip id when the tag's close button is clicked", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("chips", [{ id: "chip-1", label: "01.01.2026" }]); + fixture.detectChanges(); + + const removeBtn = el.querySelector( + "tedi-tag .tedi-closing-button", + ) as HTMLButtonElement; + expect(removeBtn).not.toBeNull(); + const spy = jest.fn(); + component.chipRemove.subscribe(spy); + + removeBtn.click(); + expect(spy).toHaveBeenCalledWith("chip-1"); + }); + + it("does not render a tag close button when disabled", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("chips", [{ id: "chip-1", label: "01.01.2026" }]); + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + const removeBtn = el.querySelector("tedi-tag .tedi-closing-button"); + expect(removeBtn).toBeNull(); + }); + + it("does not render a tag close button when readOnly", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("chips", [{ id: "chip-1", label: "01.01.2026" }]); + fixture.componentRef.setInput("readOnly", true); + fixture.detectChanges(); + + const removeBtn = el.querySelector("tedi-tag .tedi-closing-button"); + expect(removeBtn).toBeNull(); + }); + + it("emits iconClick when the calendar icon button is clicked", () => { + const spy = jest.fn(); + component.iconClick.subscribe(spy); + + getIconButton().click(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("does not emit iconClick when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + const spy = jest.fn(); + component.iconClick.subscribe(spy); + + getIconButton().click(); + expect(spy).not.toHaveBeenCalled(); + }); + + it("still emits iconClick when readOnly", () => { + fixture.componentRef.setInput("readOnly", true); + fixture.detectChanges(); + + const spy = jest.fn(); + component.iconClick.subscribe(spy); + + getIconButton().click(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("emits inputChange with the typed value", () => { + const spy = jest.fn(); + component.inputChange.subscribe(spy); + + const input = getInput(); + input.value = "14.05."; + input.dispatchEvent(new Event("input")); + + expect(spy).toHaveBeenCalledWith("14.05."); + }); + + it("sets the readonly attribute on the underlying input when readOnly is true", () => { + fixture.componentRef.setInput("readOnly", true); + fixture.detectChanges(); + + expect(getInput().readOnly).toBe(true); + }); + + it("disables both the input and the icon button when disabled is true", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + expect(getInput().disabled).toBe(true); + expect(getIconButton().disabled).toBe(true); + }); + + it("disables only the icon button when iconDisabled is true (input stays enabled)", () => { + fixture.componentRef.setInput("iconDisabled", true); + fixture.detectChanges(); + + expect(getInput().disabled).toBe(false); + expect(getIconButton().disabled).toBe(true); + }); + + it("does not emit iconClick when iconDisabled is true", () => { + fixture.componentRef.setInput("iconDisabled", true); + fixture.detectChanges(); + + const spy = jest.fn(); + component.iconClick.subscribe(spy); + getIconButton().click(); + expect(spy).not.toHaveBeenCalled(); + }); + + it("does not emit inputChange or iconClick when disabled", () => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + + const inputSpy = jest.fn(); + const iconSpy = jest.fn(); + component.inputChange.subscribe(inputSpy); + component.iconClick.subscribe(iconSpy); + + getIconButton().click(); + expect(iconSpy).not.toHaveBeenCalled(); + + // disabled native input ignores `input` events from user, but if dispatched manually + // (which simulates a browser bug or programmatic event), the component must + // still not call its handler since the input is disabled. To verify the + // contract honestly, we only check the icon path here. + expect(inputSpy).not.toHaveBeenCalled(); + }); + + it("sets aria-expanded=true and the active modifier on the icon when iconActive is true", () => { + fixture.componentRef.setInput("iconActive", true); + fixture.detectChanges(); + + const icon = getIconButton(); + expect(icon.getAttribute("aria-expanded")).toBe("true"); + expect(icon.classList.contains("tedi-date-input__icon--active")).toBe(true); + }); + + it("sets aria-expanded=false and no active modifier when iconActive is false", () => { + const icon = getIconButton(); + expect(icon.getAttribute("aria-expanded")).toBe("false"); + expect(icon.classList.contains("tedi-date-input__icon--active")).toBe(false); + }); + + it("exposes an aria-label on the icon button", () => { + const icon = getIconButton(); + const label = icon.getAttribute("aria-label"); + expect(label).toBeTruthy(); + expect(label?.length).toBeGreaterThan(0); + }); + + it("renders a labelled tag close button that is described by the chip label", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("chips", [{ id: "a", label: "01.01.2026" }]); + fixture.detectChanges(); + + const tag = el.querySelector("tedi-tag") as HTMLElement; + const removeBtn = tag.querySelector( + ".tedi-closing-button", + ) as HTMLButtonElement; + expect(removeBtn.getAttribute("aria-label")).toBeTruthy(); + + const describedById = removeBtn.getAttribute("aria-describedby"); + expect(describedById).toBeTruthy(); + const describedBy = tag.querySelector(`#${describedById}`); + expect(describedBy?.textContent).toContain("01.01.2026"); + }); + + it("renders a clear button only when clearable and value is non-empty", () => { + expect(el.querySelector(".tedi-date-input__clear")).toBeNull(); + + fixture.componentRef.setInput("clearable", true); + fixture.componentRef.setInput("value", "14.05.2026"); + fixture.detectChanges(); + expect(el.querySelector(".tedi-date-input__clear")).not.toBeNull(); + }); + + it("emits clear when the clear button is clicked", () => { + fixture.componentRef.setInput("clearable", true); + fixture.componentRef.setInput("value", "14.05.2026"); + fixture.detectChanges(); + + const spy = jest.fn(); + component.clear.subscribe(spy); + + const clearBtn = el.querySelector( + ".tedi-date-input__clear", + ) as HTMLButtonElement; + clearBtn.click(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("toggles the with-chips host modifier based on chip rendering", () => { + const host = el as HTMLElement; + expect(host.classList.contains("tedi-date-input--with-chips")).toBe(false); + + fixture.componentRef.setInput("mode", "multiple"); + fixture.componentRef.setInput("chips", [{ id: "a", label: "01.01.2026" }]); + fixture.detectChanges(); + expect(host.classList.contains("tedi-date-input--with-chips")).toBe(true); + }); + + it("applies disabled and readonly host modifiers based on inputs", () => { + const host = el as HTMLElement; + expect(host.classList.contains("tedi-date-input--disabled")).toBe(false); + expect(host.classList.contains("tedi-date-input--readonly")).toBe(false); + + fixture.componentRef.setInput("disabled", true); + fixture.componentRef.setInput("readOnly", true); + fixture.detectChanges(); + + expect(host.classList.contains("tedi-date-input--disabled")).toBe(true); + expect(host.classList.contains("tedi-date-input--readonly")).toBe(true); + }); +}); 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 new file mode 100644 index 000000000..6f402074e --- /dev/null +++ b/tedi/components/form/date-field/date-input/date-input.component.ts @@ -0,0 +1,122 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + effect, + inject, + input, + output, + viewChild, + ViewEncapsulation, +} from "@angular/core"; +import { IconComponent } from "../../../base/icon/icon.component"; +import { TextFieldComponent } from "../../text-field/text-field.component"; +import { TagComponent } from "../../../tags/tag/tag.component"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { DateFieldMode } from "../../../content/calendar/types"; + +export interface DateInputChip { + id: string; + label: string; +} + +@Component({ + selector: "tedi-date-input", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [IconComponent, TextFieldComponent, TagComponent], + templateUrl: "./date-input.component.html", + styleUrl: "./date-input.component.scss", + host: { + class: "tedi-date-input", + "[class.tedi-date-input--disabled]": "disabled()", + "[class.tedi-date-input--readonly]": "readOnly()", + "[class.tedi-date-input--with-chips]": "hasChips()", + }, +}) +export class DateInputComponent { + readonly inputId = input.required(); + readonly value = input(""); + readonly chips = input([]); + readonly mode = input("single"); + readonly placeholder = input(""); + readonly disabled = input(false); + readonly readOnly = input(false); + readonly required = input(false); + readonly iconActive = input(false); + readonly iconDisabled = input(false); + readonly useNativePicker = input(false); + readonly nativeIsoValue = input(""); + readonly clearable = input(false); + + readonly inputChange = output(); + readonly iconClick = output(); + readonly chipRemove = output(); + readonly clear = output(); + + private readonly translationService = inject(TediTranslationService); + + private readonly inputElement = viewChild("inputElement", { + read: ElementRef, + }); + + constructor() { + effect(() => { + const ref = this.inputElement(); + const target = ref?.nativeElement as HTMLInputElement | undefined; + if (!target) return; + const next = this.inputValue(); + if (target.value !== next) { + target.value = next; + } + }); + } + + readonly hasChips = computed( + () => this.mode() === "multiple" && this.chips().length > 0, + ); + + readonly inputType = computed(() => (this.useNativePicker() ? "date" : "text")); + + readonly inputValue = computed(() => + this.useNativePicker() ? this.nativeIsoValue() : this.value(), + ); + + readonly showClear = computed( + () => + this.clearable() && + !this.disabled() && + !this.readOnly() && + this.inputValue() !== "", + ); + + readonly iconAriaLabel = this.translationService.track( + "date-picker.open-calendar", + ); + + readonly clearAriaLabel = this.translationService.track( + "date-picker.clear-date", + ); + + handleInput(event: Event): void { + const target = event.target as HTMLInputElement; + this.inputChange.emit(target.value); + } + + handleIconClick(): void { + if (this.disabled() || this.iconDisabled()) return; + this.iconClick.emit(); + } + + handleChipRemove(id: string): void { + if (this.disabled() || this.readOnly()) return; + this.chipRemove.emit(id); + } + + handleClear(): void { + if (this.disabled() || this.readOnly()) return; + this.clear.emit(); + } +} diff --git a/tedi/components/form/date-field/date-input/index.ts b/tedi/components/form/date-field/date-input/index.ts new file mode 100644 index 000000000..155e3e113 --- /dev/null +++ b/tedi/components/form/date-field/date-input/index.ts @@ -0,0 +1,2 @@ +export { DateInputComponent } from "./date-input.component"; +export type { DateInputChip } from "./date-input.component"; diff --git a/tedi/components/form/date-field/index.ts b/tedi/components/form/date-field/index.ts new file mode 100644 index 000000000..a5684f3f4 --- /dev/null +++ b/tedi/components/form/date-field/index.ts @@ -0,0 +1,7 @@ +export { DateFieldComponent } from "./date-field.component"; +export type { + CalendarView, + DateFieldMode, + DateRange, +} from "../../content/calendar/types"; +export type { Matcher } from "../../../utils/matchers.util"; diff --git a/tedi/components/form/date-picker/date-picker.stories.ts b/tedi/components/form/date-picker/date-picker.stories.ts index 2d8909863..048c4df26 100644 --- a/tedi/components/form/date-picker/date-picker.stories.ts +++ b/tedi/components/form/date-picker/date-picker.stories.ts @@ -8,6 +8,7 @@ import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { DatePickerComponent } from "./date-picker.component"; import { AlertComponent } from "../../notifications/alert/alert.component"; import { TextComponent } from "../../base/text/text.component"; +import { LabelComponent } from "../label/label.component"; /** * Figma ↗
    @@ -24,7 +25,7 @@ export default { }, decorators: [ moduleMetadata({ - imports: [DatePickerComponent, ReactiveFormsModule, AlertComponent, TextComponent], + imports: [DatePickerComponent, ReactiveFormsModule, AlertComponent, TextComponent, LabelComponent], }), ], argTypes: { @@ -275,6 +276,11 @@ export const WithLowWidth: StoryObj = { +
    + +
    diff --git a/tedi/components/form/form-field/form-field.component.html b/tedi/components/form/form-field/form-field.component.html index 41e96764e..645b49a73 100644 --- a/tedi/components/form/form-field/form-field.component.html +++ b/tedi/components/form/form-field/form-field.component.html @@ -1,7 +1,7 @@
    - + @if (showClearButton()) {
    diff --git a/tedi/components/form/index.ts b/tedi/components/form/index.ts index 944011dc3..af30b7611 100644 --- a/tedi/components/form/index.ts +++ b/tedi/components/form/index.ts @@ -6,6 +6,7 @@ export * from "./radio/radio.component"; export * from "./radio-card/radio-card.component"; export * from "./radio-group/radio-group.component"; export * from "./radio-card-group/radio-card-group.component"; +export * from "./date-field"; export * from "./date-picker/date-picker.component"; export * from "./feedback-text/feedback-text.component"; export * from "./label/label.component"; diff --git a/tedi/components/form/select/select.component.scss b/tedi/components/form/select/select.component.scss index e26d36f8c..6d89f6923 100644 --- a/tedi/components/form/select/select.component.scss +++ b/tedi/components/form/select/select.component.scss @@ -135,7 +135,7 @@ margin-top: var(--form-field-outer-spacing); margin-bottom: var(--form-field-outer-spacing); background: var(--card-background-primary); - border-radius: var(--card-border-radius); + border-radius: var(--card-radius-rounded); box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); } diff --git a/tedi/components/overlay/modal/modal.stories.ts b/tedi/components/overlay/modal/modal.stories.ts index 2123b5358..58202fa44 100644 --- a/tedi/components/overlay/modal/modal.stories.ts +++ b/tedi/components/overlay/modal/modal.stories.ts @@ -1,5 +1,6 @@ -import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular"; -import { Component, inject, Input } from "@angular/core"; +import { type Meta, type StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { Component, inject, Input, signal } from "@angular/core"; +import { provideAnimations } from "@angular/platform-browser/animations"; import { ModalComponent } from "./modal.component"; import { ModalHeaderComponent } from "./modal-header/modal-header.component"; import { ModalContentComponent } from "./modal-content/modal-content.component"; @@ -13,7 +14,9 @@ import { IconComponent } from "../../base/icon/icon.component"; import { ScrollFadeComponent } from "../../helpers/scroll-fade/scroll-fade.component"; import { TextFieldComponent } from "../../form/text-field/text-field.component"; import { FormFieldComponent } from "../../form/form-field/form-field.component"; -import { DatePickerComponent } from "../../form/date-picker/date-picker.component"; +import { DateFieldComponent } from "../../form/date-field/date-field.component"; +import { ToastService } from "../../../services/toast/toast.service"; +import { formatDate } from "../../../utils/date.util"; interface StoryModalData { title: string; @@ -69,7 +72,7 @@ class StoryModalContentComponent { @Component({ standalone: true, selector: "story-scrollable-content", - imports: [...sharedModalImports, DatePickerComponent], + imports: [...sharedModalImports, DateFieldComponent], template: ` @@ -98,14 +101,14 @@ class StoryModalContentComponent {
    -
    + - -
    -
    + + + - -
    + +

    Kontaktisik

    @@ -187,7 +190,7 @@ class StoryScrollableContentComponent { @Component({ standalone: true, selector: "story-scrollable-fade-content", - imports: [...sharedModalImports, ScrollFadeComponent, DatePickerComponent], + imports: [...sharedModalImports, ScrollFadeComponent, DateFieldComponent], template: ` @@ -218,14 +221,14 @@ class StoryScrollableContentComponent {
    -
    + - -
    -
    + + + - -
    + +

    Kontaktisik

    @@ -389,6 +392,51 @@ class StoryNoFooterComponent { readonly ref = inject(ModalRef); } +@Component({ + standalone: true, + selector: "story-modal-with-toast", + imports: [ + ModalComponent, + ModalHeaderComponent, + ModalContentComponent, + ModalFooterComponent, + ButtonComponent, + LabelComponent, + FormFieldComponent, + DateFieldComponent, + ], + template: ` + + +

    {{ data.title }}

    +
    + + + + + + + + + + +
    + `, +}) +class StoryModalWithToastComponent { + readonly data = inject(MODAL_DATA) as StoryModalData; + readonly ref = inject(ModalRef); + private readonly toastService = inject(ToastService); + + readonly selectedDate = signal(null); + + confirm() { + const date = this.selectedDate(); + const formatted = date ? formatDate(date) : "no date selected"; + this.toastService.success("Saved", `Selected date: ${formatted}`); + } +} + /** * Figma ↗
    * Zeroheight ↗ @@ -1411,6 +1459,92 @@ this.modalService.open(MyModalContent, { }, }; +export const WithToast: StoryObj = { + name: "With date picker and toast", + parameters: { + docs: { + source: { + code: ` +@Component({ + imports: [ModalComponent, ModalHeaderComponent, ModalContentComponent, ModalFooterComponent, ButtonComponent, LabelComponent, FormFieldComponent, DateFieldComponent], + template: \` + + +

    {{ data.title }}

    +
    + + + + + + + + + + +
    + \`, +}) +class MyModalContent { + data = inject(MODAL_DATA); + ref = inject(ModalRef); + private toastService = inject(ToastService); + + selectedDate = signal(null); + + confirm() { + const date = this.selectedDate(); + this.toastService.success("Saved", \`Selected date: \${date ? formatDate(date) : "no date selected"}\`); + } +} + +// Open from a host component: +this.modalService.open(MyModalContent, { + data: { title: 'Pick a date' }, + width: 'sm', +});`, + language: "typescript", + type: "code", + }, + }, + }, + decorators: [ + applicationConfig({ + providers: [provideAnimations()], + }), + moduleMetadata({ + imports: [ButtonComponent, StoryModalWithToastComponent], + }), + ], + render: () => { + @Component({ + standalone: true, + selector: "story-with-toast-demo", + imports: [ButtonComponent], + template: ` + + `, + }) + class WithToastDemoComponent { + private readonly modalService = inject(ModalService); + + open() { + this.modalService.open(StoryModalWithToastComponent, { + data: { title: "Pick a date" }, + width: "sm", + }); + } + } + + return { + template: "", + moduleMetadata: { + imports: [WithToastDemoComponent], + }, + }; + }, +}; + export const TemplateBased: StoryObj = { name: "Template-based (deprecated)", parameters: { diff --git a/tedi/index.ts b/tedi/index.ts index c4d9f1432..ff861d86e 100644 --- a/tedi/index.ts +++ b/tedi/index.ts @@ -5,3 +5,4 @@ export * from "./types"; export * from "./helpers"; export * from "./providers"; export * from "./tokens"; +export * from "./utils"; diff --git a/tedi/services/translation/translations.ts b/tedi/services/translation/translations.ts index 4e54a8687..8012b5509 100644 --- a/tedi/services/translation/translations.ts +++ b/tedi/services/translation/translations.ts @@ -957,6 +957,37 @@ export const translationsMap = { en: "Next years", ru: "Следующие годы", }, + "date-field.remove-chip": { + description: + "Label for the remove button on a selected-date chip inside the date field.", + components: ["DateField"], + et: (label: string) => `Eemalda ${label}`, + en: (label: string) => `Remove ${label}`, + ru: (label: string) => `Удалить ${label}`, + }, + "date-field.modal-title": { + description: "Title shown in the mobile date picker modal header.", + components: ["DateField"], + et: "Kuupäev", + en: "Date", + ru: "Дата", + }, + "date-field.confirm": { + description: + "Label for the confirm button in the mobile date picker modal.", + components: ["DateField"], + et: "Kinnita", + en: "Confirm", + ru: "Подтвердить", + }, + "date-field.cancel": { + description: + "Label for the cancel button in the mobile date picker modal.", + components: ["DateField"], + et: "Tühista", + en: "Cancel", + ru: "Отмена", + }, "time-picker.hours": { description: "Aria label for the hours listbox in the time picker.", components: ["TimePicker"], diff --git a/tedi/utils/date.util.spec.ts b/tedi/utils/date.util.spec.ts index d488a5e88..1aaba8dcd 100644 --- a/tedi/utils/date.util.spec.ts +++ b/tedi/utils/date.util.spec.ts @@ -1,10 +1,27 @@ import { + addDays, + addMonths, + addYears, + buildMonthGrid, + endOfMonth, formatDate, - parseDate, - isSameDay, - isBeforeDay, - isAfterDay, + formatLocaleDate, + getDaysInMonth, + getFirstDayOfWeek, getISOWeek, + getMonthNames, + getWeekdayNames, + isAfterDay, + isBeforeDay, + isDateInRange, + isSameDay, + isSameMonth, + isSameYear, + parseDate, + parseLocaleDate, + startOfMonth, + startOfWeek, + toggleDateInArray, } from "./date.util"; describe("date.util", () => { @@ -148,4 +165,477 @@ describe("date.util", () => { expect(getISOWeek(new Date(2026, 0, 4))).toBe(1); }); }); + + describe("addDays", () => { + it("adds positive days", () => { + expect(addDays(new Date(2026, 4, 15), 5)).toEqual(new Date(2026, 4, 20)); + }); + + it("subtracts when n is negative", () => { + expect(addDays(new Date(2026, 4, 15), -5)).toEqual(new Date(2026, 4, 10)); + }); + + it("rolls over into the next month", () => { + expect(addDays(new Date(2026, 0, 31), 1)).toEqual(new Date(2026, 1, 1)); + }); + + it("does not mutate the input", () => { + const input = new Date(2026, 4, 15); + addDays(input, 5); + expect(input).toEqual(new Date(2026, 4, 15)); + }); + }); + + describe("addMonths", () => { + it("adds positive months", () => { + expect(addMonths(new Date(2026, 0, 15), 2)).toEqual( + new Date(2026, 2, 15), + ); + }); + + it("subtracts when n is negative", () => { + expect(addMonths(new Date(2026, 4, 15), -5)).toEqual( + new Date(2025, 11, 15), + ); + }); + + it("clamps Jan 31 + 1 month to last day of Feb (non-leap)", () => { + expect(addMonths(new Date(2025, 0, 31), 1)).toEqual( + new Date(2025, 1, 28), + ); + }); + + it("clamps Jan 31 + 1 month to Feb 29 in leap year", () => { + expect(addMonths(new Date(2024, 0, 31), 1)).toEqual( + new Date(2024, 1, 29), + ); + }); + + it("rolls across years correctly", () => { + expect(addMonths(new Date(2026, 10, 15), 3)).toEqual( + new Date(2027, 1, 15), + ); + }); + }); + + describe("addYears", () => { + it("adds years", () => { + expect(addYears(new Date(2026, 4, 15), 4)).toEqual( + new Date(2030, 4, 15), + ); + }); + + it("subtracts years when negative", () => { + expect(addYears(new Date(2026, 4, 15), -10)).toEqual( + new Date(2016, 4, 15), + ); + }); + + it("clamps Feb 29 + 1 year to Feb 28 in non-leap target", () => { + expect(addYears(new Date(2024, 1, 29), 1)).toEqual( + new Date(2025, 1, 28), + ); + }); + }); + + describe("startOfMonth", () => { + it("returns the first day of the month at 00:00", () => { + expect(startOfMonth(new Date(2026, 4, 15, 10, 30))).toEqual( + new Date(2026, 4, 1, 0, 0, 0, 0), + ); + }); + }); + + describe("endOfMonth", () => { + it("returns the last day for 31-day month", () => { + expect(endOfMonth(new Date(2026, 0, 15))).toEqual(new Date(2026, 0, 31)); + }); + + it("returns Feb 28 for non-leap year", () => { + expect(endOfMonth(new Date(2025, 1, 5))).toEqual(new Date(2025, 1, 28)); + }); + + it("returns Feb 29 for leap year", () => { + expect(endOfMonth(new Date(2024, 1, 5))).toEqual(new Date(2024, 1, 29)); + }); + }); + + describe("startOfWeek", () => { + it("returns the same day when input is already the first day", () => { + // 2026-05-11 is a Monday; firstDayOfWeek=1 (Monday) + expect(startOfWeek(new Date(2026, 4, 11), 1)).toEqual( + new Date(2026, 4, 11), + ); + }); + + it("walks back to Monday when firstDayOfWeek=1", () => { + // 2026-05-15 is a Friday → Monday is 2026-05-11 + expect(startOfWeek(new Date(2026, 4, 15), 1)).toEqual( + new Date(2026, 4, 11), + ); + }); + + it("walks back to Sunday when firstDayOfWeek=0", () => { + // 2026-05-15 is a Friday → Sunday is 2026-05-10 + expect(startOfWeek(new Date(2026, 4, 15), 0)).toEqual( + new Date(2026, 4, 10), + ); + }); + + it("walks across month boundary", () => { + // 2026-05-02 is a Saturday; firstDayOfWeek=1 → Monday 2026-04-27 + expect(startOfWeek(new Date(2026, 4, 2), 1)).toEqual( + new Date(2026, 3, 27), + ); + }); + }); + + describe("getDaysInMonth", () => { + it("returns 31 for January", () => { + expect(getDaysInMonth(2026, 0)).toBe(31); + }); + + it("returns 28 for Feb in non-leap year", () => { + expect(getDaysInMonth(2025, 1)).toBe(28); + }); + + it("returns 29 for Feb in leap year", () => { + expect(getDaysInMonth(2024, 1)).toBe(29); + }); + + it("returns 30 for April", () => { + expect(getDaysInMonth(2026, 3)).toBe(30); + }); + }); + + describe("isSameMonth", () => { + it("returns true for same year and month", () => { + expect(isSameMonth(new Date(2026, 4, 1), new Date(2026, 4, 31))).toBe( + true, + ); + }); + + it("returns false for different months", () => { + expect(isSameMonth(new Date(2026, 4, 1), new Date(2026, 5, 1))).toBe( + false, + ); + }); + + it("returns false for same month but different year", () => { + expect(isSameMonth(new Date(2026, 4, 1), new Date(2025, 4, 1))).toBe( + false, + ); + }); + }); + + describe("isSameYear", () => { + it("returns true for same year", () => { + expect(isSameYear(new Date(2026, 0, 1), new Date(2026, 11, 31))).toBe( + true, + ); + }); + + it("returns false for different years", () => { + expect(isSameYear(new Date(2026, 0, 1), new Date(2025, 11, 31))).toBe( + false, + ); + }); + }); + + describe("getFirstDayOfWeek", () => { + it("returns a numeric JS day index (0-6) for et-EE", () => { + const result = getFirstDayOfWeek("et-EE"); + expect(typeof result).toBe("number"); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(6); + }); + + it("falls back to 1 (Monday) for an unparseable locale", () => { + // Syntactically invalid BCP47 — Intl.Locale throws, catch returns 1. + expect(getFirstDayOfWeek("!@#$")).toBe(1); + }); + + it("converts Intl 7 (Sun) to JS 0", () => { + const original = Intl.Locale.prototype as unknown as { + getWeekInfo?: () => { firstDay: number }; + }; + const previous = original.getWeekInfo; + original.getWeekInfo = () => ({ firstDay: 7 }); + try { + expect(getFirstDayOfWeek("en-US")).toBe(0); + } finally { + if (previous) original.getWeekInfo = previous; + else delete original.getWeekInfo; + } + }); + + it("returns the Intl value as-is for Mon..Sat", () => { + const original = Intl.Locale.prototype as unknown as { + getWeekInfo?: () => { firstDay: number }; + }; + const previous = original.getWeekInfo; + original.getWeekInfo = () => ({ firstDay: 1 }); + try { + expect(getFirstDayOfWeek("et-EE")).toBe(1); + } finally { + if (previous) original.getWeekInfo = previous; + else delete original.getWeekInfo; + } + }); + }); + + describe("getMonthNames", () => { + it("returns 12 long names in English", () => { + const names = getMonthNames("en-US", "long"); + expect(names).toHaveLength(12); + expect(names[0]).toBe("January"); + expect(names[11]).toBe("December"); + }); + + it("returns 12 short names in English", () => { + const names = getMonthNames("en-US", "short"); + expect(names).toHaveLength(12); + expect(names[0]).toBe("Jan"); + }); + + it("returns 12 names in Estonian", () => { + const names = getMonthNames("et-EE", "long"); + expect(names).toHaveLength(12); + expect(names[0].toLowerCase()).toContain("jaanuar"); + }); + + it("returns 12 names in Japanese", () => { + const names = getMonthNames("ja-JP", "long"); + expect(names).toHaveLength(12); + expect(names[0]).toContain("1"); + }); + }); + + describe("getWeekdayNames", () => { + it("returns 7 names starting at firstDayOfWeek=1 (Monday) in English", () => { + const names = getWeekdayNames("en-US", "short", 1); + expect(names).toHaveLength(7); + expect(names[0]).toBe("Mon"); + expect(names[6]).toBe("Sun"); + }); + + it("returns 7 names starting at firstDayOfWeek=0 (Sunday) in English", () => { + const names = getWeekdayNames("en-US", "short", 0); + expect(names).toHaveLength(7); + expect(names[0]).toBe("Sun"); + expect(names[6]).toBe("Sat"); + }); + + it("returns 7 narrow names", () => { + const names = getWeekdayNames("en-US", "narrow", 1); + // en-US narrow weekdays starting Mon: M T W T F S S + expect(names).toEqual(["M", "T", "W", "T", "F", "S", "S"]); + }); + + it("returns 7 entries in Estonian", () => { + const names = getWeekdayNames("et-EE", "short", 1); + expect(names).toHaveLength(7); + }); + }); + + describe("formatLocaleDate", () => { + it("formats et-EE as dd.MM.yyyy", () => { + expect(formatLocaleDate(new Date(2026, 2, 10), "et-EE")).toBe( + "10.03.2026", + ); + }); + + it("formats en-GB as dd/MM/yyyy", () => { + expect(formatLocaleDate(new Date(2026, 2, 10), "en-GB")).toBe( + "10/03/2026", + ); + }); + + it("formats en-US as MM/dd/yyyy", () => { + expect(formatLocaleDate(new Date(2026, 2, 10), "en-US")).toBe( + "03/10/2026", + ); + }); + + it("formats ja-JP", () => { + const formatted = formatLocaleDate(new Date(2026, 2, 10), "ja-JP"); + // ja-JP produces 2026/03/10 (year first, slash separator) + expect(formatted).toMatch(/2026/); + expect(formatted).toMatch(/03/); + expect(formatted).toMatch(/10/); + }); + }); + + describe("parseLocaleDate", () => { + it("parses et-EE dd.MM.yyyy", () => { + expect(parseLocaleDate("10.03.2026", "et-EE")).toEqual( + new Date(2026, 2, 10), + ); + }); + + it("parses en-GB dd/MM/yyyy", () => { + expect(parseLocaleDate("10/03/2026", "en-GB")).toEqual( + new Date(2026, 2, 10), + ); + }); + + it("parses en-US MM/dd/yyyy", () => { + expect(parseLocaleDate("03/10/2026", "en-US")).toEqual( + new Date(2026, 2, 10), + ); + }); + + it("returns undefined for empty input", () => { + expect(parseLocaleDate("", "et-EE")).toBeUndefined(); + expect(parseLocaleDate(" ", "et-EE")).toBeUndefined(); + }); + + it("returns undefined for wrong separator", () => { + expect(parseLocaleDate("10/03/2026", "et-EE")).toBeUndefined(); + }); + + it("returns undefined for impossible date (Feb 30)", () => { + expect(parseLocaleDate("30.02.2026", "et-EE")).toBeUndefined(); + }); + + it("returns undefined for month > 12", () => { + expect(parseLocaleDate("10.13.2026", "et-EE")).toBeUndefined(); + }); + + it("returns undefined for day 0", () => { + expect(parseLocaleDate("00.12.2026", "et-EE")).toBeUndefined(); + }); + + it("trims whitespace from input", () => { + expect(parseLocaleDate(" 10.03.2026 ", "et-EE")).toEqual( + new Date(2026, 2, 10), + ); + }); + + it("round-trips formatLocaleDate output", () => { + const date = new Date(2026, 6, 4); + for (const locale of ["et-EE", "en-US", "en-GB", "ja-JP"]) { + const formatted = formatLocaleDate(date, locale); + expect(parseLocaleDate(formatted, locale)).toEqual(date); + } + }); + + it("rejects 3-digit years (regex over-permissiveness guard)", () => { + expect(parseLocaleDate("10/3/100", "en-US")).toBeUndefined(); + expect(parseLocaleDate("10.3.100", "et-EE")).toBeUndefined(); + }); + }); + + describe("isDateInRange", () => { + it("matches dates strictly inside the range", () => { + const range = { from: new Date(2026, 4, 10), to: new Date(2026, 4, 20) }; + expect(isDateInRange(new Date(2026, 4, 15), range)).toBe(true); + }); + + it("matches both bounds inclusively", () => { + const range = { from: new Date(2026, 4, 10), to: new Date(2026, 4, 20) }; + expect(isDateInRange(new Date(2026, 4, 10), range)).toBe(true); + expect(isDateInRange(new Date(2026, 4, 20), range)).toBe(true); + }); + + it("rejects dates outside the range", () => { + const range = { from: new Date(2026, 4, 10), to: new Date(2026, 4, 20) }; + expect(isDateInRange(new Date(2026, 4, 9), range)).toBe(false); + expect(isDateInRange(new Date(2026, 4, 21), range)).toBe(false); + }); + + it("acts as same-day check when `to` is undefined", () => { + const range = { from: new Date(2026, 4, 15) }; + expect(isDateInRange(new Date(2026, 4, 15), range)).toBe(true); + expect(isDateInRange(new Date(2026, 4, 16), range)).toBe(false); + }); + + it("handles swapped range (from after to)", () => { + const range = { from: new Date(2026, 4, 20), to: new Date(2026, 4, 10) }; + expect(isDateInRange(new Date(2026, 4, 15), range)).toBe(true); + }); + }); + + describe("toggleDateInArray", () => { + it("adds a new date", () => { + const result = toggleDateInArray( + [new Date(2026, 4, 10)], + new Date(2026, 4, 15), + ); + expect(result).toHaveLength(2); + expect(result[1]).toEqual(new Date(2026, 4, 15)); + }); + + it("removes an existing same-day entry", () => { + const result = toggleDateInArray( + [new Date(2026, 4, 10), new Date(2026, 4, 15, 9, 0)], + new Date(2026, 4, 15, 22, 30), + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(new Date(2026, 4, 10)); + }); + + it("returns a new array instance", () => { + const original = [new Date(2026, 4, 10)]; + const result = toggleDateInArray(original, new Date(2026, 4, 15)); + expect(result).not.toBe(original); + }); + + it("adds when starting from empty array", () => { + const result = toggleDateInArray([], new Date(2026, 4, 15)); + expect(result).toEqual([new Date(2026, 4, 15)]); + }); + }); + + describe("buildMonthGrid", () => { + it("always returns 6 rows × 7 cells", () => { + const grid = buildMonthGrid(new Date(2026, 4, 1), 1, true); + expect(grid).toHaveLength(6); + grid.forEach((row) => expect(row).toHaveLength(7)); + }); + + it("places the first day of the month on its correct weekday (Mon-first)", () => { + // May 2026: May 1 is a Friday. With firstDayOfWeek=1 (Mon), Friday is index 4. + const grid = buildMonthGrid(new Date(2026, 4, 1), 1, true); + const firstRow = grid[0]; + expect(firstRow[4]).toEqual(new Date(2026, 4, 1)); + }); + + it("places the first day of the month on its correct weekday (Sun-first)", () => { + // May 2026: May 1 is a Friday. With firstDayOfWeek=0 (Sun), Friday is index 5. + const grid = buildMonthGrid(new Date(2026, 4, 1), 0, true); + const firstRow = grid[0]; + expect(firstRow[5]).toEqual(new Date(2026, 4, 1)); + }); + + it("returns real Date objects for outside days when showOutsideDays=true", () => { + // May 2026: Mon-first → leading cells are Apr 27, 28, 29, 30 + const grid = buildMonthGrid(new Date(2026, 4, 1), 1, true); + expect(grid[0][0]).toEqual(new Date(2026, 3, 27)); + expect(grid[0][3]).toEqual(new Date(2026, 3, 30)); + }); + + it("returns null for outside days when showOutsideDays=false", () => { + const grid = buildMonthGrid(new Date(2026, 4, 1), 1, false); + expect(grid[0][0]).toBeNull(); + expect(grid[0][3]).toBeNull(); + expect(grid[0][4]).toEqual(new Date(2026, 4, 1)); + }); + + it("does not include null for in-month days", () => { + const grid = buildMonthGrid(new Date(2026, 4, 15), 1, false); + const inMonthCells = grid + .flat() + .filter((c) => c !== null && c.getMonth() === 4); + expect(inMonthCells).toHaveLength(31); + }); + + it("fills trailing cells correctly", () => { + // February 2025: 28 days. Feb 1 is a Saturday (getDay=6). + // Mon-first → Feb 1 is at index 5. Feb has 28 days → cells 5..32 (=index 32 is Feb 28). + // Trailing cells 33..41 are Mar 1..9. + const grid = buildMonthGrid(new Date(2025, 1, 1), 1, true); + expect(grid[5][6]).toEqual(new Date(2025, 2, 9)); + }); + }); }); diff --git a/tedi/utils/date.util.ts b/tedi/utils/date.util.ts index 659c63ec7..eb131760b 100644 --- a/tedi/utils/date.util.ts +++ b/tedi/utils/date.util.ts @@ -1,35 +1,29 @@ +/** + * A date range with an inclusive start (`from`) and optional inclusive end (`to`). + * When `to` is undefined the range degenerates to the single day `from`. + */ +export type DateRange = { from: Date; to?: Date }; + /** * Formats a Date object to dd.MM.yyyy string format. + * + * Thin wrapper around `formatLocaleDate(date, 'et-EE')` kept for back-compat. + * New code should call `formatLocaleDate` directly with the desired locale. */ export function formatDate(date: Date): string { - const d = String(date.getDate()).padStart(2, "0"); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const y = date.getFullYear(); - return `${d}.${m}.${y}`; + return formatLocaleDate(date, "et-EE"); } /** * Parses a dd.MM.yyyy string to a Date object. * Returns null if the string is invalid. + * + * Thin wrapper around `parseLocaleDate(str, 'et-EE')` kept for back-compat. + * Note: `parseLocaleDate` returns `Date | undefined`; this wrapper normalizes + * undefined to null so existing callers asserting `=== null` still work. */ export function parseDate(str: string): Date | null { - const parts = str.trim().split("."); - if (parts.length !== 3) return null; - - const [dd, mm, yyyy] = parts.map(Number); - if (!dd || !mm || !yyyy) return null; - - const date = new Date(yyyy, mm - 1, dd); - - if ( - date.getFullYear() !== yyyy || - date.getMonth() !== mm - 1 || - date.getDate() !== dd - ) { - return null; - } - - return date; + return parseLocaleDate(str, "et-EE") ?? null; } /** @@ -44,7 +38,7 @@ export function isSameDay(a: Date, b: Date): boolean { } /** - * Checks if date a is before date b. + * Checks if date a is before date b (day-level, time ignored). */ export function isBeforeDay(a: Date, b: Date): boolean { if (a.getFullYear() !== b.getFullYear()) { @@ -57,7 +51,7 @@ export function isBeforeDay(a: Date, b: Date): boolean { } /** - * Checks if date a is after date b. + * Checks if date a is after date b (day-level, time ignored). */ export function isAfterDay(a: Date, b: Date): boolean { if (a.getFullYear() !== b.getFullYear()) { @@ -87,3 +81,337 @@ export function getISOWeek(date: Date): number { ); return Math.floor(diffInDays / 7) + 1; } + +/** + * Returns a new Date offset by `n` days from `date`. `n` may be negative. + */ +export function addDays(date: Date, n: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + n); + return result; +} + +/** + * Returns a new Date offset by `n` months from `date`. Clamps the day to the + * last day of the target month (e.g. Jan 31 + 1 month → Feb 28/29). + */ +export function addMonths(date: Date, n: number): Date { + const targetYear = date.getFullYear(); + const targetMonthRaw = date.getMonth() + n; + const targetMonth = ((targetMonthRaw % 12) + 12) % 12; + const yearOffset = Math.floor(targetMonthRaw / 12); + const year = targetYear + yearOffset; + const day = Math.min(date.getDate(), getDaysInMonth(year, targetMonth)); + const result = new Date(date); + result.setFullYear(year, targetMonth, day); + return result; +} + +/** + * Returns a new Date offset by `n` years from `date`. Clamps Feb 29 to Feb 28 + * in non-leap target years. + */ +export function addYears(date: Date, n: number): Date { + const targetYear = date.getFullYear() + n; + const month = date.getMonth(); + const day = Math.min(date.getDate(), getDaysInMonth(targetYear, month)); + const result = new Date(date); + result.setFullYear(targetYear, month, day); + return result; +} + +/** + * Returns the first day of the month for `date` at 00:00 local time. + */ +export function startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +/** + * Returns the last day of the month for `date` at 00:00 local time. + */ +export function endOfMonth(date: Date): Date { + return new Date( + date.getFullYear(), + date.getMonth(), + getDaysInMonth(date.getFullYear(), date.getMonth()), + ); +} + +/** + * Returns the first day of the week containing `date`, given a locale's + * first day of week (0=Sun..6=Sat). + */ +export function startOfWeek(date: Date, firstDayOfWeek: number): Date { + const result = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const offset = (result.getDay() - firstDayOfWeek + 7) % 7; + result.setDate(result.getDate() - offset); + return result; +} + +/** + * Returns the number of days in the given month/year. `monthIndex` is 0-based. + */ +export function getDaysInMonth(year: number, monthIndex: number): number { + return new Date(year, monthIndex + 1, 0).getDate(); +} + +/** + * Checks if two dates fall in the same calendar month and year. + */ +export function isSameMonth(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); +} + +/** + * Checks if two dates fall in the same calendar year. + */ +export function isSameYear(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear(); +} + +type WeekInfo = { firstDay: number }; +type LocaleWithWeekInfo = { + weekInfo?: WeekInfo; + getWeekInfo?: () => WeekInfo; +}; + +/** + * Returns the locale's first day of week as a JS day index (0=Sun..6=Sat). + * + * Reads `Intl.Locale(localeCode).getWeekInfo()` when available, falling back + * to 1 (Monday) when unavailable. Intl returns 1-7 (1=Mon..7=Sun); this + * function converts to JS's 0-6 convention. + */ +export function getFirstDayOfWeek(localeCode: string): number { + try { + const locale = new Intl.Locale(localeCode) as Intl.Locale & + LocaleWithWeekInfo; + const info = locale.getWeekInfo?.() ?? locale.weekInfo; + if (info && typeof info.firstDay === "number") { + return info.firstDay === 7 ? 0 : info.firstDay; + } + } catch { + // ignore — fall through to default + } + return 1; +} + +/** + * Returns 12 month names (January..December) in the requested locale and format. + */ +export function getMonthNames( + localeCode: string, + format: "long" | "short", +): string[] { + const fmt = new Intl.DateTimeFormat(localeCode, { month: format }); + const names: string[] = []; + for (let i = 0; i < 12; i++) { + names.push(fmt.format(new Date(2021, i, 1))); + } + return names; +} + +/** + * Returns 7 weekday names starting at `firstDayOfWeek` (0=Sun..6=Sat) in the + * requested locale and format. + */ +export function getWeekdayNames( + localeCode: string, + format: "short" | "narrow", + firstDayOfWeek: number, +): string[] { + const fmt = new Intl.DateTimeFormat(localeCode, { weekday: format }); + // 2021-08-01 was a Sunday → adding `i` days gives weekday `i` (0..6). + const sunday = new Date(2021, 7, 1); + const names: string[] = []; + for (let i = 0; i < 7; i++) { + const dayIndex = (firstDayOfWeek + i) % 7; + const reference = new Date(sunday); + reference.setDate(sunday.getDate() + dayIndex); + names.push(fmt.format(reference)); + } + return names; +} + +/** + * Formats a Date according to the given locale's short numeric form. + * For `et-EE` produces `dd.MM.yyyy`; for `en-US` `MM/dd/yyyy`; for `en-GB` + * `dd/MM/yyyy`; etc. + */ +export function formatLocaleDate(date: Date, localeCode: string): string { + const fmt = new Intl.DateTimeFormat(localeCode, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return fmt.format(date); +} + +type DateFieldName = "day" | "month" | "year"; + +function isDateFieldType(type: string): type is DateFieldName { + return type === "day" || type === "month" || type === "year"; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +type LocalePattern = { order: DateFieldName[]; regex: RegExp }; + +const FIELD_DIGIT_RANGE: Record = { + day: "\\d{1,2}", + month: "\\d{1,2}", + // Year accepts 2 or 4 digits; 3-digit years (e.g. 100) are invalid input. + year: "(?:\\d{2}|\\d{4})", +}; + +function buildLocalePattern(localeCode: string): LocalePattern | undefined { + const fmt = new Intl.DateTimeFormat(localeCode, { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const parts = fmt.formatToParts(new Date(2024, 0, 2)); + const order: DateFieldName[] = []; + let pattern = ""; + for (const part of parts) { + if (isDateFieldType(part.type)) { + order.push(part.type); + pattern += `(${FIELD_DIGIT_RANGE[part.type]})`; + } else { + pattern += escapeRegExp(part.value); + } + } + if (order.length !== 3) return undefined; + return { order, regex: new RegExp(`^${pattern}$`) }; +} + +function extractFields( + match: RegExpMatchArray, + order: DateFieldName[], +): { year: number; month: number; day: number } | undefined { + let year = 0; + let month = 0; + let day = 0; + for (let i = 0; i < order.length; i++) { + const num = Number(match[i + 1]); + if (!Number.isFinite(num)) return undefined; + if (order[i] === "year") year = num; + else if (order[i] === "month") month = num; + else day = num; + } + return { year, month, day }; +} + +function buildValidatedDate( + year: number, + month: number, + day: number, +): Date | undefined { + if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31) { + return undefined; + } + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined; + } + return date; +} + +/** + * Parses a locale-formatted date string back into a Date. + * + * Algorithm (mirrors React's defaultParseDate): + * 1. Format a reference date with distinct y/m/d digits via Intl.DateTimeFormat. + * 2. Walk `formatToParts` to discover the field order and the literal + * separators the locale uses. + * 3. Build a regex from that ordering using field-aware digit ranges + * (day/month: 1-2 digits, year: 2 or 4 digits) and the actual escaped + * separators. 3-digit years are rejected. + * 4. Match the (trimmed) input; reject if no match. + * 5. Validate ranges and check the constructed Date round-trips (so e.g. + * Feb 30 is rejected). + * + * Returns `undefined` on any failure — silent, caller decides UX. + */ +export function parseLocaleDate( + value: string, + localeCode: string, +): Date | undefined { + const input = value.trim(); + if (!input) return undefined; + + const pattern = buildLocalePattern(localeCode); + if (!pattern) return undefined; + + const match = input.match(pattern.regex); + if (!match) return undefined; + + const fields = extractFields(match, pattern.order); + if (!fields) return undefined; + + return buildValidatedDate(fields.year, fields.month, fields.day); +} + +/** + * Returns true if `date` falls within the inclusive `[from, to]` range + * (day-level). When `to` is undefined, only `from` matters (same-day check). + */ +export function isDateInRange(date: Date, range: DateRange): boolean { + if (range.to === undefined) { + return isSameDay(date, range.from); + } + const start = isBeforeDay(range.from, range.to) ? range.from : range.to; + const end = isBeforeDay(range.from, range.to) ? range.to : range.from; + return !isBeforeDay(date, start) && !isAfterDay(date, end); +} + +/** + * Toggles a date in an array of dates (day-level). If a same-day entry exists + * it is removed; otherwise the date is appended. Returns a new array. + */ +export function toggleDateInArray(dates: Date[], date: Date): Date[] { + const index = dates.findIndex((d) => isSameDay(d, date)); + if (index >= 0) { + return dates.slice(0, index).concat(dates.slice(index + 1)); + } + return [...dates, date]; +} + +/** + * Builds a 6×7 grid of Date cells for the calendar day view. + * + * - The first weekday of the month is found, then we walk back to + * `firstDayOfWeek` to fill leading cells. + * - Cells outside the current month are real Date objects when + * `showOutsideDays` is true, otherwise `null`. + * - Always returns 6 rows × 7 cells (42 cells total). + */ +export function buildMonthGrid( + month: Date, + firstDayOfWeek: number, + showOutsideDays: boolean, +): (Date | null)[][] { + const monthStart = startOfMonth(month); + const gridStart = startOfWeek(monthStart, firstDayOfWeek); + const rows: (Date | null)[][] = []; + for (let row = 0; row < 6; row++) { + const cells: (Date | null)[] = []; + for (let col = 0; col < 7; col++) { + const cell = addDays(gridStart, row * 7 + col); + if (isSameMonth(cell, month)) { + cells.push(cell); + } else { + cells.push(showOutsideDays ? cell : null); + } + } + rows.push(cells); + } + return rows; +} diff --git a/tedi/utils/index.ts b/tedi/utils/index.ts new file mode 100644 index 000000000..9e47e1779 --- /dev/null +++ b/tedi/utils/index.ts @@ -0,0 +1,5 @@ +export * from "./cookies.util"; +export * from "./date.util"; +export * from "./elements.util"; +export * from "./matchers.util"; +export * from "./time.util"; diff --git a/tedi/utils/matchers.util.spec.ts b/tedi/utils/matchers.util.spec.ts new file mode 100644 index 000000000..b8338b68e --- /dev/null +++ b/tedi/utils/matchers.util.spec.ts @@ -0,0 +1,211 @@ +import { matchAny, matchDate, Matcher } from "./matchers.util"; + +describe("matchers.util", () => { + const day = (y: number, m: number, d: number) => new Date(y, m, d); + + describe("matchDate", () => { + describe("boolean matcher", () => { + it("returns true for true", () => { + expect(matchDate(day(2026, 0, 1), true)).toBe(true); + }); + + it("returns false for false", () => { + expect(matchDate(day(2026, 0, 1), false)).toBe(false); + }); + }); + + describe("Date matcher", () => { + it("returns true when same day", () => { + expect(matchDate(day(2026, 4, 15), day(2026, 4, 15))).toBe(true); + }); + + it("returns true when same day with different times", () => { + const a = new Date(2026, 4, 15, 9, 0); + const b = new Date(2026, 4, 15, 23, 30); + expect(matchDate(a, b)).toBe(true); + }); + + it("returns false when different days", () => { + expect(matchDate(day(2026, 4, 15), day(2026, 4, 16))).toBe(false); + }); + }); + + describe("Date[] matcher", () => { + it("returns true when any entry is same day", () => { + const list = [day(2026, 0, 1), day(2026, 4, 15), day(2026, 11, 31)]; + expect(matchDate(day(2026, 4, 15), list)).toBe(true); + }); + + it("returns false when none match", () => { + const list = [day(2026, 0, 1), day(2026, 11, 31)]; + expect(matchDate(day(2026, 4, 15), list)).toBe(false); + }); + + it("returns false for empty array", () => { + expect(matchDate(day(2026, 4, 15), [])).toBe(false); + }); + }); + + describe("DateBefore matcher", () => { + it("returns true when date is strictly before", () => { + expect( + matchDate(day(2026, 4, 14), { before: day(2026, 4, 15) }), + ).toBe(true); + }); + + it("returns false when same day", () => { + expect( + matchDate(day(2026, 4, 15), { before: day(2026, 4, 15) }), + ).toBe(false); + }); + + it("returns false when after", () => { + expect( + matchDate(day(2026, 4, 16), { before: day(2026, 4, 15) }), + ).toBe(false); + }); + + it("ignores time of day", () => { + const date = new Date(2026, 4, 15, 23, 59); + const before = new Date(2026, 4, 15, 0, 0); + expect(matchDate(date, { before })).toBe(false); + }); + }); + + describe("DateAfter matcher", () => { + it("returns true when date is strictly after", () => { + expect( + matchDate(day(2026, 4, 16), { after: day(2026, 4, 15) }), + ).toBe(true); + }); + + it("returns false when same day", () => { + expect( + matchDate(day(2026, 4, 15), { after: day(2026, 4, 15) }), + ).toBe(false); + }); + + it("returns false when before", () => { + expect( + matchDate(day(2026, 4, 14), { after: day(2026, 4, 15) }), + ).toBe(false); + }); + }); + + describe("DateInterval matcher (outside semantics)", () => { + const interval = { before: day(2026, 4, 20), after: day(2026, 4, 10) }; + + it("returns true at or before after-bound", () => { + expect(matchDate(day(2026, 4, 10), interval)).toBe(true); + expect(matchDate(day(2026, 4, 9), interval)).toBe(true); + expect(matchDate(day(2026, 0, 1), interval)).toBe(true); + }); + + it("returns true at or after before-bound", () => { + expect(matchDate(day(2026, 4, 20), interval)).toBe(true); + expect(matchDate(day(2026, 4, 21), interval)).toBe(true); + expect(matchDate(day(2026, 11, 31), interval)).toBe(true); + }); + + it("returns false strictly inside the interval", () => { + expect(matchDate(day(2026, 4, 11), interval)).toBe(false); + expect(matchDate(day(2026, 4, 15), interval)).toBe(false); + expect(matchDate(day(2026, 4, 19), interval)).toBe(false); + }); + }); + + describe("DateRange matcher (inclusive)", () => { + it("matches date inside the range", () => { + const range = { from: day(2026, 4, 10), to: day(2026, 4, 20) }; + expect(matchDate(day(2026, 4, 15), range)).toBe(true); + }); + + it("matches both bounds inclusively", () => { + const range = { from: day(2026, 4, 10), to: day(2026, 4, 20) }; + expect(matchDate(day(2026, 4, 10), range)).toBe(true); + expect(matchDate(day(2026, 4, 20), range)).toBe(true); + }); + + it("rejects dates outside the range", () => { + const range = { from: day(2026, 4, 10), to: day(2026, 4, 20) }; + expect(matchDate(day(2026, 4, 9), range)).toBe(false); + expect(matchDate(day(2026, 4, 21), range)).toBe(false); + }); + + it("treats `to: undefined` like a single-day match", () => { + const range = { from: day(2026, 4, 15) }; + expect(matchDate(day(2026, 4, 15), range)).toBe(true); + expect(matchDate(day(2026, 4, 16), range)).toBe(false); + }); + + it("handles a swapped range (from after to)", () => { + const range = { from: day(2026, 4, 20), to: day(2026, 4, 10) }; + expect(matchDate(day(2026, 4, 15), range)).toBe(true); + expect(matchDate(day(2026, 4, 10), range)).toBe(true); + expect(matchDate(day(2026, 4, 20), range)).toBe(true); + }); + }); + + describe("DayOfWeek matcher", () => { + it("matches when date.getDay() is in the array", () => { + // 2026-05-16 is a Saturday (getDay() === 6) + const sat = day(2026, 4, 16); + expect(matchDate(sat, { dayOfWeek: [0, 6] })).toBe(true); + }); + + it("does not match when not in the array", () => { + // 2026-05-15 is a Friday (getDay() === 5) + const fri = day(2026, 4, 15); + expect(matchDate(fri, { dayOfWeek: [0, 6] })).toBe(false); + }); + + it("empty array never matches", () => { + expect(matchDate(day(2026, 4, 15), { dayOfWeek: [] })).toBe(false); + }); + }); + + describe("function matcher", () => { + it("calls the function with the date", () => { + const fn = jest.fn((d: Date) => d.getFullYear() === 2026); + expect(matchDate(day(2026, 4, 15), fn)).toBe(true); + expect(fn).toHaveBeenCalledWith(day(2026, 4, 15)); + }); + + it("returns false when the predicate returns false", () => { + expect(matchDate(day(2026, 4, 15), () => false)).toBe(false); + }); + }); + }); + + describe("matchAny", () => { + it("returns false for empty array", () => { + expect(matchAny(new Date(2026, 4, 15), [])).toBe(false); + }); + + it("returns true if any matcher matches", () => { + const matchers: Matcher[] = [ + false, + new Date(2026, 0, 1), + { before: new Date(2026, 4, 15) }, + new Date(2026, 4, 15), + ]; + expect(matchAny(new Date(2026, 4, 15), matchers)).toBe(true); + }); + + it("returns false when no matcher matches", () => { + const matchers: Matcher[] = [ + false, + new Date(2026, 0, 1), + { after: new Date(2026, 4, 20) }, + ]; + expect(matchAny(new Date(2026, 4, 15), matchers)).toBe(false); + }); + + it("short-circuits — does not call later matchers after a true", () => { + const later = jest.fn(() => true); + const result = matchAny(new Date(2026, 4, 15), [true, later]); + expect(result).toBe(true); + expect(later).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tedi/utils/matchers.util.ts b/tedi/utils/matchers.util.ts new file mode 100644 index 000000000..d0302d8c5 --- /dev/null +++ b/tedi/utils/matchers.util.ts @@ -0,0 +1,132 @@ +import { + DateRange, + isAfterDay, + isBeforeDay, + isDateInRange, + isSameDay, +} from "./date.util"; + +export type DateBefore = { before: Date }; +export type DateAfter = { after: Date }; +export type DateInterval = { before: Date; after: Date }; +export type DayOfWeek = { dayOfWeek: number[] }; + +export type Matcher = + | boolean + | Date + | Date[] + | DateBefore + | DateAfter + | DateInterval + | DateRange + | DayOfWeek + | ((date: Date) => boolean); + +function isDate(value: unknown): value is Date { + return value instanceof Date; +} + +function isDateArray(value: unknown): value is Date[] { + return Array.isArray(value) && value.every(isDate); +} + +function isDateInterval(value: object): value is DateInterval { + return ( + "before" in value && + "after" in value && + isDate((value as { before: unknown }).before) && + isDate((value as { after: unknown }).after) + ); +} + +function isDateBefore(value: object): value is DateBefore { + return ( + "before" in value && + !("after" in value) && + isDate((value as { before: unknown }).before) + ); +} + +function isDateAfter(value: object): value is DateAfter { + return ( + "after" in value && + !("before" in value) && + isDate((value as { after: unknown }).after) + ); +} + +function isDateRange(value: object): value is DateRange { + if (!("from" in value)) return false; + const from = (value as { from: unknown }).from; + if (!isDate(from)) return false; + if ("to" in value) { + const to = (value as { to: unknown }).to; + return to === undefined || isDate(to); + } + return true; +} + +function isDayOfWeek(value: object): value is DayOfWeek { + return ( + "dayOfWeek" in value && + Array.isArray((value as { dayOfWeek: unknown }).dayOfWeek) && + ((value as { dayOfWeek: unknown[] }).dayOfWeek).every( + (n) => typeof n === "number", + ) + ); +} + +function matchObjectMatcher( + date: Date, + matcher: DateBefore | DateAfter | DateInterval | DateRange | DayOfWeek, +): boolean { + if (isDateInterval(matcher)) { + return ( + !isAfterDay(date, matcher.after) || !isBeforeDay(date, matcher.before) + ); + } + if (isDateBefore(matcher)) return isBeforeDay(date, matcher.before); + if (isDateAfter(matcher)) return isAfterDay(date, matcher.after); + if (isDayOfWeek(matcher)) return matcher.dayOfWeek.includes(date.getDay()); + if (isDateRange(matcher)) return matchDateRange(date, matcher); + return false; +} + +function matchDateRange(date: Date, range: DateRange): boolean { + return isDateInRange(date, range); +} + +/** + * Returns whether `date` matches the given matcher. + * + * Semantics mirror react-day-picker: + * - `boolean` — returned as-is. + * - `Date` — true if same day. + * - `Date[]` — true if any entry is the same day. + * - `{ before }` — true if date is strictly before `before` (day-level). + * - `{ after }` — true if date is strictly after `after` (day-level). + * - `{ before, after }` — true if date is OUTSIDE the interval `(after, before)` + * (i.e. `date <= after || date >= before`, day-level). + * - `{ from, to? }` — true if date is within `[from, to]` inclusive (day-level). + * If `to` is omitted, only `from` matters. + * - `{ dayOfWeek }` — true if `date.getDay()` is in the array. + * - function — called with the date. + */ +export function matchDate(date: Date, matcher: Matcher): boolean { + if (typeof matcher === "boolean") return matcher; + if (typeof matcher === "function") return matcher(date); + if (isDate(matcher)) return isSameDay(date, matcher); + if (isDateArray(matcher)) return matcher.some((d) => isSameDay(date, d)); + if (typeof matcher === "object" && matcher !== null) { + return matchObjectMatcher(date, matcher); + } + return false; +} + +/** + * Returns true if any matcher in the list matches the given date. + * An empty array returns false. + */ +export function matchAny(date: Date, matchers: Matcher[]): boolean { + return matchers.some((m) => matchDate(date, m)); +} From 57c0dbf4155e8ef9196739394fa7e160c711b1af Mon Sep 17 00:00:00 2001 From: m2rt Date: Tue, 19 May 2026 09:12:19 +0300 Subject: [PATCH 2/3] feat(date-field,calendar): calendar wcag improvements #6 --- .storybook/preview-head.html | 6 + .../calendar-day-grid.component.html | 36 +- .../calendar-day-grid.component.scss | 7 + .../calendar-day-grid.component.spec.ts | 121 ++++++- .../calendar-day-grid.component.ts | 72 +++- .../calendar-header.component.html | 27 +- .../calendar-header.component.scss | 3 + .../calendar-header.component.spec.ts | 47 +++ .../calendar-header.component.ts | 19 +- .../calendar-month-grid.component.html | 45 ++- .../calendar-month-grid.component.scss | 4 + .../calendar-month-grid.component.spec.ts | 28 +- .../calendar-month-grid.component.ts | 21 ++ .../calendar-year-grid.component.html | 44 ++- .../calendar-year-grid.component.scss | 4 + .../calendar-year-grid.component.spec.ts | 28 +- .../calendar-year-grid.component.ts | 17 + .../content/calendar/calendar.component.html | 13 +- .../content/calendar/calendar.component.scss | 1 + .../calendar/calendar.component.spec.ts | 4 + .../content/calendar/calendar.component.ts | 10 +- .../content/calendar/calendar.stories.ts | 291 ++++++++-------- .../form/date-field/date-field.stories.ts | 329 ++++++------------ tedi/services/translation/translations.ts | 46 +++ tedi/utils/date.util.ts | 34 +- 25 files changed, 836 insertions(+), 421 deletions(-) diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 86a1b5f29..d675c6a82 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -2,4 +2,10 @@ .sbdocs.sbdocs-content { max-width: 1200px; } + + body.sb-show-main, + body.sb-show-main #storybook-root { + height: auto !important; + min-height: 100vh !important; + } diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html index e62f0bcb6..c9a74562b 100644 --- a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html @@ -2,18 +2,26 @@ class="tedi-calendar-day-grid" [class.tedi-calendar-day-grid--with-week-numbers]="showWeekNumbers()" role="grid" + [attr.aria-label]="gridAriaLabel()" + [attr.aria-multiselectable]="multiselectable() ? 'true' : null" > @if (showWeekNumbers()) { } - @for (name of weekdayNames(); track $index) { - - {{ name }} + @for (name of weekdayNames(); track $index; let i = $index) { + + } @@ -22,8 +30,13 @@ @for (row of grid(); track rowKey(row, $index)) { @if (showWeekNumbers()) { - - {{ weekNumber(row) }} + + } @for (day of row; track cellKey(day, $index)) { @@ -34,15 +47,24 @@ [class]="cellState(day)" [attr.aria-selected]="isSelected(day) ? 'true' : null" [attr.aria-disabled]="isDisabled(day) ? 'true' : null" + [attr.aria-label]="ariaLabelForDay(day)" [attr.tabindex]="isFocusable(day) ? 0 : -1" [attr.data-date-key]="day.getTime()" (click)="handleClick(day)" (mouseenter)="handleMouseEnter(day)" (mouseleave)="handleMouseLeave()" (focus)="handleFocus(day)" - (blur)="handleBlur()" + (blur)="handleBlur($event)" > {{ day.getDate() }} + @let status = statusForDay(day); + @if (status) { + + } } diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss index a3bb9c81f..d33074b7b 100644 --- a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss @@ -183,5 +183,12 @@ cursor: not-allowed; opacity: 0.3; } + + tedi-status-indicator { + position: absolute; + top: var(--tedi-borders-02); + right: 0; + pointer-events: none; + } } } diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts index a10d07c6a..dfd0829cb 100644 --- a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts @@ -3,6 +3,17 @@ import { By } from "@angular/platform-browser"; import { CalendarDayGridComponent } from "./calendar-day-grid.component"; import { DateRange } from "../../../../utils/date.util"; import { Matcher } from "../../../../utils/matchers.util"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; +import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; + +class TranslationMock { + translate(key: string): string { + return key; + } + track(key: string): () => string { + return () => key; + } +} describe("CalendarDayGridComponent", () => { let fixture: ComponentFixture; @@ -13,6 +24,10 @@ describe("CalendarDayGridComponent", () => { function createComponent(): void { TestBed.configureTestingModule({ imports: [CalendarDayGridComponent], + providers: [ + { provide: TediTranslationService, useClass: TranslationMock }, + { provide: TEDI_TRANSLATION_DEFAULT_TOKEN, useValue: "et" }, + ], }); fixture = TestBed.createComponent(CalendarDayGridComponent); component = fixture.componentInstance; @@ -367,18 +382,40 @@ describe("CalendarDayGridComponent", () => { ), ).toBe(true); - component.handleBlur(); + component.handleBlur(new FocusEvent("blur")); fixture.detectChanges(); expect(hasAnyPreviewClass()).toBe(false); }); + it("keeps preview classes when focus moves to a sibling day cell (no flash)", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); + fixture.detectChanges(); + + component.handleFocus(new Date(2024, 4, 14)); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(true); + + // Simulate the browser-native blur → focus chain that fires when the + // user clicks a different day in the same grid. relatedTarget points + // at the incoming cell — blur should NOT clear hoveredDate, because + // doing so would render one frame with no preview (visible as a flash). + const nextCell = buttonForDay(new Date(2024, 4, 16)); + expect(nextCell).toBeTruthy(); + component.handleBlur( + new FocusEvent("blur", { relatedTarget: nextCell as EventTarget }), + ); + fixture.detectChanges(); + expect(hasAnyPreviewClass()).toBe(true); + }); + it("does not apply preview classes on focus when not in range mode", () => { fixture.componentRef.setInput("value", { from: new Date(2024, 4, 10) }); fixture.detectChanges(); component.handleFocus(new Date(2024, 4, 14)); fixture.detectChanges(); expect(hasAnyPreviewClass()).toBe(false); - component.handleBlur(); + component.handleBlur(new FocusEvent("blur")); fixture.detectChanges(); expect(hasAnyPreviewClass()).toBe(false); }); @@ -485,4 +522,84 @@ describe("CalendarDayGridComponent", () => { expect(component.weekNumber([null, null, null, null, null, null, null])).toBeNull(); }); }); + + describe("a11y", () => { + function gridTable(): HTMLElement { + return fixture.debugElement.query(By.css(".tedi-calendar-day-grid")) + .nativeElement as HTMLElement; + } + + it("sets aria-label on the grid to the month/year", () => { + const label = gridTable().getAttribute("aria-label"); + // formatMonthYear via Intl yields locale-specific output; we just assert + // both the month name and year appear in it. + expect(label).toMatch(/2024/); + expect(label?.toLowerCase()).toContain("mai"); + }); + + it("omits aria-multiselectable in single mode", () => { + expect(gridTable().getAttribute("aria-multiselectable")).toBeNull(); + }); + + it("sets aria-multiselectable=true in multiple mode", () => { + fixture.componentRef.setInput("mode", "multiple"); + fixture.detectChanges(); + expect(gridTable().getAttribute("aria-multiselectable")).toBe("true"); + }); + + it("sets aria-multiselectable=true in range mode", () => { + fixture.componentRef.setInput("mode", "range"); + fixture.detectChanges(); + expect(gridTable().getAttribute("aria-multiselectable")).toBe("true"); + }); + + it("exposes a long aria-label on each weekday header", () => { + const headers = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid__weekday"), + ); + const labels = headers.map( + (h) => (h.nativeElement as HTMLElement).getAttribute("aria-label"), + ); + // Estonian locale "et" returns "esmaspäev" .. "pühapäev" for `long`. + for (const label of labels) { + expect(label && label.length > 3).toBe(true); + } + }); + + it("hides the visual narrow weekday text from screen readers", () => { + const headers = fixture.debugElement.queryAll( + By.css(".tedi-calendar-day-grid__weekday span"), + ); + for (const h of headers) { + expect((h.nativeElement as HTMLElement).getAttribute("aria-hidden")).toBe( + "true", + ); + } + }); + + it("week number cell is a rowheader with translated aria-label", () => { + fixture.componentRef.setInput("showWeekNumbers", true); + fixture.detectChanges(); + const cell = fixture.debugElement.query( + By.css(".tedi-calendar-day-grid__week-number"), + ); + expect(cell.nativeElement.getAttribute("role")).toBe("rowheader"); + expect(cell.nativeElement.getAttribute("scope")).toBe("row"); + expect(cell.nativeElement.getAttribute("aria-label")).toBe( + "date-picker.week-number", + ); + }); + + it("week number column header has scope=col and translated aria-label", () => { + fixture.componentRef.setInput("showWeekNumbers", true); + fixture.detectChanges(); + const header = fixture.debugElement.query( + By.css(".tedi-calendar-day-grid__week-number-header"), + ); + expect(header.nativeElement.getAttribute("scope")).toBe("col"); + expect(header.nativeElement.getAttribute("aria-label")).toBe( + "date-picker.week-number-header", + ); + }); + }); }); diff --git a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts index 7b57ed944..20faaf390 100644 --- a/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, + inject, input, model, output, @@ -10,6 +11,8 @@ import { import { buildMonthGrid, DateRange, + formatLocaleDateLong, + formatMonthYear, getISOWeek, getWeekdayNames, isAfterDay, @@ -18,15 +21,31 @@ import { isSameMonth, } from "../../../../utils/date.util"; import { matchAny, Matcher } from "../../../../utils/matchers.util"; +import { + StatusIndicatorComponent, + StatusIndicatorType, +} from "../../../tags/status-indicator/status-indicator.component"; +import { TediTranslationService } from "../../../../services/translation/translation.service"; import { DateFieldMode } from "../types"; type DayPredicate = (date: Date) => boolean; type DayAvailabilityInput = Date[] | DayPredicate | undefined; type CalendarValue = Date | Date[] | DateRange | null; +export interface DayStatus { + type: StatusIndicatorType; + /** + * Accessible label for the status. Surfaced on the day button's `aria-label` + * so screen readers announce the date together with its status — required to + * meet WCAG 1.1.1 / 1.4.1 since the indicator dot alone is decorative. + */ + label: string; +} +export type DayStatusFn = (date: Date) => DayStatus | null | undefined; @Component({ selector: "tedi-calendar-day-grid", standalone: true, + imports: [StatusIndicatorComponent], templateUrl: "./calendar-day-grid.component.html", styleUrl: "./calendar-day-grid.component.scss", encapsulation: ViewEncapsulation.None, @@ -44,6 +63,9 @@ export class CalendarDayGridComponent { readonly availableDays = input(undefined); readonly unavailableDays = input(undefined); readonly inputDisabled = input(false); + readonly dayStatus = input(undefined); + + private readonly translation = inject(TediTranslationService); readonly daySelect = output(); @@ -53,6 +75,28 @@ export class CalendarDayGridComponent { getWeekdayNames(this.localeCode(), "narrow", this.firstDayOfWeek()), ); + readonly weekdayFullNames = computed(() => + getWeekdayNames(this.localeCode(), "long", this.firstDayOfWeek()), + ); + + readonly gridAriaLabel = computed(() => + formatMonthYear(this.month(), this.localeCode()), + ); + + readonly multiselectable = computed( + () => this.mode() === "multiple" || this.mode() === "range", + ); + + readonly weekNumberHeaderLabel = computed(() => + this.translation.translate("date-picker.week-number-header"), + ); + + weekNumberLabel(row: (Date | null)[]): string | null { + const value = this.weekNumber(row); + if (value === null) return null; + return this.translation.translate("date-picker.week-number", value); + } + readonly grid = computed(() => buildMonthGrid( this.month(), @@ -85,6 +129,25 @@ export class CalendarDayGridComponent { return firstSelectable ? this.dayKey(firstSelectable) : null; }); + statusForDay(day: Date | null): DayStatus | null { + if (!day) return null; + const fn = this.dayStatus(); + if (!fn) return null; + return fn(day) ?? null; + } + + ariaLabelForDay(day: Date | null): string | null { + if (!day) return null; + const parts: string[] = []; + if (isSameDay(day, new Date())) { + parts.push(this.translation.translate("date-picker.today")); + } + parts.push(formatLocaleDateLong(day, this.localeCode())); + const status = this.statusForDay(day); + if (status?.label) parts.push(status.label); + return parts.join(", "); + } + cellState(day: Date | null): string { if (!day) return ""; const modifiers: string[] = []; @@ -242,8 +305,15 @@ export class CalendarDayGridComponent { this.hoveredDate.set(day); } - handleBlur(): void { + handleBlur(event: FocusEvent): void { if (this.mode() !== "range") return; + const next = event.relatedTarget as HTMLElement | null; + // Clicking a sibling day fires blur on the previous focus target before + // focus lands on the new one. Clearing here would render one frame with + // no preview, which the user sees as a flash. Skip the clear when focus + // is moving to another cell in the same grid — the incoming focus handler + // will overwrite hoveredDate with the new day. + if (next?.closest(".tedi-calendar-day-grid")) return; this.hoveredDate.set(null); } diff --git a/tedi/components/content/calendar/calendar-header/calendar-header.component.html b/tedi/components/content/calendar/calendar-header/calendar-header.component.html index 2d175b162..01b31ce52 100644 --- a/tedi/components/content/calendar/calendar-header/calendar-header.component.html +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.html @@ -1,6 +1,7 @@ -
    @if (showNavigation()) { + } @else { + + {{ currentMonthLabel() }} + } @if (monthYearSelectType() === "dropdown") { @@ -105,7 +110,7 @@ } - } @else { + } @else if (monthYearSelectType() === "grid") { + } @else { + + {{ currentYear() }} + } } @case ("months") { @@ -154,7 +163,7 @@ } - } @else { + } @else if (monthYearSelectType() === "grid") { + } @else { + + {{ currentYear() }} + } } @case ("years") { @@ -188,4 +201,8 @@ } -
    + + + {{ captionAnnouncement() }} + + diff --git a/tedi/components/content/calendar/calendar-header/calendar-header.component.scss b/tedi/components/content/calendar/calendar-header/calendar-header.component.scss index 1a2bb3457..9cee7d893 100644 --- a/tedi/components/content/calendar/calendar-header/calendar-header.component.scss +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.scss @@ -26,6 +26,7 @@ gap: var(--layout-grid-gutters-04); align-items: center; padding-left: var(--layout-grid-gutters-04); + font-family: inherit; font-size: var(--body-regular-size); font-weight: 500; color: var(--general-text-primary); @@ -76,7 +77,9 @@ &__static-label { font-size: var(--body-regular-size); + font-weight: 500; color: var(--general-text-primary); + text-transform: capitalize; } &--disabled { diff --git a/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts b/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts index 573f25fe6..405f2370d 100644 --- a/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts @@ -448,4 +448,51 @@ describe("CalendarHeaderComponent", () => { expect(navButtons()[1].disabled).toBe(false); }); }); + + describe("a11y", () => { + function nav(): HTMLElement { + return fixture.debugElement.query(By.css("nav.tedi-calendar-header")) + .nativeElement as HTMLElement; + } + + function liveRegion(): HTMLElement { + return fixture.debugElement.query( + By.css("nav.tedi-calendar-header .sr-only[role='status']"), + ).nativeElement as HTMLElement; + } + + it("renders the header as a