diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 86a1b5f2..d675c6a8 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/.storybook/preview.tsx b/.storybook/preview.tsx index 242ee3c0..4e86fb27 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -112,6 +112,12 @@ const preview: Preview = { description: "This component lacks some TEDI-Ready functionality, e.g it may rely on another component that has not yet been developed", }, + deprecated: { + background: "#b00020", + color: "#fff", + description: + "This component is deprecated and will be removed in a future release. Migrate to its replacement.", + }, mobileViewDifference: { background: "#99BDDA", color: "#000", 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 00000000..92e1b676 --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.html @@ -0,0 +1,76 @@ + + + + @if (showWeekNumbers()) { + + } + @for (name of weekdayNames(); track $index; let i = $index) { + + } + + + + @for (row of grid(); track rowKey(row, $index)) { + + @if (showWeekNumbers()) { + + } + @for (day of row; track cellKey(day, $index)) { + + } + + } + +
+ +
+ + + @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 00000000..ca2d1b2a --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.scss @@ -0,0 +1,191 @@ +.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); + 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; + } + } + + &__status { + 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 new file mode 100644 index 00000000..dfd0829c --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.spec.ts @@ -0,0 +1,605 @@ +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"; +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; + let component: CalendarDayGridComponent; + + const MAY_2024 = new Date(2024, 4, 15); + + 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; + 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(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(new FocusEvent("blur")); + 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(); + }); + }); + + 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 new file mode 100644 index 00000000..20faaf39 --- /dev/null +++ b/tedi/components/content/calendar/calendar-day-grid/calendar-day-grid.component.ts @@ -0,0 +1,384 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + model, + output, + ViewEncapsulation, +} from "@angular/core"; +import { + buildMonthGrid, + DateRange, + formatLocaleDateLong, + formatMonthYear, + getISOWeek, + getWeekdayNames, + isAfterDay, + isBeforeDay, + isSameDay, + 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, + 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 dayStatus = input(undefined); + + private readonly translation = inject(TediTranslationService); + + readonly daySelect = output(); + + readonly hoveredDate = model(null); + + readonly weekdayNames = computed(() => + 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(), + 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; + }); + + 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[] = []; + 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(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); + } + + 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 00000000..d6fcfd7d --- /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 00000000..01b31ce5 --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.html @@ -0,0 +1,208 @@ + 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 00000000..f80497a1 --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.scss @@ -0,0 +1,93 @@ +.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-family: inherit; + 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); + font-weight: 500; + color: var(--general-text-primary); + text-transform: capitalize; + } + + &--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 00000000..405f2370 --- /dev/null +++ b/tedi/components/content/calendar/calendar-header/calendar-header.component.spec.ts @@ -0,0 +1,498 @@ +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); + }); + }); + + 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