mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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.
This commit is contained in:
@@ -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(
|
||||
<CalendarMonthView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="month:2026-09-09"
|
||||
rangeStart={new Date(2026, 7, 31)}
|
||||
rangeEnd={new Date(2026, 9, 4)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={[]}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<HTMLElement>('[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]);
|
||||
});
|
||||
});
|
||||
@@ -155,21 +155,29 @@ export function CalendarMonthView({
|
||||
}, [events, weeks, tasksByDate]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(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<HTMLElement>("[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<HTMLElement>(`[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 (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
|
||||
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||
{dayHeaderKeys.map((d) => (
|
||||
<div key={d} role="columnheader" className={cn(
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
|
||||
isMobile && "py-1.5 text-[11px]"
|
||||
)}>
|
||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 flex flex-col overflow-y-auto [overflow-anchor:none]"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
ref={headerRef}
|
||||
className="sticky top-0 z-50 bg-background grid grid-cols-7 border-b border-border"
|
||||
role="row"
|
||||
>
|
||||
{dayHeaderKeys.map((d) => (
|
||||
<div key={d} role="columnheader" className={cn(
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
|
||||
isMobile && "py-1.5 text-[11px]"
|
||||
)}>
|
||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div ref={topSentinelRef} data-testid="month-top-sentinel" className="h-px flex-shrink-0" />
|
||||
{weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => {
|
||||
const overflowing = capRows && contentRows > rowCapacity;
|
||||
|
||||
Reference in New Issue
Block a user