@rapidmx/web-client 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +3 -0
  2. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
  3. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
  4. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
  6. package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
  7. package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
  8. package/apps/shared/components/calendar/EventModal.tsx +33 -12
  9. package/apps/shared/components/calendar/ListView.tsx +207 -0
  10. package/apps/shared/components/calendar/MonthView.tsx +14 -2
  11. package/apps/shared/components/calendar/SlideIn.tsx +47 -0
  12. package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
  13. package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
  14. package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
  15. package/apps/shared/components/calendar/allDay.ts +16 -1
  16. package/apps/shared/components/calendar/calendarView.ts +41 -0
  17. package/apps/shared/components/layout/AppShell.tsx +13 -6
  18. package/apps/shared/components/mail/ConversationList.tsx +29 -3
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
  22. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  24. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  25. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  26. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  27. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  28. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  29. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  30. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  31. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  32. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  33. package/apps/shared/keyboard/keymap.ts +2 -0
  34. package/apps/shared/mail/rangeSelection.ts +45 -0
  35. package/apps/shared/mail/rowExit.ts +39 -0
  36. package/apps/www/calendar/index.tsx +251 -53
  37. package/apps/www/index.tsx +522 -343
  38. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  40. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  41. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  42. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  43. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  44. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  45. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  46. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  47. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  48. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  49. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  50. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  51. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  52. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  53. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  54. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  55. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  56. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  57. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  58. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  59. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  60. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  61. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  62. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  63. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  64. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  65. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  66. package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
  67. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  68. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  69. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  70. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  71. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  72. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  73. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  74. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  75. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  76. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  77. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  78. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  79. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  80. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  81. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  83. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  84. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  85. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  86. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  87. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  88. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  89. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  90. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  91. package/dist/apps/shared/keyboard/keymap.js +2 -0
  92. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  93. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  94. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  95. package/dist/apps/shared/mail/rowExit.js +33 -0
  96. package/dist/apps/www/calendar/index.js +177 -18
  97. package/dist/apps/www/index.js +220 -65
  98. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  99. package/dist/lib/calendar/eventSearch.js +152 -0
  100. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  101. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  102. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  103. package/dist/lib/components/navigation/NavBadge.js +18 -0
  104. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  105. package/dist/lib/mail/compose/quotedImages.js +41 -0
  106. package/dist/lib/mail/inlineImages.d.ts +38 -0
  107. package/dist/lib/mail/inlineImages.js +90 -0
  108. package/dist/lib/mail/mailApi.d.ts +8 -1
  109. package/dist/lib/mail/mailApi.js +57 -5
  110. package/lib/calendar/eventSearch.ts +172 -0
  111. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  112. package/lib/components/navigation/NavBadge.tsx +33 -0
  113. package/lib/mail/compose/quotedImages.ts +49 -0
  114. package/lib/mail/inlineImages.ts +104 -0
  115. package/lib/mail/mailApi.ts +64 -5
  116. package/package.json +1 -1
  117. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  118. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  119. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
@@ -29,19 +29,26 @@ import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calen
29
29
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
30
30
  import { createFolder } from "../../../lib/mail/mailApi.js";
31
31
  import { CalendarOccurrence, expandAllOccurrences } from "../../../lib/calendar/recurrence.js";
32
+ import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
32
33
  import Drawer from "../../../lib/components/overlays/Drawer.js";
