diff --git a/src/components/Schedule/DayView.tsx b/src/components/Schedule/DayView.tsx index 8d5514f..b55ae7f 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 d1ffbc9..d546524 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 d604adb..5c6bbe2 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 fe2fcba..76cf0a4 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 763cbb4..7039970 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 13151c7..3a13f5d 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 b403428..74d1fb7 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 02a6c15..c62630a 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,