From e121d06d551a82908dc19e677190f98b788f932f Mon Sep 17 00:00:00 2001 From: bomi ci Date: Wed, 14 Oct 2026 14:21:28 -0700 Subject: [PATCH] Add initialScrollTime to Schedule day and week views A day or week grid in a fill-height Schedule always opened at its first hour, so an app that wanted the current time in view had to reach into the grid's markup to scroll it. `initialScrollTime: 'now'` places the current time a third of the way down the visible hours when the grid shows today. A time of day places that time at the top. The grid scrolls once when it mounts, below the sticky day headers, and keeps the user's scroll after that. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/Schedule/DayView.tsx | 10 ++ .../Schedule/Schedule.browser.test.tsx | 97 +++++++++++++++++++ src/components/Schedule/Schedule.stories.tsx | 11 +++ src/components/Schedule/TimeGridView.tsx | 69 +++++++++++++ src/components/Schedule/WeeklyView.tsx | 10 ++ src/components/Schedule/index.ts | 1 + src/components/Schedule/types.ts | 7 ++ src/index.ts | 1 + 8 files changed, 206 insertions(+) diff --git a/src/components/Schedule/DayView.tsx b/src/components/Schedule/DayView.tsx index 8d5514fd..b55ae7fe 100644 --- a/src/components/Schedule/DayView.tsx +++ b/src/components/Schedule/DayView.tsx @@ -12,6 +12,7 @@ import { formatMonthTitle, } from 'components/Schedule/shared'; import type { + ScheduleInitialScrollTime, ScheduleTimeGridOverlapBehavior, ScheduleView, ScheduleViewComponentProps, @@ -30,6 +31,12 @@ export interface ScheduleDayViewOptions { * @default 100 */ hourHeight?: number; + /** + * Where the grid scrolls when it mounts, when it scrolls vertically (as with + * `height="fill"`). Later renders, including paging, keep the user's scroll. + * By default the grid opens at its first hour. + */ + initialScrollTime?: ScheduleInitialScrollTime; /** * Exclusive ending hour shown in the day grid (1-24). * @default 24 @@ -71,6 +78,7 @@ function ScheduleDayView({ days={days} height={height} hourHeight={options.hourHeight} + initialScrollTime={options.initialScrollTime} maxHour={options.maxHour} minHour={options.minHour} overlapBehavior={options.overlapBehavior} @@ -85,6 +93,7 @@ function ScheduleDayView({ export function createScheduleDayView({ allDayEventLimit = 3, hourHeight = 100, + initialScrollTime, maxHour = 24, minHour = 0, overlapBehavior = 'sideBySide', @@ -111,6 +120,7 @@ export function createScheduleDayView({ options: { allDayEventLimit, hourHeight, + initialScrollTime, maxHour, minHour, overlapBehavior, diff --git a/src/components/Schedule/Schedule.browser.test.tsx b/src/components/Schedule/Schedule.browser.test.tsx index d1ffbc92..d5465241 100644 --- a/src/components/Schedule/Schedule.browser.test.tsx +++ b/src/components/Schedule/Schedule.browser.test.tsx @@ -4,6 +4,10 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; import {Schedule} from 'components/Schedule/Schedule'; import {createScheduleWeeklyView} from 'components/Schedule/WeeklyView'; import {useScheduleEventCreatePlugin} from 'components/Schedule/plugins/EventCreatePlugin'; +import type { + Instant, + ScheduleInitialScrollTime, +} from 'components/Schedule/types'; import {Temporal} from 'internal/temporal'; // Real-layout placement tests for the create popover, reproducing a tall @@ -105,3 +109,96 @@ describe('Schedule create popover placement (browser)', () => { }, ); }); + +// A full-day grid in a short frame, so every case has room to scroll. +function ScrollingWeek({ + initialScrollTime, + viewDate, +}: { + initialScrollTime: ScheduleInitialScrollTime; + viewDate: Instant; +}) { + return ( +
+ +
+ ); +} + +const SEP_30 = Temporal.Instant.from('2026-09-30T12:00:00Z').epochMilliseconds; +const OCT_7 = Temporal.Instant.from('2026-10-07T12:00:00Z').epochMilliseconds; + +function timeGrid(): HTMLElement { + return screen.getByRole('grid', {name: 'Schedule time grid'}); +} + +// The top of the hours left visible below the sticky day headers. +function visibleHoursTop(): number { + return screen + .getByTestId('schedule-time-grid-fixed-rows') + .getBoundingClientRect().bottom; +} + +describe('Schedule initial scroll time (browser)', () => { + beforeEach(() => { + vi.spyOn(Temporal.Now, 'instant').mockReturnValue( + Temporal.Instant.from('2026-09-30T15:30:00Z'), + ); + return () => { + vi.restoreAllMocks(); + }; + }); + + it('places now a third of the way down the visible hours', () => { + render(); + + const visibleHoursHeight = + timeGrid().getBoundingClientRect().bottom - visibleHoursTop(); + const line = screen.getByTestId('schedule-current-time-line'); + // The line is drawn a couple of pixels below the time it marks. + expect( + Math.abs( + line.getBoundingClientRect().top - + visibleHoursTop() - + visibleHoursHeight / 3, + ), + ).toBeLessThanOrEqual(2); + }); + + it('stays at the first hour when the grid does not show today', () => { + render(); + + expect(timeGrid().scrollTop).toBe(0); + }); + + it('places a time of day at the top of the visible hours', () => { + render( + , + ); + + const eightOClock = screen + .getByTestId('schedule-time-grid-cell-2026-10-07-8') + .getBoundingClientRect(); + expect(visibleHoursTop() - eightOClock.top).toBeCloseTo(24, 0); + }); + + it("keeps the user's scroll when paging", () => { + const {rerender} = render( + , + ); + timeGrid().scrollTop = 100; + + rerender(); + + expect(timeGrid().scrollTop).toBe(100); + }); +}); diff --git a/src/components/Schedule/Schedule.stories.tsx b/src/components/Schedule/Schedule.stories.tsx index d604adb4..5c6bbe24 100644 --- a/src/components/Schedule/Schedule.stories.tsx +++ b/src/components/Schedule/Schedule.stories.tsx @@ -1137,6 +1137,17 @@ export const FillHeightWithSurplusSpace: Story = { ), }; +export const FillHeightScrolledToNow: Story = { + render: () => ( +
+ +
+ ), +}; + export const CustomPlugin: Story = { render: () => { const customPlugin = useMemo( diff --git a/src/components/Schedule/TimeGridView.tsx b/src/components/Schedule/TimeGridView.tsx index fe2fcbad..76cf0a49 100644 --- a/src/components/Schedule/TimeGridView.tsx +++ b/src/components/Schedule/TimeGridView.tsx @@ -35,6 +35,7 @@ import type { CalendarEvent, CalendarInstantEvent, ScheduleHeight, + ScheduleInitialScrollTime, SchedulePluginElementProps, ScheduleTimeGridCellPropsRenderProps, ScheduleTimeGridOverlapBehavior, @@ -52,6 +53,7 @@ import { type PlainDate, } from 'internal/plainDate'; import {observeResize, unobserveResize} from 'internal/sharedResizeObserver'; +import {Temporal} from 'internal/temporal'; import {useIsomorphicLayoutEffect} from 'internal/useIsomorphicLayoutEffect'; import {cx} from 'utils/cx'; @@ -68,6 +70,10 @@ interface InlineOverflow { const MINUTES_PER_DAY = 24 * 60; +// `initialScrollTime: 'now'` lands this far down the visible hours, so the +// hour just before now stays in view. +const NOW_SCROLL_FRACTION = 1 / 3; + /** * A timed event's wall-clock extent, resolved to the grid timezone once so the * per-day and per-hour-cell work below is plain arithmetic. Temporal timezone @@ -417,6 +423,7 @@ export function TimeGridView({ days, height, hourHeight = 100, + initialScrollTime, maxHour = 24, minHour = 0, overlapBehavior = 'sideBySide', @@ -445,6 +452,10 @@ export function TimeGridView({ * @default 100 */ hourHeight?: number; + /** + * Where the grid scrolls when it mounts. + */ + initialScrollTime?: ScheduleInitialScrollTime; /** * Exclusive ending hour shown in the grid (1-24). * @default 24 @@ -608,6 +619,34 @@ export function TimeGridView({ }; }, [days.length, normalizedDayMinWidth]); + // Scrolls once, when the grid mounts. Later renders (paging, the + // current-time tick, new events) keep wherever the user has scrolled since. + const hasAppliedInitialScrollRef = useRef(false); + useIsomorphicLayoutEffect(() => { + if (hasAppliedInitialScrollRef.current) { + return; + } + hasAppliedInitialScrollRef.current = true; + const grid = gridRef.current; + const target = getInitialScrollTarget(initialScrollTime, days, timezoneID); + if (grid == null || target == null) { + return; + } + // The sticky day headers cover the top of the grid, so the visible hours + // start below them. + const visibleHoursHeight = + grid.clientHeight - (fixedRowsRef.current?.offsetHeight ?? 0); + grid.scrollTop = + ((target.minute - normalizedMinHour * 60) / 60) * normalizedHourHeight - + target.fraction * visibleHoursHeight; + }, [ + days, + initialScrollTime, + normalizedHourHeight, + normalizedMinHour, + timezoneID, + ]); + return (
, + timezoneID: string, +): {fraction: number; minute: number} | null { + if (initialScrollTime == null) { + return null; + } + if (initialScrollTime !== 'now') { + return { + fraction: 0, + minute: initialScrollTime.hour * 60 + (initialScrollTime.minute ?? 0), + }; + } + const now = Temporal.Now.instant().epochMilliseconds; + const today = plainDateFromInstant(now, timezoneID); + return days.some(day => plainDateIsEqual(day, today)) + ? { + fraction: NOW_SCROLL_FRACTION, + minute: getMinutesSinceStartOfDay(now, timezoneID), + } + : null; +} + function getCurrentTimeTopForHour({ currentTimePosition, day, diff --git a/src/components/Schedule/WeeklyView.tsx b/src/components/Schedule/WeeklyView.tsx index 763cbb4a..70399705 100644 --- a/src/components/Schedule/WeeklyView.tsx +++ b/src/components/Schedule/WeeklyView.tsx @@ -11,6 +11,7 @@ import { import {scheduleRangeToScheduleZonedInstantRange} from 'components/Schedule/scheduleZonedInstant'; import {ScheduleFrame, formatWeekTitle} from 'components/Schedule/shared'; import type { + ScheduleInitialScrollTime, ScheduleTimeGridOverlapBehavior, ScheduleView, ScheduleViewComponentProps, @@ -50,6 +51,12 @@ export interface ScheduleWeeklyViewOptions { * @default 100 */ hourHeight?: number; + /** + * Where the grid scrolls when it mounts, when it scrolls vertically (as with + * `height="fill"`). Later renders, including paging, keep the user's scroll. + * By default the grid opens at its first hour. + */ + initialScrollTime?: ScheduleInitialScrollTime; /** * Exclusive ending hour shown in the weekly grid (1-24). * @default 24 @@ -104,6 +111,7 @@ function ScheduleWeeklyView({ days={days} height={height} hourHeight={options.hourHeight} + initialScrollTime={options.initialScrollTime} maxHour={options.maxHour} minHour={options.minHour} overlapBehavior={options.overlapBehavior} @@ -120,6 +128,7 @@ export function createScheduleWeeklyView({ dayMinWidth = 160, hiddenDays = EMPTY_HIDDEN_DAYS, hourHeight = 100, + initialScrollTime, maxHour = 24, minHour = 0, overlapBehavior = 'sideBySide', @@ -164,6 +173,7 @@ export function createScheduleWeeklyView({ dayMinWidth, hiddenDays: [...hiddenDaySet], hourHeight, + initialScrollTime, maxHour, minHour, overlapBehavior, diff --git a/src/components/Schedule/index.ts b/src/components/Schedule/index.ts index 13151c7a..3a13f5dd 100644 --- a/src/components/Schedule/index.ts +++ b/src/components/Schedule/index.ts @@ -71,6 +71,7 @@ export type { ScheduleEventColor, ScheduleEventBorderStyle, ScheduleHeight, + ScheduleInitialScrollTime, ScheduleEventPropsRenderProps, ScheduleEventSource, ScheduleHeaderContent, diff --git a/src/components/Schedule/types.ts b/src/components/Schedule/types.ts index b4034282..74d1fb74 100644 --- a/src/components/Schedule/types.ts +++ b/src/components/Schedule/types.ts @@ -39,6 +39,13 @@ export type ScheduleEventBorderStyle = 'border' | 'left' | 'none'; export type ScheduleTimeGridOverlapBehavior = 'indented' | 'sideBySide'; +/** + * Where a day or week time grid scrolls when it mounts. `'now'` places the + * current time a third of the way down the hours, and only when the grid shows + * today. A time of day is placed at the top of the hours on any date. + */ +export type ScheduleInitialScrollTime = 'now' | {hour: number; minute?: number}; + export interface ScheduleRange { end: Instant; endDate: PlainDate; diff --git a/src/index.ts b/src/index.ts index 02a6c150..c62630a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -321,6 +321,7 @@ export { type ScheduleEventSource, type ScheduleHeaderContent, type ScheduleHeight, + type ScheduleInitialScrollTime, type ScheduleListViewOptions, type ScheduleMonthlyViewOptions, type ScheduleMonthCellPropsRenderProps,