Add DayTabs container with pagination and auto-scroll-to-active
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user