Moltenship Time
Views

Agenda

A chronological list of upcoming events, grouped by day, and a version that scrolls through months or years.

Live, in your time zone.

Try it in the live demo: pick Agenda in the view switcher.

createAgendaView lists the events in the coming weeks, one group per day. Days without events are left out. Only the days in view render, so a long range, such as weeks: 104, stays fast. For a list that scrolls without end and loads events as you go, use the scrolling agenda.

Add it

Copy registry/list-view into your app, along with the theme and registry/calendar, as described in UI source. Then register the view:

import { createAgendaView } from "@/components/calendar/list-view/agenda-view";

export const Calendar = createCalendar<Meeting>()({
  eventAdapter,
  views: [createAgendaView<Meeting>({ weeks: 2, getEventColor: (meeting) => meeting.color })],
});

The view is named agenda. It renders with @tanstack/react-virtual, which the registry item installs.

Scrolling agenda

Live, in your time zone. A year of events either way of today, fetched as you scroll.

createScrollingAgendaView from registry/scrolling-list-view is the agenda as a scrolling view: one list that scrolls through months or years, with the anchor date on top. This preview holds a year of events either way of today, a few thousand of them, and fetches them from a pretend server as you scroll. Try it in the demo with "Scroll day, week, month and agenda" on in the settings.

import { createScrollingAgendaView } from "@/components/calendar/scrolling-list-view/scrolling-agenda-view";

views: [createScrollingAgendaView<Meeting>({ dayView: "scrolling-day", getEventColor })];
ViewShowsLoadsPrevious and NextToday
scrolling-agendaweeks from the anchor date (4)A page either sideweeksToday on top
  • The anchor date is the day on top. The range starts at the anchor date rather than its week, since the list comes to rest on any day. When a scroll comes to rest, the anchor date moves to the day on top, so Calendar.Title, onAnchorDateChange, and a URL that stores the date follow it. On a long scroll it moves every half page on the way, so the next weeks load before they scroll into view.
  • Today, Previous, Next, and any other anchor date jump the list to put that date on top, or the next day with events, at once, as Google Calendar's agenda does, rather than gliding there as the other scrolling views do over a page or two. The list holds that date on top while its events load, until you scroll. With a controlled anchorDate, the list stays where it was scrolled until your state takes the latest date onAnchorDateChange sent, as in the other scrolling views.
  • Only the days in view render. Each is measured once it renders, as its events wrap, and days that load above or below the ones on screen don't move them.
  • The end of the list follows the last loaded day. It's as tall as the view, so any day can scroll to the top, and says "Loading events…" while loading is set, or that nothing more is scheduled. Scrolling on past either end of the loaded days moves a page that way, once per gesture, so the list crosses weeks without events. Weeks shown without events get the empty state, at the end of the list or among the days after them.

Loading events as you scroll

The scrolling agenda's load range is a page of weeks either way of the ones it shows. As with the other scrolling views, onVisibleRangeChange on Root reports it after the first render and whenever the anchor date moves it. Fetch what you haven't fetched yet, and set loading while some is on its way:

long-agenda.tsx
const timeZone = "Europe/Berlin";

/**
 * Fetches meetings for the dates the agenda lists, each date once, as it scrolls, and says so
 * while some are still on their way.
 */
