@rapidmx/web-client 0.14.0 → 0.15.1

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 (171) hide show
  1. package/apps/admin/domains/[uid].tsx +3 -2
  2. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  3. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  4. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
  6. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
  8. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  9. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  10. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  11. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  12. package/apps/shared/components/calendar/EventEditor.tsx +640 -0
  13. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  14. package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
  15. package/apps/shared/components/calendar/EventModal.tsx +126 -862
  16. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  17. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  18. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  19. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  20. package/apps/shared/components/calendar/MonthView.tsx +15 -3
  21. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  22. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  23. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  24. package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
  25. package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
  26. package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
  27. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  28. package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
  29. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  30. package/apps/shared/components/calendar/eventForm.ts +108 -0
  31. package/apps/shared/components/calendar/eventFormat.ts +183 -0
  32. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  33. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  34. package/apps/shared/components/layout/UserMenu.tsx +80 -28
  35. package/apps/shared/components/mail/ConversationList.tsx +100 -48
  36. package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
  37. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  38. package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
  39. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  40. package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
  41. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  42. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  43. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  44. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  45. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  46. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
  48. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  49. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  50. package/apps/shared/gestures/useSwipe.ts +114 -0
  51. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  52. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  53. package/apps/shared/mail/newMailNotifications.ts +171 -183
  54. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  55. package/apps/shared/notifications/preferences.ts +36 -0
  56. package/apps/shared/notifications/store.ts +560 -551
  57. package/apps/www/_routes.ts +1 -0
  58. package/apps/www/calendar/index.tsx +139 -37
  59. package/apps/www/index.tsx +273 -126
  60. package/apps/www/messages/[uid].tsx +31 -4
  61. package/apps/www/settings/profile/index.tsx +248 -0
  62. package/dist/apps/admin/domains/[uid].js +4 -2
  63. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  64. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  65. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  66. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  67. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  68. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  69. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  70. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
  71. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  72. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  73. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  74. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  75. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  76. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  77. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  78. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  79. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  80. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  81. package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
  82. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  83. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  84. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
  85. package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
  86. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  87. package/dist/apps/shared/components/calendar/EventModal.js +57 -455
  88. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  89. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  90. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  91. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  92. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  93. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  94. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  95. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  96. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  97. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  98. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  99. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  100. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  101. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  102. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  103. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  104. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  105. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  106. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  107. package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
  108. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  109. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  110. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  111. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  112. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  113. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  114. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  115. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  116. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  117. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  118. package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
  119. package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
  120. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  121. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  122. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  123. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
  125. package/dist/apps/shared/components/layout/UserMenu.js +70 -23
  126. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  127. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  129. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  130. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  131. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  132. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  133. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  134. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
  135. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  136. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  137. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  138. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  139. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  140. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  141. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  142. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  143. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  144. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  145. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  146. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  147. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  148. package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
  149. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  150. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  151. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  152. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  153. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  154. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  155. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  156. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  157. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  158. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  159. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  160. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  161. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  162. package/dist/apps/shared/notifications/preferences.js +36 -0
  163. package/dist/apps/shared/notifications/store.d.ts +1 -0
  164. package/dist/apps/shared/notifications/store.js +7 -23
  165. package/dist/apps/www/_routes.js +1 -0
  166. package/dist/apps/www/calendar/index.js +76 -6
  167. package/dist/apps/www/index.js +140 -39
  168. package/dist/apps/www/messages/[uid].js +13 -5
  169. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  170. package/dist/apps/www/settings/profile/index.js +124 -0
  171. package/package.json +3 -3
