diff --git a/src/features/online-board/components/DayTabs/DayTabs.scss b/src/features/online-board/components/DayTabs/DayTabs.scss index cf14285a..2cec93e1 100644 --- a/src/features/online-board/components/DayTabs/DayTabs.scss +++ b/src/features/online-board/components/DayTabs/DayTabs.scss @@ -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; + } +} diff --git a/src/features/online-board/components/DayTabs/DayTabs.test.tsx b/src/features/online-board/components/DayTabs/DayTabs.test.tsx new file mode 100644 index 00000000..d266baca --- /dev/null +++ b/src/features/online-board/components/DayTabs/DayTabs.test.tsx @@ -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( + {}} + />, + ); + expect(screen.getByTestId("day-tabs")).toBeTruthy(); + }); + + it("renders 7 tabs on first page", () => { + render( + {}} + />, + ); + const tabs = screen.getAllByTestId(/^day-tab-\d{8}$/); + expect(tabs).toHaveLength(7); + }); + + it("disables tabs not in availableDates", () => { + render( + {}} + />, + ); + 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( + {}} + />, + ); + const prev = screen.getByTestId("day-tabs-prev") as HTMLButtonElement; + expect(prev.disabled).toBe(true); + }); + + it("next arrow changes page", () => { + render( + {}} + />, + ); + 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( + , + ); + 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( + {}} + />, + ); + // 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( + {}} + />, + ); + expect(screen.getByTestId("day-select")).toBeTruthy(); + }); +}); diff --git a/src/features/online-board/components/DayTabs/DayTabs.tsx b/src/features/online-board/components/DayTabs/DayTabs.tsx new file mode 100644 index 00000000..0bbb8c26 --- /dev/null +++ b/src/features/online-board/components/DayTabs/DayTabs.tsx @@ -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 = ({ + 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 ( +
+
+ +
+ {visibleDates.map((date) => ( + + ))} +
+ +
+ +
+ ); +}; diff --git a/src/features/online-board/components/DayTabs/index.ts b/src/features/online-board/components/DayTabs/index.ts new file mode 100644 index 00000000..6fcf719a --- /dev/null +++ b/src/features/online-board/components/DayTabs/index.ts @@ -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";