From cf844ff4d7368713d3842d8c1317c1665ce90bb6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:51:08 +0100 Subject: [PATCH] fix: keep an event's title in sight while its block runs above the visible hours, as an overnight event's morning part --- .../event-block-title-ux-p2-pim.test.tsx | 37 +++++++++++++++++++ components/calendar/event-card.tsx | 10 ++++- hooks/use-display-hours.ts | 8 +++- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx diff --git a/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx new file mode 100644 index 000000000..fce16b53c --- /dev/null +++ b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx @@ -0,0 +1,37 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { EventCard } from '../event-card'; +import { useDisplayHours } from '@/hooks/use-display-hours'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +// pim-p2-19: the part of "Night train to Vienna" after midnight starts at the +// top of the day, scrolled out of view, so the week showed a block with no +// title. The title line now sticks under the grid's header while the block is +// in sight. A block that hid its overflow was a scroller of its own, and a +// sticky line inside one sticks to the block, not to the grid. + +const train = { + id: 'n1', '@type': 'Event', uid: 'n1', title: 'Night train to Vienna', start: '2026-11-07T00:00:00', + duration: 'PT7H40M', showWithoutTime: false, status: 'confirmed', calendarIds: { cal: true }, participants: null, +} as unknown as CalendarEvent; + +describe('the title of a long block stays in sight', () => { + it('sticks the title line under the header and clips the block without making it a scroller', () => { + render(); + const card = screen.getByRole('button', { name: /Night train to Vienna/ }); + expect(card.className).toContain('overflow-clip'); + expect(card.className).not.toContain('overflow-hidden'); + const line = card.querySelector('[data-event-title-line]') as HTMLElement; + expect(line.className).toContain('sticky'); + expect(line.className).toContain('top-[var(--grid-header-height,0px)]'); + expect(line.textContent).toContain('Night train to Vienna'); + }); + + it('tells the grid how tall its header is, for the sticky title', () => { + const pane = document.createElement('div'); + const header = document.createElement('div'); + Object.defineProperty(header, 'offsetHeight', { value: 96 }); + renderHook(() => useDisplayHours({ scrollRef: { current: pane }, headerRef: { current: header }, minHourHeight: 48 })); + expect(pane.style.getPropertyValue('--grid-header-height')).toBe('96px'); + }); +}); diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 4a31e76e9..8f9732155 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -450,8 +450,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM data-continues-after={continuesAfter || undefined} className={cn( // A block box: an inline one hangs its line's descent below the - // grid's last hour and makes the filled pane scroll. - "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden", + // grid's last hour and makes the filled pane scroll. Clipped, not + // hidden: hidden makes the block a scroller and its title can't stick. + "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-clip", "hover:opacity-90 transition-opacity cursor-pointer", // Cut at the edge of the visible hours: a square edge and an arrow // say the event runs on past it. @@ -470,6 +471,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {/* Shorter blocks have no room for a second line at the 60px hour, so their start time follows the title on the same line, when the block is wide enough to keep the title readable. */} + {/* The title stays in sight under the grid's header while the block + is: the morning part of a night train started at midnight, scrolled + out of view, and showed as a block with no name (pim-p2-19). */} +
{event.title || t("events.no_title")} @@ -487,6 +492,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)} +
{visibleCalendarName && (
{visibleCalendarName}
)} diff --git a/hooks/use-display-hours.ts b/hooks/use-display-hours.ts index 68a64e34c..39821c87f 100644 --- a/hooks/use-display-hours.ts +++ b/hooks/use-display-hours.ts @@ -58,7 +58,13 @@ export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt useLayoutEffect(() => { const root = scrollRef.current; if (!root) return; - const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0))); + const measure = () => { + const headerHeight = headerRef?.current?.offsetHeight ?? 0; + setAvailable(Math.max(0, root.clientHeight - headerHeight)); + // Where an event's title sticks while its block runs above the visible + // hours: an overnight event's morning part (pim-p2-19). + root.style?.setProperty("--grid-header-height", `${headerHeight}px`); + }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure);