From decaa07a5a33dfe76277e2f90342ef089717ae1c Mon Sep 17 00:00:00 2001 From: Tobias Genannt Date: Mon, 19 Oct 2026 12:42:20 +0200 Subject: [PATCH] fix(calendar): align month view day columns with the header The day header in the calendar month view was rendered outside the vertical scroll container, causing each header column to divide the full container width (100%) while the day grid cells below divided the remaining width excluding the vertical scrollbar. On platforms with classic scrollbars, this caused an escalating misalignment between the header columns and the day cells. Move the day header inside the scroll container as a sticky row (sticky top-0 z-50 bg-background), matching the pattern established in the week and day views, and account for the header height in row minimum height, scrollToFocus, and sampleVisibleMonth. --- .../calendar-month-view-header.test.tsx | 46 +++++++++++++++++++ components/calendar/calendar-month-view.tsx | 43 +++++++++++------ 2 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 components/calendar/__tests__/calendar-month-view-header.test.tsx 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;