@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.
- package/apps/admin/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -862
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -455
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- 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
|
+
}
|