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:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user