Audit DayTabs behavior per TZ 4.1.13.1 (7-day window, paging, padding, active range)

- Fix daysAfter: 7→14 in OnlineBoardSearchPage (TZ active range is today-1 to today+14)
- Add inactive padding tabs on the last page when it has fewer than 7 slots; right-arrow stays disabled on last page regardless (TZ §4.1.13.1)
- Add aria-current="date" to active DayTabButton for accessible highlight (TZ requires visual highlight + screen-reader signal)
- Add auto-scroll via scrollIntoView when selectedDate changes externally (URL-driven day navigation)
- Convert DayTabButton to forwardRef to support the activeBtnRef scroll anchor
- 9 new TZ-labelled tests locking in all the above behaviors
This commit is contained in:
2026-04-21 22:45:08 +03:00
parent 38a512004f
commit 439624244d
5 changed files with 234 additions and 43 deletions
@@ -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(<DayTabButton date="20260416" isActive={true} isDisabled={false} locale="en" onClick={() => {}} />);
expect(screen.getByTestId("day-tab-20260416").getAttribute("aria-current")).toBe("date");
});
it("4.1.13.1: inactive tab has no aria-current attribute", () => {
render(<DayTabButton date="20260416" isActive={false} isDisabled={false} locale="en" onClick={() => {}} />);
expect(screen.getByTestId("day-tab-20260416").getAttribute("aria-current")).toBeNull();
});
});
@@ -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<DayTabButtonProps> = ({
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<HTMLButtonElement, DayTabButtonProps>(
({ 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 (
<button
type="button"
className={classes}
disabled={isDisabled}
aria-pressed={isActive}
data-testid={`day-tab-${date}`}
onClick={() => {
if (!isDisabled) onClick(date);
}}
>
<span className="day-tab__label">{label}</span>
</button>
);
};
return (
<button
ref={ref}
type="button"
className={classes}
disabled={isDisabled}
aria-pressed={isActive}
aria-current={isActive ? "date" : undefined}
data-testid={`day-tab-${date}`}
onClick={() => {
if (!isDisabled) onClick(date);
}}
>
<span className="day-tab__label">{label}</span>
</button>
);
},
);
DayTabButton.displayName = "DayTabButton";
@@ -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(
<DayTabs
selectedDate="20260415"
availableDates={allDatesBetween(new Date(2026, 3, 15), new Date(2026, 3, 30))}
daysBefore={1}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
// 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(
<DayTabs
selectedDate="20260415"
availableDates={allDatesBetween(new Date(2026, 3, 15), new Date(2026, 3, 30))}
daysBefore={1}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
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(
<DayTabs
selectedDate="20260415"
availableDates={allDatesBetween(new Date(2026, 3, 15), new Date(2026, 3, 30))}
daysBefore={1}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
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(
<DayTabs
selectedDate="20260415"
availableDates={allDatesBetween(new Date(2026, 3, 15), new Date(2026, 3, 30))}
daysBefore={1}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
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(
<DayTabs
selectedDate="20260416"
availableDates={["20260416"]}
daysBefore={0}
daysAfter={6}
locale="en"
onNavigate={() => {}}
/>,
);
expect(screen.queryByTestId("day-tab-pad-0")).toBeNull();
});
it("4.1.13.1: selected tab has aria-current=date for highlight", () => {
render(
<DayTabs
selectedDate="20260416"
availableDates={["20260415", "20260416", "20260417"]}
daysBefore={1}
daysAfter={5}
locale="en"
onNavigate={() => {}}
/>,
);
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(
<DayTabs
selectedDate="20260416"
availableDates={["20260415", "20260416", "20260417"]}
daysBefore={1}
daysAfter={5}
locale="en"
onNavigate={() => {}}
/>,
);
// 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(
<DayTabs
selectedDate="20260415"
availableDates={allDatesBetween(new Date(2026, 3, 15), new Date(2026, 3, 30))}
daysBefore={1}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
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");
});
});
@@ -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<DayTabsProps> = ({
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<HTMLButtonElement | null>(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<DayTabsProps> = ({
(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 (
<div className="day-tabs-wrap" data-testid="day-tabs">
@@ -72,7 +91,7 @@ export const DayTabs: FC<DayTabsProps> = ({
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
aria-label={t("SHARED.A11Y-PREV-PAGE")}
>
{"\u2039"}
{""}
</button>
<div className="day-tabs__list">
{visibleDates.map((date) => (
@@ -83,6 +102,15 @@ export const DayTabs: FC<DayTabsProps> = ({
isDisabled={disableByAvailability && !availableSet.has(date)}
locale={locale}
onClick={onNavigate}
ref={date === selectedDate ? activeBtnRef : undefined}
/>
))}
{Array.from({ length: paddingCount }, (_, i) => (
<div
key={`pad-${i}`}
className="day-tab day-tab--disabled day-tab--pad"
data-testid={`day-tab-pad-${i}`}
aria-hidden="true"
/>
))}
</div>
@@ -94,7 +122,7 @@ export const DayTabs: FC<DayTabsProps> = ({
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
aria-label={t("SHARED.A11Y-NEXT-PAGE")}
>
{"\u203a"}
{""}
</button>
</div>
<DaySelect
@@ -486,7 +486,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
selectedDate={params.date}
availableDates={calendarDays}
daysBefore={1}
daysAfter={7}
daysAfter={14}
locale={language}
onNavigate={handleDateChange}
/>