33
34
  import CalendarShell, { CalendarShellProps, useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
34
35
  import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
36
+ import CalendarSearchBox from "../../shared/components/calendar/CalendarSearchBox.js";
37
+ import { ActiveOccurrenceContext } from "../../shared/components/calendar/activeOccurrence.js";
38
+ import { isUpcoming, occurrenceDay } from "../../shared/components/calendar/allDay.js";
35
39
  import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
36
40
  import EventModal from "../../shared/components/calendar/EventModal.js";
37
41
  import FloatingActionButton from "../../shared/components/layout/FloatingActionButton.js";
38
42
  import { EventAnchor, anchorOf } from "../../shared/components/calendar/EventShell.js";
39
43
  import MiniDatePicker from "../../../lib/components/pickers/MiniDatePicker.js";
44
+ import ListView, { ListStep } from "../../shared/components/calendar/ListView.js";
40
45
  import MonthView from "../../shared/components/calendar/MonthView.js";
41
46
  import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
42
47
  import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
43
48
  import Alert from "../../../lib/components/feedback/Alert.js";
44
49
  import Button from "../../../lib/components/buttons/Button.js";
50
+ import { CALENDAR_VIEWS, CalendarView, getStoredCalendarView, isCalendarView, storeCalendarView } from "../../shared/components/calendar/calendarView.js";
51
+ import { SlideFrom } from "../../shared/components/calendar/SlideIn.js";
45
52
  import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
46
53
  import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
47
54
  import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
@@ -54,6 +61,9 @@ import { notifyApiError } from "../../shared/notifications/apiErrors.js";
54
61
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
55
62
  import { redactedEventUidOf } from "../../shared/calendar/calendarLiveUpdates.js";
56
63
 
64
+ /** The furthest instants a `Date` can hold: the List's "range" reaches both. */
65
+ const MAX_DATE_MS = 8.64e15;
66
+
57
67
  function CalendarPage(props: CalendarShellProps) {
58
68
  return (
59
69
  <CalendarShell {...props}>
@@ -62,11 +72,11 @@ function CalendarPage(props: CalendarShellProps) {
62
72
  );
63
73
  }
64
74
 
65
- type ViewType = "month" | "week" | "workWeek" | "day" | "split";
66
- const VIEW_TYPES: ViewType[] = ["month", "week", "workWeek", "day", "split"];
67
- const VIEW_LABELS: Record<ViewType, string> = { month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
68
- /** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4); the split view has none. */
75
+ type ViewType = CalendarView;
76
+ const VIEW_LABELS: Record<ViewType, string> = { list: "List", month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
77
+ /** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4, the List one more); the split view has none. */
69
78
  const VIEW_SHORTCUTS: Partial<Record<ViewType, ShortcutDef>> = {
79
+ list: SHORTCUTS.calendar.list,
70
80
  day: SHORTCUTS.calendar.day,
71
81
  workWeek: SHORTCUTS.calendar.workWeek,
72
82
  week: SHORTCUTS.calendar.week,
@@ -122,7 +132,20 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
122
132
  );
123
133
 
124
134
  const [drawerOpen, setDrawerOpen] = useState(false);
125
- const [view, setView] = useState<ViewType>("month");
135
+ // Month, until the effects below know better: a remembered view, or the List on a phone (never read while rendering, so the server's markup and the
136
+ // first client render agree). A desktop always opens on the Month unless the link or a remembered Week, Work Week, Day, Split or Month says otherwise.
137
+ const [view, setViewState] = useState<ViewType>("month");
138
+ // Whether the view was decided by the URL, a remembered choice or the reader (and so is not the device's default to follow).
139
+ const viewChosenRef = useRef(false);
140
+ /** Switches the view; a reader's own choice (`remember`) is also what the Calendar opens on next time - except the List on a desktop, which
141
+ * only a phone opens on, so it is never written there (and so can never turn up as the next desktop visit's view). */
142
+ function setView(next: ViewType, remember = true) {
143
+ viewChosenRef.current = true;
144
+ setViewState(next);
145
+ if (remember && (next !== "list" || isMobile)) {
146
+ storeCalendarView(next);
147
+ }
148
+ }
126
149
  const [viewDate, setViewDate] = useState(() => startOfDay(new Date()));
127
150
  const [events, setEvents] = useState<CalendarEvent[]>([]);
128
151
  const [loading, setLoading] = useState(true);
@@ -138,8 +161,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
138
161
  useEffect(() => {
139
162
  const params = new URLSearchParams(window.location.search);
140
163
  const requestedView = params.get("view");
141
- if ((VIEW_TYPES as string[]).includes(requestedView ?? "")) {
142
- setView(requestedView as ViewType);
164
+ const storedView = getStoredCalendarView();
165
+ if (isCalendarView(requestedView)) {
166
+ // Asked for by the link: shown, but not remembered - it is not a choice of the reader's.
167
+ setView(requestedView, false);
168
+ } else if (storedView && storedView !== "list") {
169
+ // A remembered List is not restored: it is the phone's default (below) and never the desktop's, whatever an older visit left in storage.
170
+ setView(storedView, false);
143
171
  }
144
172
  const requestedDate = params.get("date");
145
173
  // `parseISO()`, not `new Date()` - `?date=2026-06-16` is a local calendar day, but `new Date()` reads a
@@ -150,6 +178,14 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
150
178
  }
151
179
  }, []);
152
180
 
181
+ // With nothing remembered the device decides: the List on a phone, the Month elsewhere (`useIsMobile()` only knows after mounting).
182
+ const isMobile = useIsMobile();
183
+ useEffect(() => {
184
+ if (!viewChosenRef.current) {
185
+ setViewState(isMobile ? "list" : "month");
186
+ }
187
+ }, [isMobile]);
188
+
153
189
  const checkedFolderUids = checkedFolderUidsState ?? new Set(calendarFolders.map((f) => f.uid));
154
190
  const folderColors = useMemo(
155
191
  () => Object.fromEntries(calendarFolders.map((f) => [f.uid, colorFor(f)])),
@@ -175,6 +211,10 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
175
211
  }
176
212
 
177
213
  const { rangeStart, rangeEnd, days } = useMemo(() => {
214
+ if (view === "list") {
215
+ // The list is not a range at all: every event, whenever it falls (a series bounded as the search bounds it - see `allOccurrences()`).
216
+ return { rangeStart: new Date(-MAX_DATE_MS), rangeEnd: new Date(MAX_DATE_MS), days: [] as Date[] };
217
+ }
178
218
  if (view === "month") {
179
219
  const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
180
220
  const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
@@ -287,10 +327,104 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
287
327
  [client],
288
328
  );
289
329
 
290
- const occurrences = useMemo(
291
- () => expandAllOccurrences(events, rangeStart, rangeEnd),
292
- [events, rangeStart, rangeEnd],
293
- );
330
+ // Search: `searchText` is the box, `query` what it held once typing paused (or Enter was pressed) and is what is searched for. A search covers every
331
+ // event of the checked calendars whatever the view shows (see `searchOccurrences()`); while one runs the views show only its matches, and
332
+ // `activeKey` is the match being pointed at (Previous/Next match step through `matches`).
333
+ const [searchText, setSearchText] = useState("");
334
+ const [query, setQuery] = useState("");
335
+ const [activeKey, setActiveKey] = useState<string | null>(null);
336
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
337
+ const searching = searchTerms(query).length > 0;
338
+ useEffect(() => {
339
+ const timer = setTimeout(() => setQuery(searchText), 300);
340
+ return () => clearTimeout(timer);
341
+ }, [searchText]);
342
+ // The List shows only what is from today on, so that is all a search steps through there; the other views reach the past ones.
343
+ const matches = useMemo(() => {
344
+ const found = searching ? searchOccurrences(events, query) : [];
345
+ const today = new Date();
346
+ return view === "list" ? found.filter((occurrence) => isUpcoming(occurrence, today)) : found;
347
+ }, [events, query, searching, view]);
348
+ const activeIndex = matches.findIndex((m) => m.occurrenceKey === activeKey);
349
+
350
+ // What an open event card steps through, in order: the matches while a search runs, else every event of the checked calendars (bounded as a
351
+ // search is). The List holds only what is from today on, so that is all its card steps through. Worked out only when something needs it.
352
+ const everything = useMemo(() => (view === "list" || modal?.occurrence ? allOccurrences(events) : []), [events, view, !!modal?.occurrence]);
353
+ const sequence = useMemo(() => {
354
+ if (searching) {
355
+ return matches;
356
+ }
357
+ if (view === "list") {
358
+ const today = new Date();
359
+ return everything.filter((occurrence) => isUpcoming(occurrence, today));
360
+ }
361
+ return everything;
362
+ }, [searching, matches, view, everything]);
363
+
364
+ const occurrences = useMemo(() => {
365
+ if (view === "list") {
366
+ return sequence;
367
+ }
368
+ return searching ? occurrencesInRange(matches, rangeStart, rangeEnd) : expandAllOccurrences(events, rangeStart, rangeEnd);
369
+ }, [events, matches, searching, view, sequence, rangeStart, rangeEnd]);
370
+
371
+ /** Points at `match`, moving the view to the day it is on unless the view already shows it. */
372
+ function showMatch(match: CalendarOccurrence) {
373
+ setActiveKey(match.occurrenceKey);
374
+ // An event's card that is open moves on to it, so the card and the view stay together.
375
+ setModal((current) => (current?.occurrence ? { occurrence: match } : current));
376
+ if (occurrencesInRange([match], rangeStart, rangeEnd).length === 0) {
377
+ setViewDate(occurrenceDay(match));
378
+ }
379
+ }
380
+
381
+ // The step that moved an open event card on, for the card to slide in from the side of the button pressed (also on a wrap-around).
382
+ const [slide, setSlide] = useState<{ from: SlideFrom; occurrenceKey: string } | undefined>(undefined);
383
+
384
+ // The card open is where the sequence stands; with none, the match the search is on. A search wraps round at either end, the events of the
385
+ // calendar do not.
386
+ const stepFrom = modal?.occurrence;
387
+ const stepIndex = (direction: 1 | -1) =>
388
+ stepSequenceIndex(sequence, stepFrom?.occurrenceKey ?? activeKey, stepFrom ? new Date(stepFrom.startDate) : undefined, direction, searching);
389
+
390
+ function stepMatch(direction: 1 | -1) {
391
+ const next = sequence[stepIndex(direction)];
392
+ if (next) {
393
+ setSlide({ from: direction === 1 ? "right" : "left", occurrenceKey: next.occurrenceKey });
394
+ showMatch(next);
395
+ }
396
+ }
397
+
398
+ function clearSearch() {
399
+ setSearchText("");
400
+ setQuery("");
401
+ }
402
+
403
+ function submitSearch(backwards: boolean) {
404
+ if (searchText !== query) {
405
+ setQuery(searchText);
406
+ } else {
407
+ stepMatch(backwards ? -1 : 1);
408
+ }
409
+ }
410
+
411
+ // A new search goes to its first match: the first one in the view if there is one, else the first from the day being viewed on (the nearest
412
+ // before it when there is none after). Matches that change under the same query (a saved edit, a live update) leave the view where it is.
413
+ const searchedQueryRef = useRef("");
414
+ useEffect(() => {
415
+ if (!searching || matches.length === 0) {
416
+ searchedQueryRef.current = "";
417
+ setActiveKey(null);
418
+ return;
419
+ }
420
+ if (searchedQueryRef.current !== query) {
421
+ searchedQueryRef.current = query;
422
+ // The List holds every match: it starts at the first from the day being viewed on rather than the first of all.
423
+ const from = startOfDay(viewDate);
424
+ showMatch(matches[view === "list" ? initialMatchIndex(matches, from, rangeEnd, from) : initialMatchIndex(matches, rangeStart, rangeEnd, from)]);
425
+ }
426
+ }, [matches, query, searching]);
427
+
294
428
 
295
429
  const splitColumns = useMemo(
296
430
  () =>
@@ -301,6 +435,11 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
301
435
  );
302
436
 
303
437
  function shiftView(direction: 1 | -1) {
438
+ if (view === "list") {
439
+ // Previous and Next move the list a month, from wherever it has been scrolled to (the list does the scrolling).
440
+ setListStep((step) => ({ direction, nonce: (step?.nonce ?? 0) + 1 }));
441
+ return;
442
+ }
304
443
  setViewDate((d) =>
305
444
  view === "month"
306
445
  ? addMonths(d, direction)
@@ -314,9 +453,8 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
314
453
  // the Previous/Next buttons and shortcuts do, and it is off while an event is being dragged or the editor is open. The views
315
454
  // never scroll sideways (their columns share the width), so there is no horizontal scroller for the gesture to fight with.
316
455
  // The view follows the finger and, once the swipe commits, slides out while the next period slides in (see `useSwipeSlide()`).
317
- const isMobile = useIsMobile();
318
456
  const swipe = useSwipeSlide({
319
- enabled: isMobile && !dragging && !modal,
457
+ enabled: isMobile && !dragging && !modal && view !== "list",
320
458
  onShift: (direction) => shiftView(SWIPE_PERIOD_SHIFT[direction]),
321
459
  });
322
460
 
@@ -328,7 +466,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
328
466
  const arrivalEdge = useRef<WheelEdge | null>(null);
329
467
  const scrollerOf = (): HTMLElement | null => swipe.ref.current?.querySelector<HTMLElement>("[data-calendar-scroller]") ?? null;
330
468
  useWheelPaging(swipe.ref, {
331
- enabled: !isMobile && !dragging && !modal,
469
+ enabled: !isMobile && !dragging && !modal && view !== "list",
332
470
  getScroller: () => (view === "month" ? null : scrollerOf()),
333
471
  onStep: (direction, edge) => {
334
472
  arrivalEdge.current = edge;
@@ -345,12 +483,22 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
345
483
  }
346
484
  }, [viewDate]);
347
485
 
486
+ // Brings the match being pointed at into sight (the views scroll their own hours; the page may too).
487
+ useEffect(() => {
488
+ const marked = Array.from(swipe.ref.current!.querySelectorAll<HTMLElement>("[data-occurrence-key]"));
489
+ marked.find((el) => el.dataset.occurrenceKey === activeKey)?.scrollIntoView({ block: "nearest", inline: "nearest" });
490
+ }, [activeKey, view === "list" ? null : viewDate, view, loading]);
491
+
492
+ // The List scrolls to `viewDate` when this changes (Today, a day picked in the mini calendar), even when it is the date it already has.
493
+ const [jumpNonce, setJumpNonce] = useState(0);
494
+ const [listStep, setListStep] = useState<ListStep | null>(null);
348
495
  function goToday() {
349
496
  setViewDate(startOfDay(new Date()));
497
+ setJumpNonce((n) => n + 1);
350
498
  }
351
499
 
352
500
  function handleSelectDay(date: Date) {
353
- setView("day");
501
+ setView("day", false);
354
502
  setViewDate(startOfDay(date));
355
503
  }
356
504
 
@@ -371,6 +519,8 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
371
519
  }
372
520
 
373
521
  function openEvent(occurrence: CalendarOccurrence) {
522
+ setSlide(undefined);
523
+ setActiveKey(occurrence.occurrenceKey);
374
524
  setModal({ occurrence });
375
525
  }
376
526
 
@@ -383,10 +533,15 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
383
533
  useShortcut(SHORTCUTS.calendar.today, goToday);
384
534
  useShortcut(SHORTCUTS.calendar.previous, () => shiftView(-1));
385
535
  useShortcut(SHORTCUTS.calendar.next, () => shiftView(1));
536
+ useShortcut(SHORTCUTS.calendar.list, () => setView("list"));
386
537
  useShortcut(SHORTCUTS.calendar.day, () => setView("day"));
387
538
  useShortcut(SHORTCUTS.calendar.workWeek, () => setView("workWeek"));
388
539
  useShortcut(SHORTCUTS.calendar.week, () => setView("week"));
389
540
  useShortcut(SHORTCUTS.calendar.month, () => setView("month"));
541
+ useShortcut(SHORTCUTS.calendar.search, () => {
542
+ searchInputRef.current?.focus();
543
+ searchInputRef.current?.select();
544
+ });
390
545
  const newEventHint = useShortcutProps("New event", SHORTCUTS.calendar.create, !!mailboxUid && !!folderUid);
391
546
  const previousHint = useShortcutProps("Previous", SHORTCUTS.calendar.previous);
392
547
  const todayHint = useShortcutProps("Today", SHORTCUTS.calendar.today);
@@ -422,8 +577,10 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
422
577
  }
423
578
 
424
579
  const title =
425
- view === "month"
426
- ? format(viewDate, "MMMM yyyy")
580
+ view === "list"
581
+ ? "Upcoming events"
582
+ : view === "month"
583
+ ? format(viewDate, "MMMM yyyy")
427
584
  : view === "week" || view === "workWeek"
428
585
  ? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
429
586
  : format(viewDate, "EEEE, MMMM d, yyyy");
@@ -444,7 +601,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
444
601
 
445
602
  const sidebarContent = (
446
603
  <>
447
- <MiniDatePicker selected={viewDate} onSelect={(date) => setViewDate(startOfDay(date))} />
604
+ <MiniDatePicker
605
+ selected={viewDate}
606
+ onSelect={(date) => {
607
+ setViewDate(startOfDay(date));
608
+ setJumpNonce((n) => n + 1);
609
+ }}
610
+ />
448
611
  <CalendarListSidebar
449
612
  mailboxCalendars={mailboxCalendars}
450
613
  checkedFolderUids={checkedFolderUids}
@@ -490,8 +653,20 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
490
653
  </button>
491
654
  </div>
492
655
  <h1 className="hidden md:block text-lg font-bold tracking-tight">{title}</h1>
493
- <div className="ml-auto flex flex-wrap gap-1">
494
- {VIEW_TYPES.map((v) => (
656
+ <CalendarSearchBox
657
+ value={searchText}
658
+ onChange={setSearchText}
659
+ onSubmit={submitSearch}
660
+ onClear={clearSearch}
661
+ onPrevious={() => stepMatch(-1)}
662
+ onNext={() => stepMatch(1)}
663
+ active={searching}
664
+ count={matches.length}
665
+ position={activeIndex}
666
+ inputRef={searchInputRef}
667
+ />
668
+ <div className="flex flex-wrap gap-1">
669
+ {CALENDAR_VIEWS.map((v) => (
495
670
  <ViewButton key={v} view={v} current={view} onSelect={setView} />
496
671
  ))}
497
672
  </div>
@@ -509,39 +684,50 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
509
684
  {loading ? (
510
685
  <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
511
686
  ) : (
512
- <DndContext
513
- sensors={sensors}
514
- onDragStart={() => setDragging(true)}
515
- onDragEnd={handleDragEnd}
516
- onDragCancel={() => setDragging(false)}
517
- >
518
- {view === "month" ? (
519
- <MonthView
520
- viewDate={viewDate}
521
- occurrences={occurrences}
522
- folderColors={folderColors}
523
- onSelectDay={handleSelectDay}
524
- onSelectEvent={openEvent}
525
- onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor, true)}
526
- />
527
- ) : view === "split" ? (
528
- <SplitDayView
529
- day={viewDate}
530
- columns={splitColumns}
531
- occurrences={occurrences}
532
- onSelectEvent={openEvent}
533
- onSelectSlot={(start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor)}
534
- />
535
- ) : (
536
- <TimeGridView
537
- days={days}
538
- occurrences={occurrences}
539
- folderColors={folderColors}
540
- onSelectEvent={openEvent}
541
- onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor)}
542
- />
543
- )}
544
- </DndContext>
687
+ <ActiveOccurrenceContext.Provider value={searching ? activeKey : null}>
688
+ <DndContext
689
+ sensors={sensors}
690
+ onDragStart={() => setDragging(true)}
691
+ onDragEnd={handleDragEnd}
692
+ onDragCancel={() => setDragging(false)}
693
+ >
694
+ {view === "list" ? (
695
+ <ListView
696
+ occurrences={occurrences}
697
+ folderColors={folderColors}
698
+ onSelectEvent={openEvent}
699
+ focusDate={viewDate}
700
+ jumpNonce={jumpNonce}
701
+ step={listStep}
702
+ />
703
+ ) : view === "month" ? (
704
+ <MonthView
705
+ viewDate={viewDate}
706
+ occurrences={occurrences}
707
+ folderColors={folderColors}
708
+ onSelectDay={handleSelectDay}
709
+ onSelectEvent={openEvent}
710
+ onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor, true)}
711
+ />
712
+ ) : view === "split" ? (
713
+ <SplitDayView
714
+ day={viewDate}
715
+ columns={splitColumns}
716
+ occurrences={occurrences}
717
+ onSelectEvent={openEvent}
718
+ onSelectSlot={(start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor)}
719
+ />
720
+ ) : (
721
+ <TimeGridView
722
+ days={days}
723
+ occurrences={occurrences}
724
+ folderColors={folderColors}
725
+ onSelectEvent={openEvent}
726
+ onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor)}
727
+ />
728
+ )}
729
+ </DndContext>
730
+ </ActiveOccurrenceContext.Provider>
545
731
  )}
546
732
  </div>
547
733
  </div>
@@ -568,6 +754,18 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
568
754
  initialAllDay={modal.initialAllDay}
569
755
  anchor={modal.anchor}
570
756
  bookingHref={bookingHref}
757
+ matchNav={
758
+ modal.occurrence && sequence.length > 0
759
+ ? {
760
+ onPrevious: () => stepMatch(-1),
761
+ onNext: () => stepMatch(1),
762
+ canPrevious: stepIndex(-1) >= 0,
763
+ canNext: stepIndex(1) >= 0,
764
+ noun: searching ? "match" : "event",
765
+ slide,
766
+ }
767
+ : undefined
768
+ }
571
769
  onSaved={handleSaved}
572
770
  onDeleted={handleDeleted}
573
771
  />