Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/components/Schedule/DayView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
formatMonthTitle,
} from 'components/Schedule/shared';
import type {
ScheduleInitialScrollTime,
ScheduleTimeGridOverlapBehavior,
ScheduleView,
ScheduleViewComponentProps,
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -85,6 +93,7 @@ function ScheduleDayView({
export function createScheduleDayView({
allDayEventLimit = 3,
hourHeight = 100,
initialScrollTime,
maxHour = 24,
minHour = 0,
overlapBehavior = 'sideBySide',
Expand All @@ -111,6 +120,7 @@ export function createScheduleDayView({
options: {
allDayEventLimit,
hourHeight,
initialScrollTime,
maxHour,
minHour,
overlapBehavior,
Expand Down
97 changes: 97 additions & 0 deletions src/components/Schedule/Schedule.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div style={{height: 400}}>
<Schedule
events={[]}
height="fill"
timezoneID="UTC"
view={createScheduleWeeklyView({hourHeight: 48, initialScrollTime})}
viewDate={viewDate}
/>
</div>
);
}

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(<ScrollingWeek initialScrollTime="now" viewDate={SEP_30} />);

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(<ScrollingWeek initialScrollTime="now" viewDate={OCT_7} />);

expect(timeGrid().scrollTop).toBe(0);
});

it('places a time of day at the top of the visible hours', () => {
render(
<ScrollingWeek
initialScrollTime={{hour: 8, minute: 30}}
viewDate={OCT_7}
/>,
);

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(
<ScrollingWeek initialScrollTime="now" viewDate={OCT_7} />,
);
timeGrid().scrollTop = 100;

rerender(<ScrollingWeek initialScrollTime="now" viewDate={SEP_30} />);

expect(timeGrid().scrollTop).toBe(100);
});
});
11 changes: 11 additions & 0 deletions src/components/Schedule/Schedule.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,17 @@ export const FillHeightWithSurplusSpace: Story = {
),
};

export const FillHeightScrolledToNow: Story = {
render: () => (
<div className={fillHeightStoryContainer}>
<ScheduleStory
height="fill"
view={createScheduleWeeklyView({initialScrollTime: 'now'})}
/>
</div>
),
};

export const CustomPlugin: Story = {
render: () => {
const customPlugin = useMemo<SchedulePlugin>(
Expand Down
69 changes: 69 additions & 0 deletions src/components/Schedule/TimeGridView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
CalendarEvent,
CalendarInstantEvent,
ScheduleHeight,
ScheduleInitialScrollTime,
SchedulePluginElementProps,
ScheduleTimeGridCellPropsRenderProps,
ScheduleTimeGridOverlapBehavior,
Expand All @@ -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';

Expand All @@ -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
Expand Down Expand Up @@ -417,6 +423,7 @@ export function TimeGridView({
days,
height,
hourHeight = 100,
initialScrollTime,
maxHour = 24,
minHour = 0,
overlapBehavior = 'sideBySide',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div className={styles.container}>
<div
Expand Down Expand Up @@ -879,6 +918,36 @@ export function TimeGridView({
);
}

/**
* Resolves `initialScrollTime` to the minute of the day to scroll to and how
* far down the visible hours it lands, or `null` when there is nothing to
* scroll to. The current time is read here rather than from `useCurrentTime`,
* whose server snapshot is still in place while hydrating.
*/
function getInitialScrollTarget(
initialScrollTime: ScheduleInitialScrollTime | undefined,
days: ReadonlyArray<PlainDate>,
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,
Expand Down
10 changes: 10 additions & 0 deletions src/components/Schedule/WeeklyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {scheduleRangeToScheduleZonedInstantRange} from 'components/Schedule/scheduleZonedInstant';
import {ScheduleFrame, formatWeekTitle} from 'components/Schedule/shared';
import type {
ScheduleInitialScrollTime,
ScheduleTimeGridOverlapBehavior,
ScheduleView,
ScheduleViewComponentProps,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -120,6 +128,7 @@ export function createScheduleWeeklyView({
dayMinWidth = 160,
hiddenDays = EMPTY_HIDDEN_DAYS,
hourHeight = 100,
initialScrollTime,
maxHour = 24,
minHour = 0,
overlapBehavior = 'sideBySide',
Expand Down Expand Up @@ -164,6 +173,7 @@ export function createScheduleWeeklyView({
dayMinWidth,
hiddenDays: [...hiddenDaySet],
hourHeight,
initialScrollTime,
maxHour,
minHour,
overlapBehavior,
Expand Down
1 change: 1 addition & 0 deletions src/components/Schedule/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export type {
ScheduleEventColor,
ScheduleEventBorderStyle,
ScheduleHeight,
ScheduleInitialScrollTime,
ScheduleEventPropsRenderProps,
ScheduleEventSource,
ScheduleHeaderContent,
Expand Down
7 changes: 7 additions & 0 deletions src/components/Schedule/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export {
type ScheduleEventSource,
type ScheduleHeaderContent,
type ScheduleHeight,
type ScheduleInitialScrollTime,
type ScheduleListViewOptions,
type ScheduleMonthlyViewOptions,
type ScheduleMonthCellPropsRenderProps,
Expand Down
Loading