export function LongAgenda({
  loadMeetings,
}: {
  readonly loadMeetings: (from: Temporal.Instant, to: Temporal.Instant) => Promise<Meeting[]>;
}) {
  const [loaded, setLoaded] = useState<ReadonlyMap<string, Meeting>>(new Map());
  const [fetching, setFetching] = useState(0);
  const requested = useRef(new Set<string>());
  const meetings = useMemo(() => [...loaded.values()], [loaded]);

  const load = useCallback(
    ({ loadRange }: CalendarVisibleRange) => {
      const missing = [...iteratePlainDates(loadRange)].filter(
        (date) => !requested.current.has(date.toString()),
      );
      if (missing.length === 0) return;
      for (const date of missing) requested.current.add(date.toString());
      const from = missing[0]!.toZonedDateTime(timeZone).toInstant();
      const to = missing.at(-1)!.add({ days: 1 }).toZonedDateTime(timeZone).toInstant();
      setFetching((count) => count + 1);
      void loadMeetings(from, to)
        .then((arrived) =>
          setLoaded((current) => new Map([...current, ...arrived.map((m) => [m.id, m] as const)])),
        )
        .finally(() => setFetching((count) => count - 1));
    },
    [loadMeetings],
  );

  return (
    <Calendar.Root
      events={meetings}
      defaultAnchorDate={Temporal.Now.plainDateISO(timeZone)}
      onVisibleRangeChange={load}
      timeZone={timeZone}
      locale="en-US"
      viewOptions={{ "scrolling-agenda": { loading: fetching > 0 } }}
      weekRules={weekRules}
    >
      <Calendar.ActiveView />
    </Calendar.Root>
  );
}

While loading is set, the end of the list says "Loading events…", and a view without events shows a loading state in place of the empty one. Time never fetches or keeps events itself.

Anatomy

AgendaView from registry/list-view renders a list with one group per day. The view builds these parts itself, so they're not parts you compose: to change one, edit its file. The tree shows where each part lives.

  1. Agenda viewAgendaView
  2. Day group header
  3. Day groupdata-date
  4. All-day badge
  5. ItemAgendaItem
  6. Day k of ndayCount
  7. End of listAgendaEnd
  8. Empty state
Rendered by AgendaView
AgendaView                       agenda-view.tsx
├── AgendaList                   agenda-list.tsx, scrolls the days in view
│   ├── Day group                agenda-day.tsx, data-date, one per day
│   │   ├── Day group header     weekday and date, sticky
│   │   └── AgendaItem           agenda-item.tsx, one per event
│   └── End of list              agenda-states.tsx, AgendaEnd: scrolling agenda only
└── Empty state                  agenda-states.tsx, EmptyAgenda: in place of the list

AgendaItem shows the time range or an "All day" badge, the title, and "Day 2 of 3", which comes from dayIndex and dayCount. use-agenda-virtualizer.ts decides which days render. The scrolling agenda's ScrollingAgendaView renders the same parts, and use-agenda-scroll.ts in registry/scrolling-list-view keeps its scroll position and the anchor date together.

Options

Prop

Type

What it shows

  • Days: each day with events gets a date column that stays in view while you scroll its events. Today is highlighted.
  • Times are formatted as a range with Intl, as in "10:00 – 11:30 AM". Events that started the day before read "Until 2:00 AM"; events that run into the next day read "From 10:00 PM".
  • All-day events show an "All day" badge, and events longer than a day show "Day 2 of 3".
  • Empty range: a short message when the weeks shown have no events, or a loading one while loading is set.
  • Clicking a date opens that day in the view named day. Register a day view so the click has somewhere to go.
  • Changes being saved: events with a pending change get aria-busy and render at 60% opacity.

Each day has data-date, and each event is a <button> with data-event-id.

Keyboard

Tab into the preview above and try these keys. The Accessibility page has the full map.

FocusKeyEffect
An event↑ ↓Previous or next event
An eventPage Up, Page DownFirst event of the previous or next day
An eventHome, EndFirst or last event
An eventEnterOpen it, as a click does
An eventShift+EnterOpen its date in the day view
A range without events↑ or Page Up, ↓ or Page DownThe previous or next range

Moving past the first or last event shows the previous or next weeks. In the scrolling agenda the keys move through the weeks it shows, and moving past them pages on as focus moves to the next event, scrolling it into view. The list scrolls to keep the focused event in view, and the focused day stays rendered while focus is in the list. Focus belongs to an event on a date, not to an element: if its day scrolls out of the loaded weeks, focus goes with it, and it returns once the day is in view again. The agenda doesn't move or create events from the keyboard, since it has no days or times to move them to.

Screen readers

The list is an <ol> named by its weeks. Only the days in view render, so each day's <li> carries aria-posinset and aria-setsize, counting every day listed: "list item, 5 of 41". The list is aria-busy while loading is set.

Edit on GitHub

Last updated on

On this page