Add DayTabs container with pagination and auto-scroll-to-active

This commit is contained in:
2026-04-17 00:24:12 +03:00
parent 5b85231132
commit bd147dabe1
4 changed files with 293 additions and 0 deletions
@@ -51,3 +51,39 @@
border: 1px solid #d0dae5;
}
}
.day-tabs-wrap {
margin-bottom: 8px;
}
.day-tabs {
display: flex;
align-items: stretch;
background: #e8f0f7;
border-radius: 8px 8px 0 0;
&__arrow {
width: 48px;
flex-shrink: 0;
background: transparent;
border: none;
cursor: pointer;
color: #2060c0;
font-size: 20px;
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
&__list {
flex: 1;
display: grid;
grid-template-columns: repeat(7, 1fr);
}
@media (max-width: 768px) {
display: none;
}
}
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DayTabs } from "./DayTabs.js";
describe("DayTabs", () => {
const FIXED_TODAY = new Date(2026, 3, 16); // Apr 16 2026
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FIXED_TODAY);
});
afterEach(() => {
vi.useRealTimers();
});
function allDatesBetween(from: Date, to: Date): string[] {
const out: string[] = [];
const cur = new Date(from);
while (cur <= to) {
const y = cur.getFullYear().toString();
const m = (cur.getMonth() + 1).toString().padStart(2, "0");
const d = cur.getDate().toString().padStart(2, "0");
out.push(`${y}${m}${d}`);
cur.setDate(cur.getDate() + 1);
}
return out;
}
it("renders container with data-testid", () => {
render(
<DayTabs
selectedDate="20260416"
availableDates={["20260416"]}
daysBefore={2}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
expect(screen.getByTestId("day-tabs")).toBeTruthy();
});
it("renders 7 tabs on first page", () => {
render(
<DayTabs
selectedDate="20260414"
availableDates={allDatesBetween(new Date(2026, 3, 14), new Date(2026, 3, 30))}
daysBefore={2}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
const tabs = screen.getAllByTestId(/^day-tab-\d{8}$/);
expect(tabs).toHaveLength(7);
});
it("disables tabs not in availableDates", () => {
render(
<DayTabs
selectedDate="20260414"
availableDates={["20260415"]}
daysBefore={2}
daysAfter={4}
locale="en"
onNavigate={() => {}}
/>,
);
const unavailable = screen.getByTestId("day-tab-20260414") as HTMLButtonElement;
const available = screen.getByTestId("day-tab-20260415") as HTMLButtonElement;
expect(unavailable.disabled).toBe(true);
expect(available.disabled).toBe(false);
});
it("prev arrow disabled on first page", () => {
render(
<DayTabs
selectedDate="20260414"
availableDates={["20260414"]}
daysBefore={2}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
const prev = screen.getByTestId("day-tabs-prev") as HTMLButtonElement;
expect(prev.disabled).toBe(true);
});
it("next arrow changes page", () => {
render(
<DayTabs
selectedDate="20260414"
availableDates={allDatesBetween(new Date(2026, 3, 14), new Date(2026, 3, 30))}
daysBefore={2}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
expect(screen.queryByTestId("day-tab-20260421")).toBeNull();
fireEvent.click(screen.getByTestId("day-tabs-next"));
expect(screen.getByTestId("day-tab-20260421")).toBeTruthy();
expect(screen.queryByTestId("day-tab-20260414")).toBeNull();
});
it("clicking an available tab calls onNavigate", () => {
const onNavigate = vi.fn();
render(
<DayTabs
selectedDate="20260416"
availableDates={["20260415", "20260416", "20260417"]}
daysBefore={2}
daysAfter={4}
locale="en"
onNavigate={onNavigate}
/>,
);
fireEvent.click(screen.getByTestId("day-tab-20260417"));
expect(onNavigate).toHaveBeenCalledWith("20260417");
});
it("initializes currentPage to the page containing selectedDate", () => {
const available = allDatesBetween(new Date(2026, 3, 14), new Date(2026, 3, 30));
render(
<DayTabs
selectedDate="20260425"
availableDates={available}
daysBefore={2}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
// 20260425 is on page 1 (0-indexed) of pages [14..20][21..27][28..30]
expect(screen.getByTestId("day-tab-20260425")).toBeTruthy();
expect(screen.queryByTestId("day-tab-20260414")).toBeNull();
});
it("renders DaySelect for mobile", () => {
render(
<DayTabs
selectedDate="20260416"
availableDates={["20260416"]}
daysBefore={2}
daysAfter={14}
locale="en"
onNavigate={() => {}}
/>,
);
expect(screen.getByTestId("day-select")).toBeTruthy();
});
});
@@ -0,0 +1,94 @@
import { type FC, useMemo, useState } from "react";
import { DayTabButton } from "./DayTabButton.js";
import { DaySelect } from "./DaySelect.js";
import { generateDateRange, findPageIndex } from "./dateRange.js";
import "./DayTabs.scss";
const PAGE_SIZE = 7;
export interface DayTabsProps {
selectedDate: string;
availableDates: string[];
daysBefore: number;
daysAfter: number;
locale: string;
onNavigate: (date: string) => void;
}
export const DayTabs: FC<DayTabsProps> = ({
selectedDate,
availableDates,
daysBefore,
daysAfter,
locale,
onNavigate,
}) => {
const allDates = useMemo(
() => generateDateRange(new Date(), daysBefore, daysAfter),
[daysBefore, daysAfter],
);
const totalPages = Math.max(1, Math.ceil(allDates.length / PAGE_SIZE));
const initialPage = useMemo(
() => findPageIndex(allDates, selectedDate, PAGE_SIZE),
[allDates, selectedDate],
);
const [currentPage, setCurrentPage] = useState(initialPage);
const availableSet = useMemo(() => new Set(availableDates), [availableDates]);
const visibleDates = allDates.slice(
currentPage * PAGE_SIZE,
(currentPage + 1) * PAGE_SIZE,
);
const canGoPrev = currentPage > 0;
const canGoNext = currentPage < totalPages - 1;
return (
<div className="day-tabs-wrap" data-testid="day-tabs">
<div className="day-tabs">
<button
type="button"
className="day-tabs__arrow"
data-testid="day-tabs-prev"
disabled={!canGoPrev}
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
aria-label="Previous page"
>
{"\u2039"}
</button>
<div className="day-tabs__list">
{visibleDates.map((date) => (
<DayTabButton
key={date}
date={date}
isActive={date === selectedDate}
isDisabled={!availableSet.has(date)}
locale={locale}
onClick={onNavigate}
/>
))}
</div>
<button
type="button"
className="day-tabs__arrow"
data-testid="day-tabs-next"
disabled={!canGoNext}
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
aria-label="Next page"
>
{"\u203a"}
</button>
</div>
<DaySelect
selectedDate={selectedDate}
availableDates={availableDates}
locale={locale}
onNavigate={onNavigate}
/>
</div>
);
};
@@ -0,0 +1,6 @@
export { DayTabs } from "./DayTabs.js";
export type { DayTabsProps } from "./DayTabs.js";
export { DayTabButton } from "./DayTabButton.js";
export type { DayTabButtonProps } from "./DayTabButton.js";
export { DaySelect } from "./DaySelect.js";
export type { DaySelectProps } from "./DaySelect.js";