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 @@
+
+
+
+
+
+ @for (row of grid(); track rowKey(row, $index)) {
+
+ @if (showWeekNumbers()) {
+ |
+ {{ weekNumber(row) }}
+ |
+ }
+ @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