@@ -22,6 +22,7 @@ export interface SettingsSectionDef {
22
22
 
23
23
  /** Every settings section with its own sidebar entry. */
24
24
  export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
25
+ { id: "profile", href: "/settings/profile", label: "Profile" },
25
26
  { id: "appearance", href: "/settings/appearance", label: "Appearance" },
26
27
  { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
27
28
  { id: "filters", href: "/settings/filters", label: "Mail Filters" },
@@ -0,0 +1,54 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef, useState, type CSSProperties } from "react";
6
+
7
+ /** How long the new contents take to settle. Short, so a run of steps (a wheel kept turning) never waits on the last one. */
8
+ export const ENTER_SLIDE_MS = 140;
9
+ /** How far off, in pixels, and how faded the new contents start. */
10
+ const ENTER_OFFSET_PX = 56;
11
+ const ENTER_START_OPACITY = 0.35;
12
+ /** The pause that lets the browser draw the contents at their starting place before they move. */
13
+ const ENTER_SETTLE_MS = 20;
14
+
15
+ /** Whether the user asked their device for less motion. */
16
+ function prefersReducedMotion(): boolean {
17
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
18
+ }
19
+
20
+ /**
21
+ * The look of a page that has just been swapped for the next or previous one by a vertical step (the wheel): the new contents start
22
+ * a little way off in the direction the step went - below for `1` (the next), above for `-1` - and faded, and settle into place.
23
+ * The old contents are not animated out: the swap is immediate, so a run of steps never queues, and a step in the middle of a
24
+ * settle just starts the next one from its start. Put `style` on the element that holds the contents and call `enter()` as the
25
+ * contents change. Nothing moves for someone who asked for less motion.
26
+ */
27
+ export function useEnterSlide() {
28
+ const [offset, setOffset] = useState(0);
29
+ const [animated, setAnimated] = useState(false);
30
+ const timer = useRef<number | undefined>(undefined);
31
+
32
+ useEffect(() => () => window.clearTimeout(timer.current), []);
33
+
34
+ function enter(direction: 1 | -1) {
35
+ if (prefersReducedMotion()) {
36
+ return;
37
+ }
38
+ window.clearTimeout(timer.current);
39
+ setAnimated(false);
40
+ setOffset(direction * ENTER_OFFSET_PX);
41
+ timer.current = window.setTimeout(() => {
42
+ setAnimated(true);
43
+ setOffset(0);
44
+ }, ENTER_SETTLE_MS);
45
+ }
46
+
47
+ const style: CSSProperties = {
48
+ transform: offset === 0 ? undefined : `translate3d(0, ${offset}px, 0)`,
49
+ opacity: offset === 0 ? undefined : ENTER_START_OPACITY,
50
+ transition: animated ? `transform ${ENTER_SLIDE_MS}ms ease-out, opacity ${ENTER_SLIDE_MS}ms ease-out` : "none",
51
+ };
52
+
53
+ return { style, enter };
54
+ }
@@ -0,0 +1,114 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useRef, type TouchEvent } from "react";
6
+
7
+ /** Which way the finger moved: `"left"` is right to left. */
8
+ export type SwipeDirection = "left" | "right";
9
+
10
+ export interface UseSwipeOptions {
11
+ /** Nothing is tracked while this is `false` (a desktop pointer, select mode). Default `true`. */
12
+ enabled?: boolean;
13
+ /** The directions that mean something; a drag the other way doesn't move at all. Default both. */
14
+ directions?: readonly SwipeDirection[];
15
+ /** How far the finger has to travel to commit, in px. Default `MIN_SWIPE_DISTANCE`, or a third of the element's width when that is more. */
16
+ distance?: number;
17
+ /** Called as the finger moves, with the horizontal offset (the direction filter applied), and with `0` once it lifts. */
18
+ onDrag?: (offset: number) => void;
19
+ /** Called once when a swipe commits. `onDrag(0)` follows. */
20
+ onSwipe: (direction: SwipeDirection) => void;
21
+ }
22
+
23
+ /** The least distance that commits a swipe, and how far the finger must move before its direction is decided. */
24
+ export const MIN_SWIPE_DISTANCE = 80;
25
+ export const SWIPE_SLOP = 10;
26
+ /** A quicker flick than this (px per ms) commits after `FLING_MIN_DISTANCE` alone. */
27
+ export const FLING_VELOCITY = 0.5;
28
+ export const FLING_MIN_DISTANCE = 40;
29
+
30
+ /**
31
+ * A horizontal swipe by one finger, for the touch screens the app's phone layout is for: mouse and pen are left alone.
32
+ *
33
+ * Spread the handlers on the element and give it `touch-action: pan-y` (`style`), so the browser keeps scrolling vertically and leaves
34
+ * the horizontal movement to this. A gesture that starts out mostly vertical is a scroll and is never a swipe, however far it later
35
+ * strays sideways; one that starts out horizontal is a swipe, and stays one.
36
+ */
37
+ export function useSwipe({ enabled = true, directions = ["left", "right"], distance, onDrag, onSwipe }: UseSwipeOptions) {
38
+ const start = useRef<{ x: number; y: number; time: number; width: number; mode?: "swipe" | "scroll" } | null>(null);
39
+ const offset = useRef(0);
40
+ const latest = useRef({ directions, distance, onDrag, onSwipe });
41
+ latest.current = { directions, distance, onDrag, onSwipe };
42
+
43
+ function clamp(dx: number): number {
44
+ const allowed = latest.current.directions;
45
+ return (dx < 0 && !allowed.includes("left")) || (dx > 0 && !allowed.includes("right")) ? 0 : dx;
46
+ }
47
+
48
+ function onTouchStart(event: TouchEvent<HTMLElement>) {
49
+ if (!enabled || event.touches.length !== 1) {
50
+ start.current = null;
51
+ return;
52
+ }
53
+ const touch = event.touches[0];
54
+ start.current = { x: touch.clientX, y: touch.clientY, time: Date.now(), width: event.currentTarget.clientWidth };
55
+ offset.current = 0;
56
+ }
57
+
58
+ function onTouchMove(event: TouchEvent<HTMLElement>) {
59
+ const gesture = start.current;
60
+ if (!gesture || gesture.mode === "scroll") {
61
+ return;
62
+ }
63
+ if (event.touches.length !== 1) {
64
+ cancel();
65
+ return;
66
+ }
67
+ const dx = event.touches[0].clientX - gesture.x;
68
+ const dy = event.touches[0].clientY - gesture.y;
69
+ if (!gesture.mode) {
70
+ if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_SLOP) {
71
+ return;
72
+ }
73
+ gesture.mode = Math.abs(dx) > Math.abs(dy) * 1.5 ? "swipe" : "scroll";
74
+ if (gesture.mode === "scroll") {
75
+ return;
76
+ }
77
+ }
78
+ offset.current = clamp(dx);
79
+ latest.current.onDrag?.(offset.current);
80
+ }
81
+
82
+ function onTouchEnd() {
83
+ const gesture = start.current;
84
+ start.current = null;
85
+ if (!gesture || gesture.mode !== "swipe") {
86
+ return;
87
+ }
88
+ const dx = offset.current;
89
+ offset.current = 0;
90
+ const travelled = Math.abs(dx);
91
+ const velocity = travelled / Math.max(1, Date.now() - gesture.time);
92
+ const needed = latest.current.distance ?? Math.max(MIN_SWIPE_DISTANCE, gesture.width / 3);
93
+ const committed = dx !== 0 && (travelled >= needed || (velocity >= FLING_VELOCITY && travelled >= FLING_MIN_DISTANCE));
94
+ if (committed) {
95
+ latest.current.onSwipe(dx < 0 ? "left" : "right");
96
+ }
97
+ latest.current.onDrag?.(0);
98
+ }
99
+
100
+ function cancel() {
101
+ const moved = start.current?.mode === "swipe";
102
+ start.current = null;
103
+ offset.current = 0;
104
+ if (moved) {
105
+ latest.current.onDrag?.(0);
106
+ }
107
+ }
108
+
109
+ return {
110
+ handlers: enabled ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: cancel } : {},
111
+ /** Leaves vertical panning to the browser. */
112
+ style: enabled ? ({ touchAction: "pan-y" } as const) : undefined,
113
+ };
114
+ }
@@ -0,0 +1,113 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef, useState, type CSSProperties } from "react";
6
+ import { SwipeDirection, useSwipe } from "./useSwipe.js";
7
+
8
+ /** How long the current contents take to leave, and the new ones to arrive - or the current ones to snap back. */
9
+ export const SLIDE_MS = 200;
10
+ /** The pause between the new contents being put off-screen and them starting to slide in, long enough for the browser to draw them there. */
11
+ const SETTLE_MS = 30;
12
+ /** How far a slide goes when the element's width can't be measured. */
13
+ const FALLBACK_WIDTH_PX = 320;
14
+
15
+ export interface UseSwipeSlideOptions {
16
+ /** Nothing is tracked while this is `false`. */
17
+ enabled: boolean;
18
+ /** Changes what is shown to the period the swipe went to - called once the old contents have left, while the new ones wait off-screen. */
19
+ onShift: (direction: SwipeDirection) => void;
20
+ }
21
+
22
+ /** Whether the user asked their device for less motion, in which case the change is made at once instead of slid. */
23
+ function prefersReducedMotion(): boolean {
24
+ return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
25
+ }
26
+
27
+ /**
28
+ * A swipe between pages of content that visibly moves them, the way a phone calendar does: the contents follow the finger, and once
29
+ * the swipe commits they carry on out of view in the direction it went while the next ones - `onShift()` changes what is shown at
30
+ * that moment - slide in from the other side. A swipe that stops short slides back. The animation is timed here (`SLIDE_MS`) rather than
31
+ * left to `transitionend`, so it can't stall if the browser skips a transition.
32
+ *
33
+ * Put `handlers` and `style` on a clipping element (`overflow-hidden`, so the contents don't widen the page while they are off to
34
+ * the side), the contents inside it in an element of their own with `contentStyle`, and `ref` on the clipping element.
35
+ */
36
+ export function useSwipeSlide({ enabled, onShift }: UseSwipeSlideOptions) {
37
+ const [offset, setOffset] = useState(0);
38
+ /** Whether a change of `offset` is animated: not while the finger is moving it, yes for everything else. */
39
+ const [animated, setAnimated] = useState(false);
40
+ /** A committed slide is under way: further swipes are ignored until it ends. */
41
+ const [sliding, setSliding] = useState(false);
42
+ const slidingRef = useRef(false);
43
+ const ref = useRef<HTMLDivElement | null>(null);
44
+ const timers = useRef<number[]>([]);
45
+ const latest = useRef({ onShift });
46
+ latest.current = { onShift };
47
+
48
+ useEffect(
49
+ () => () => {
50
+ for (const timer of timers.current) {
51
+ window.clearTimeout(timer);
52
+ }
53
+ },
54
+ [],
55
+ );
56
+
57
+ function later(work: () => void, ms: number) {
58
+ timers.current.push(window.setTimeout(work, ms));
59
+ }
60
+
61
+ const swipe = useSwipe({
62
+ enabled: enabled && !sliding,
63
+ onDrag: (dx) => {
64
+ // `useSwipe()` reports the finger lifting (0) right after a swipe commits: that must not undo the slide out.
65
+ if (slidingRef.current) {
66
+ return;
67
+ }
68
+ setAnimated(dx === 0);
69
+ setOffset(dx);
70
+ },
71
+ onSwipe: (direction) => {
72
+ if (prefersReducedMotion()) {
73
+ latest.current.onShift(direction);
74
+ return;
75
+ }
76
+ const width = ref.current?.clientWidth || FALLBACK_WIDTH_PX;
77
+ // The finger moved `direction`, so the contents go that way and the next ones come from the opposite side.
78
+ const out = direction === "left" ? -width : width;
79
+ slidingRef.current = true;
80
+ setSliding(true);
81
+ setAnimated(true);
82
+ setOffset(out);
83
+ later(() => {
84
+ latest.current.onShift(direction);
85
+ setAnimated(false);
86
+ setOffset(-out);
87
+ later(() => {
88
+ setAnimated(true);
89
+ setOffset(0);
90
+ later(() => {
91
+ slidingRef.current = false;
92
+ setSliding(false);
93
+ }, SLIDE_MS);
94
+ }, SETTLE_MS);
95
+ }, SLIDE_MS);
96
+ },
97
+ });
98
+
99
+ const contentStyle: CSSProperties = {
100
+ transform: offset === 0 ? undefined : `translate3d(${offset}px, 0, 0)`,
101
+ transition: animated ? `transform ${SLIDE_MS}ms ease-out` : "none",
102
+ };
103
+
104
+ return {
105
+ handlers: swipe.handlers,
106
+ /** For the clipping element: `touch-action: pan-y`, and no overflow. */
107
+ style: swipe.style,
108
+ contentStyle,
109
+ ref,
110
+ /** For a test or a style hook: `"idle"`, `"dragging"` or `"sliding"`. */
111
+ phase: sliding ? "sliding" : offset !== 0 && !animated ? "dragging" : "idle",
112
+ };
113
+ }
@@ -0,0 +1,107 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useRef, type RefObject } from "react";
6
+
7
+ /** Wheel movement, in pixels, that steps to the next or previous page when nothing inside scrolls: about one notch of a mouse wheel. */
8
+ export const WHEEL_STEP_PX = 100;
9
+ /** Wheel movement, in pixels, pushed against the top or bottom of what scrolls inside before it steps onto the next or previous page. */
10
+ export const WHEEL_EDGE_PX = 80;
11
+ /** Movement that isn't followed by more within this long starts from nothing again, so an old nudge doesn't add to a new one. */
12
+ export const WHEEL_IDLE_MS = 250;
13
+ /** After a step that starts at an edge, wheel events for this long are left to settle the new page, so one hard push isn't two steps. */
14
+ export const WHEEL_SETTLE_MS = 100;
15
+
16
+ /** Where the new page's scroll position goes: `"top"` when stepping forward past the bottom, `"bottom"` when stepping back past the top. */
17
+ export type WheelEdge = "top" | "bottom";
18
+
19
+ export interface UseWheelPagingOptions {
20
+ /** Nothing is handled while this is `false` (a phone, an event being dragged, a dialog open). */
21
+ enabled: boolean;
22
+ /** The element inside that scrolls vertically, if there is one now: the wheel scrolls it as usual and only steps past its ends.
23
+ * `null` when nothing inside scrolls (a month grid), and then every wheel movement counts towards a step. */
24
+ getScroller: () => HTMLElement | null;
25
+ /** Called for each step: `1` towards the next page (the wheel turned down), `-1` towards the previous. `edge` says where in the new
26
+ * page's scroller to start, when there is one. */
27
+ onStep: (direction: 1 | -1, edge: WheelEdge | null) => void;
28
+ }
29
+
30
+ /** A wheel event's vertical movement in pixels, whichever unit the browser reports it in. */
31
+ function pixelsOf(event: WheelEvent, page: number): number {
32
+ return event.deltaMode === 1 ? event.deltaY * 16 : event.deltaMode === 2 ? event.deltaY * page : event.deltaY;
33
+ }
34
+
35
+ /**
36
+ * Turns the mouse wheel over `ref`'s element into stepping through pages - the months, weeks or days of a calendar - without a limit:
37
+ * keep turning and it keeps stepping, and nothing waits for an animation to end.
38
+ *
39
+ * Where nothing inside scrolls, every `WHEEL_STEP_PX` of movement is a step (a bigger turn is several). Where something does, the
40
+ * wheel scrolls it as it always did and only what is pushed against its top or bottom counts (`WHEEL_EDGE_PX`), so scrolling on
41
+ * from the last hour of a day carries into the first hours of the next, and back the other way. The event is only cancelled when it
42
+ * counts towards a step, so the page and whatever scrolls inside keep their own wheel behaviour everywhere else. Zooming
43
+ * (Ctrl+wheel) and sideways movement are left alone.
44
+ */
45
+ export function useWheelPaging(ref: RefObject<HTMLElement | null>, { enabled, getScroller, onStep }: UseWheelPagingOptions) {
46
+ const latest = useRef({ getScroller, onStep });
47
+ latest.current = { getScroller, onStep };
48
+ /** Movement collected towards the next step, and when it last grew. */
49
+ const collected = useRef({ pixels: 0, at: 0 });
50
+ const settledUntil = useRef(0);
51
+
52
+ useEffect(() => {
53
+ const element = ref.current;
54
+ if (!enabled || !element) {
55
+ return;
56
+ }
57
+
58
+ function onWheel(event: WheelEvent) {
59
+ if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
60
+ return;
61
+ }
62
+ const pixels = pixelsOf(event, element!.clientHeight || 600);
63
+ if (pixels === 0) {
64
+ return;
65
+ }
66
+ const scroller = latest.current.getScroller();
67
+ if (scroller) {
68
+ const room = scroller.scrollHeight - scroller.clientHeight;
69
+ const canScroll = pixels > 0 ? scroller.scrollTop < room - 1 : scroller.scrollTop > 0;
70
+ if (room > 0 && canScroll) {
71
+ // Still something to scroll that way: the browser does it, and what was pushed against an end is forgotten.
72
+ collected.current = { pixels: 0, at: 0 };
73
+ return;
74
+ }
75
+ }
76
+
77
+ event.preventDefault();
78
+ const now = Date.now();
79
+ if (scroller && now < settledUntil.current) {
80
+ return;
81
+ }
82
+ const state = collected.current;
83
+ if (now - state.at > WHEEL_IDLE_MS || Math.sign(state.pixels) !== Math.sign(pixels)) {
84
+ state.pixels = 0;
85
+ }
86
+ state.at = now;
87
+ state.pixels += pixels;
88
+
89
+ const needed = scroller ? WHEEL_EDGE_PX : WHEEL_STEP_PX;
90
+ while (Math.abs(state.pixels) >= needed) {
91
+ const direction: 1 | -1 = state.pixels > 0 ? 1 : -1;
92
+ state.pixels -= direction * needed;
93
+ latest.current.onStep(direction, scroller ? (direction > 0 ? "top" : "bottom") : null);
94
+ if (scroller) {
95
+ // One push against an end is one step; the next turn of the wheel scrolls the page it arrived at.
96
+ state.pixels = 0;
97
+ settledUntil.current = now + WHEEL_SETTLE_MS;
98
+ break;
99
+ }
100
+ }
101
+ }
102
+
103
+ // Not passive: the wheel's default (scrolling the page) has to be cancellable for the steps that replace it.
104
+ element.addEventListener("wheel", onWheel, { passive: false });
105
+ return () => element.removeEventListener("wheel", onWheel);
106
+ }, [enabled, ref]);
107
+ }