diff --git a/components/calendar/__tests__/calendar-month-view-header.test.tsx b/components/calendar/__tests__/calendar-month-view-header.test.tsx new file mode 100644 index 000000000..145e33655 --- /dev/null +++ b/components/calendar/__tests__/calendar-month-view-header.test.tsx @@ -0,0 +1,46 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { CalendarMonthView } from '../calendar-month-view'; +import type { Calendar } from '@/lib/jmap/types'; + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +describe('CalendarMonthView header layout', () => { + afterEach(() => { + cleanup(); + }); + + it('renders the day headers inside the scrollable container as a sticky row', () => { + render( + , + ); + + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(7); + + // The header row should be sticky top-0 and inside the scroll container + const headerRow = headers[0].closest('[role="row"]'); + expect(headerRow).not.toBeNull(); + expect(headerRow).toHaveClass('sticky', 'top-0', 'z-50', 'bg-background'); + + // The header row must be a child of the scroll container + const scroller = document.querySelector('.overflow-y-auto'); + expect(scroller).not.toBeNull(); + expect(scroller).toContainElement(headerRow); + + // Grid cells should also be inside the same scroll container + const gridCells = screen.getAllByRole('gridcell'); + expect(gridCells.length).toBeGreaterThan(0); + expect(scroller).toContainElement(gridCells[0]); + }); +}); diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index cd413898a..012ec5f84 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -155,21 +155,29 @@ export function CalendarMonthView({ }, [events, weeks, tasksByDate]); const scrollRef = useRef(null); + const headerRef = useRef(null); const topSentinelRef = useRef(null); const bottomSentinelRef = useRef(null); // Six rows fill the viewport, as a single month used to; taller rows grow // with their chips. const [viewportHeight, setViewportHeight] = useState(0); + const [headerHeight, setHeaderHeight] = useState(0); useLayoutEffect(() => { const el = scrollRef.current; if (!el || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => setViewportHeight(el.clientHeight)); + const observer = new ResizeObserver(() => { + setViewportHeight(el.clientHeight); + if (headerRef.current) setHeaderHeight(headerRef.current.offsetHeight); + }); observer.observe(el); + if (headerRef.current) observer.observe(headerRef.current); setViewportHeight(el.clientHeight); + if (headerRef.current) setHeaderHeight(headerRef.current.offsetHeight); return () => observer.disconnect(); }, []); - const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(viewportHeight / 6)); + const availableHeight = Math.max(0, viewportHeight - headerHeight); + const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(availableHeight / 6)); // On wide screens a week keeps its height: a day with more entries than // fit shows the first ones and a "N more" link (as in Google Calendar). // Phones keep growing the row to fit. @@ -185,7 +193,8 @@ export function CalendarMonthView({ const sampleVisibleMonth = useCallback(() => { const el = scrollRef.current; if (!el) return; - const sampleY = el.getBoundingClientRect().top + el.clientHeight * VISIBLE_MONTH_SAMPLE; + const hHeight = headerRef.current?.offsetHeight ?? 0; + const sampleY = el.getBoundingClientRect().top + hHeight + (el.clientHeight - hHeight) * VISIBLE_MONTH_SAMPLE; const rows = el.querySelectorAll("[data-week]"); let hit: HTMLElement | null = null; for (const row of rows) { @@ -217,7 +226,8 @@ export function CalendarMonthView({ const targetKey = dayKey(grid[0] ?? focus.date); const row = el.querySelector(`[data-week="${targetKey}"]`); if (row) { - el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top; + const hHeight = headerRef.current?.offsetHeight ?? 0; + el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top - hHeight; } visibleMonthRef.current = focus.date; setVisibleMonthDate(focus.date); @@ -279,22 +289,25 @@ export function CalendarMonthView({ return (
-
- {dayHeaderKeys.map((d) => ( -
- {isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)} -
- ))} -
-
+
+ {dayHeaderKeys.map((d) => ( +
+ {isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)} +
+ ))} +
{weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => { const overflowing = capRows && contentRows > rowCapacity;