diff --git a/src/features/online-board/components/DayTabs/DayTabButton.test.tsx b/src/features/online-board/components/DayTabs/DayTabButton.test.tsx index ab51639f..1ab48829 100644 --- a/src/features/online-board/components/DayTabs/DayTabButton.test.tsx +++ b/src/features/online-board/components/DayTabs/DayTabButton.test.tsx @@ -47,4 +47,16 @@ describe("DayTabButton", () => { const btn = screen.getByTestId("day-tab-20260416") as HTMLButtonElement; expect(btn.disabled).toBe(true); }); + + // TZ §4.1.13.1: active tab must carry aria-current="date" for screen readers + // and to mark the highlighted day without relying solely on visual styling. + it("4.1.13.1: active tab has aria-current=date", () => { + render( {}} />); + expect(screen.getByTestId("day-tab-20260416").getAttribute("aria-current")).toBe("date"); + }); + + it("4.1.13.1: inactive tab has no aria-current attribute", () => { + render( {}} />); + expect(screen.getByTestId("day-tab-20260416").getAttribute("aria-current")).toBeNull(); + }); }); diff --git a/src/features/online-board/components/DayTabs/DayTabButton.tsx b/src/features/online-board/components/DayTabs/DayTabButton.tsx index d4da481a..895d3c36 100644 --- a/src/features/online-board/components/DayTabs/DayTabButton.tsx +++ b/src/features/online-board/components/DayTabs/DayTabButton.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import { forwardRef } from "react"; import { parseYyyymmdd } from "./dateRange.js"; import "./DayTabs.scss"; @@ -10,42 +10,41 @@ export interface DayTabButtonProps { onClick: (date: string) => void; } -export const DayTabButton: FC = ({ - date, - isActive, - isDisabled, - locale, - onClick, -}) => { - const d = parseYyyymmdd(date); - // Single format call keeps Russian month in the correct genitive - // case ('19 апреля' rather than '19 апрель'), matching Angular's - // ngx-translate output. - const label = new Intl.DateTimeFormat(locale, { - day: "numeric", - month: isActive ? "long" : "short", - }).format(d); +export const DayTabButton = forwardRef( + ({ date, isActive, isDisabled, locale, onClick }, ref) => { + const d = parseYyyymmdd(date); + // Single format call keeps Russian month in the correct genitive + // case ('19 апреля' rather than '19 апрель'), matching Angular's + // ngx-translate output. + const label = new Intl.DateTimeFormat(locale, { + day: "numeric", + month: isActive ? "long" : "short", + }).format(d); - const classes = [ - "day-tab", - isActive ? "day-tab--active" : "", - isDisabled ? "day-tab--disabled" : "", - ] - .filter(Boolean) - .join(" "); + const classes = [ + "day-tab", + isActive ? "day-tab--active" : "", + isDisabled ? "day-tab--disabled" : "", + ] + .filter(Boolean) + .join(" "); - return ( - - ); -}; + return ( + + ); + }, +); +DayTabButton.displayName = "DayTabButton"; diff --git a/src/features/online-board/components/DayTabs/DayTabs.test.tsx b/src/features/online-board/components/DayTabs/DayTabs.test.tsx index d266baca..7e919210 100644 --- a/src/features/online-board/components/DayTabs/DayTabs.test.tsx +++ b/src/features/online-board/components/DayTabs/DayTabs.test.tsx @@ -154,4 +154,156 @@ describe("DayTabs", () => { ); expect(screen.getByTestId("day-select")).toBeTruthy(); }); + + // ── TZ §4.1.13.1 compliance tests ────────────────────────────────────── + + it("4.1.13.1: active range today-1 to today+14 yields 16 total dates", () => { + // daysBefore=1, daysAfter=14 → 1+1+14 = 16 dates total (3 pages: 7+7+2) + render( + {}} + />, + ); + // First page shows today-1 (20260415) through 20260421 + expect(screen.getByTestId("day-tab-20260415")).toBeTruthy(); + expect(screen.getByTestId("day-tab-20260421")).toBeTruthy(); + // today-2 (20260414) is not in the range + expect(screen.queryByTestId("day-tab-20260414")).toBeNull(); + }); + + it("4.1.13.1: next arrow enables and disables correctly across pages", () => { + render( + {}} + />, + ); + const next = screen.getByTestId("day-tabs-next") as HTMLButtonElement; + expect(next.disabled).toBe(false); // page 0 of 3 + + fireEvent.click(next); // go to page 1 + expect(next.disabled).toBe(false); // still more pages + + fireEvent.click(next); // go to page 2 (last, with padding) + expect(next.disabled).toBe(true); // on last page → disabled + }); + + it("4.1.13.1: last page is padded to 7 slots with inactive tabs when short", () => { + // daysBefore=1, daysAfter=14 → 16 dates → 3 pages (7 + 7 + 2) + // Last page has 2 real tabs + 5 padding placeholders. + render( + {}} + />, + ); + const next = screen.getByTestId("day-tabs-next") as HTMLButtonElement; + fireEvent.click(next); // page 1 + fireEvent.click(next); // page 2 (last) + + // 2 real tabs visible on last page + const realTabs = screen.getAllByTestId(/^day-tab-\d{8}$/); + expect(realTabs).toHaveLength(2); + + // 5 padding slots + const padSlots = screen.getAllByTestId(/^day-tab-pad-\d+$/); + expect(padSlots).toHaveLength(5); + }); + + it("4.1.13.1: right-arrow disabled on last page even when padded", () => { + render( + {}} + />, + ); + const next = screen.getByTestId("day-tabs-next") as HTMLButtonElement; + fireEvent.click(next); + fireEvent.click(next); // now on last (padded) page + expect(next.disabled).toBe(true); + }); + + it("4.1.13.1: full last page (no short tail) has no padding tabs", () => { + // daysBefore=0, daysAfter=6 → 7 dates → 1 page, no padding needed + render( + {}} + />, + ); + expect(screen.queryByTestId("day-tab-pad-0")).toBeNull(); + }); + + it("4.1.13.1: selected tab has aria-current=date for highlight", () => { + render( + {}} + />, + ); + const activeTab = screen.getByTestId("day-tab-20260416"); + expect(activeTab.getAttribute("aria-current")).toBe("date"); + + const inactiveTab = screen.getByTestId("day-tab-20260415"); + expect(inactiveTab.getAttribute("aria-current")).toBeNull(); + }); + + it("4.1.13.1: selected tab does NOT have aria-current when not active", () => { + render( + {}} + />, + ); + // Sibling tabs should not have aria-current + expect(screen.getByTestId("day-tab-20260415").getAttribute("aria-current")).toBeNull(); + expect(screen.getByTestId("day-tab-20260417").getAttribute("aria-current")).toBeNull(); + }); + + it("4.1.13.1: padding tabs are aria-hidden (non-interactive)", () => { + render( + {}} + />, + ); + const next = screen.getByTestId("day-tabs-next") as HTMLButtonElement; + fireEvent.click(next); + fireEvent.click(next); // last page + const pad = screen.getByTestId("day-tab-pad-0"); + expect(pad.getAttribute("aria-hidden")).toBe("true"); + }); }); diff --git a/src/features/online-board/components/DayTabs/DayTabs.tsx b/src/features/online-board/components/DayTabs/DayTabs.tsx index b877991b..dc1f907e 100644 --- a/src/features/online-board/components/DayTabs/DayTabs.tsx +++ b/src/features/online-board/components/DayTabs/DayTabs.tsx @@ -1,4 +1,4 @@ -import { type FC, useMemo, useState } from "react"; +import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "@/i18n/provider.js"; import { DayTabButton } from "./DayTabButton.js"; import { DaySelect } from "./DaySelect.js"; @@ -46,6 +46,19 @@ export const DayTabs: FC = ({ const [currentPage, setCurrentPage] = useState(initialPage); + // TZ §4.1.13.1: auto-scroll — keep the visible page in sync when the + // URL-driven selectedDate changes externally (e.g. deep-link navigation). + const activeBtnRef = useRef(null); + + useEffect(() => { + const newPage = findPageIndex(allDates, selectedDate, PAGE_SIZE); + setCurrentPage(newPage); + }, [allDates, selectedDate]); + + useEffect(() => { + activeBtnRef.current?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + }, [selectedDate]); + // Empty availableDates means the calendar API hasn't resolved yet (or // the route has no day data). Treat every date as tappable in that // case — matches Angular where the tabs stay enabled until we *know* @@ -58,8 +71,14 @@ export const DayTabs: FC = ({ (currentPage + 1) * PAGE_SIZE, ); + // TZ §4.1.13.1: when the last active day is not the 7th visible slot, + // pad the remaining slots with inactive placeholder tabs to fill to 7. + // Right-arrow is disabled whenever we are on the last page (padded or not). + const isLastPage = currentPage >= totalPages - 1; + const paddingCount = isLastPage ? PAGE_SIZE - visibleDates.length : 0; + const canGoPrev = currentPage > 0; - const canGoNext = currentPage < totalPages - 1; + const canGoNext = !isLastPage; return (
@@ -72,7 +91,7 @@ export const DayTabs: FC = ({ onClick={() => setCurrentPage((p) => Math.max(0, p - 1))} aria-label={t("SHARED.A11Y-PREV-PAGE")} > - {"\u2039"} + {"‹"}
{visibleDates.map((date) => ( @@ -83,6 +102,15 @@ export const DayTabs: FC = ({ isDisabled={disableByAvailability && !availableSet.has(date)} locale={locale} onClick={onNavigate} + ref={date === selectedDate ? activeBtnRef : undefined} + /> + ))} + {Array.from({ length: paddingCount }, (_, i) => ( + @@ -94,7 +122,7 @@ export const DayTabs: FC = ({ onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))} aria-label={t("SHARED.A11Y-NEXT-PAGE")} > - {"\u203a"} + {"›"}
= ({ selectedDate={params.date} availableDates={calendarDays} daysBefore={1} - daysAfter={7} + daysAfter={14} locale={language} onNavigate={handleDateChange} />