@rapidmx/web-client 0.14.1 → 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 -866
  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 -459
  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 +2 -2
@@ -0,0 +1,64 @@
1
+ import React, { ReactNode } from "react";
2
+ /**
3
+ * Where a new event's quick-create popover hangs: the rectangle (viewport coordinates) of what was clicked - a time slot, a day cell, the
4
+ * "New event" button. `placement` says on which side it opens: beside the rectangle (`"side"`, the default: to its right, or to its left
5
+ * when there is no room) or under it (`"below"`).
6
+ */
7
+ export interface EventAnchor {
8
+ left: number;
9
+ top: number;
10
+ right: number;
11
+ bottom: number;
12
+ placement?: "side" | "below";
13
+ }
14
+ /** The anchor for a clicked element (see `EventAnchor`). */
15
+ export declare function anchorOf(element: Element, placement?: EventAnchor["placement"]): EventAnchor;
16
+ /** How the shell is drawn: a popover next to `anchor`, a bottom sheet (a phone's quick create), or a centered card with a backdrop. */
17
+ export type EventShellVariant = "popover" | "sheet" | "card";
18
+ /**
19
+ * Where a `width` x `height` popover goes for `anchor`, kept wholly inside the viewport (its margin on every side). With no anchor - the
20
+ * keyboard shortcut - it is centered, near the top.
21
+ */
22
+ export declare function placePopover(anchor: EventAnchor | undefined, size: {
23
+ width: number;
24
+ height: number;
25
+ }, viewport: {
26
+ width: number;
27
+ height: number;
28
+ }): {
29
+ left: number;
30
+ top: number;
31
+ };
32
+ /** What a form inside a popover uses to make its grip drag the popover. */
33
+ export interface EventShellContextValue {
34
+ dragHandleProps: {
35
+ onPointerDown: (event: React.PointerEvent) => void;
36
+ } | undefined;
37
+ }
38
+ export declare const EventShellContext: React.Context<EventShellContextValue>;
39
+ export interface EventShellProps {
40
+ variant: EventShellVariant;
41
+ /** The dialog's accessible name. */
42
+ label: string;
43
+ /** Escape, the backdrop (unless `onBackdropPress` says otherwise) and the close buttons' shared way out. */
44
+ onClose: () => void;
45
+ /** What a press on the backdrop does, where it must not just close: a half-typed quick event is not thrown away by a stray click. */
46
+ onBackdropPress?: () => void;
47
+ anchor?: EventAnchor;
48
+ /** Names the face being shown (details, quick create, full form): when it changes, focus moves to the new face's `data-autofocus` element,
49
+ * or to the dialog itself when it has none. */
50
+ focusKey: string;
51
+ /** The widest the popover or card gets, in px. */
52
+ width: number;
53
+ children: ReactNode;
54
+ }
55
+ /**
56
+ * The frame every face of the event dialog shares (the read-only details, the quick-create popover, the full form), so that switching from
57
+ * one to another keeps the focus handling and - the point of drawing them all through one element tree - keeps a form's state when the quick
58
+ * popover grows into the full card. The tree is deliberately the same for every variant (backdrop, dialog, children): only classes and
59
+ * styles change, so React never remounts what is inside.
60
+ *
61
+ * Escape and the Tab focus trap come from the shared overlay stack (`useOverlayDialog`), as for `Modal`. A popover is placed beside its
62
+ * anchor (`placePopover()`), re-placed when its own height changes, and can be dragged by a grip that takes `EventShellContext`'s handle.
63
+ */
64
+ export default function EventShell({ variant, label, onClose, onBackdropPress, anchor, focusKey, width, children }: EventShellProps): React.ReactPortal;
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
7
+ import { createPortal } from "react-dom";
8
+ import { OverlayDepthContext, useOverlayDialog } from "@rapidmx/react-shared/components/overlays/overlayStack.js";
9
+ /** The anchor for a clicked element (see `EventAnchor`). */
10
+ export function anchorOf(element, placement = "side") {
11
+ const { left, top, right, bottom } = element.getBoundingClientRect();
12
+ return { left, top, right, bottom, placement };
13
+ }
14
+ const VIEWPORT_MARGIN = 8;
15
+ const ANCHOR_GAP = 8;
16
+ /**
17
+ * Where a `width` x `height` popover goes for `anchor`, kept wholly inside the viewport (its margin on every side). With no anchor - the
18
+ * keyboard shortcut - it is centered, near the top.
19
+ */
20
+ export function placePopover(anchor, size, viewport) {
21
+ let left;
22
+ let top;
23
+ if (!anchor) {
24
+ left = (viewport.width - size.width) / 2;
25
+ top = viewport.height * 0.1;
26
+ }
27
+ else if (anchor.placement === "below") {
28
+ left = anchor.left;
29
+ top = anchor.bottom + ANCHOR_GAP;
30
+ }
31
+ else {
32
+ left = anchor.right + ANCHOR_GAP;
33
+ if (left + size.width > viewport.width - VIEWPORT_MARGIN) {
34
+ left = anchor.left - ANCHOR_GAP - size.width;
35
+ }
36
+ top = anchor.top;
37
+ }
38
+ return {
39
+ left: Math.max(VIEWPORT_MARGIN, Math.min(left, viewport.width - size.width - VIEWPORT_MARGIN)),
40
+ top: Math.max(VIEWPORT_MARGIN, Math.min(top, viewport.height - size.height - VIEWPORT_MARGIN)),
41
+ };
42
+ }
43
+ export const EventShellContext = createContext({ dragHandleProps: undefined });
44
+ /**
45
+ * The frame every face of the event dialog shares (the read-only details, the quick-create popover, the full form), so that switching from
46
+ * one to another keeps the focus handling and - the point of drawing them all through one element tree - keeps a form's state when the quick
47
+ * popover grows into the full card. The tree is deliberately the same for every variant (backdrop, dialog, children): only classes and
48
+ * styles change, so React never remounts what is inside.
49
+ *
50
+ * Escape and the Tab focus trap come from the shared overlay stack (`useOverlayDialog`), as for `Modal`. A popover is placed beside its
51
+ * anchor (`placePopover()`), re-placed when its own height changes, and can be dragged by a grip that takes `EventShellContext`'s handle.
52
+ */
53
+ export default function EventShell({ variant, label, onClose, onBackdropPress, anchor, focusKey, width, children }) {
54
+ const dialogRef = useRef(null);
55
+ const depth = useOverlayDialog(true, dialogRef, onClose);
56
+ // How far the grip has dragged the popover from where it was placed.
57
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
58
+ const offsetRef = useRef(offset);
59
+ offsetRef.current = offset;
60
+ const dragStart = useRef(null);
61
+ const place = useCallback(() => {
62
+ const dialog = dialogRef.current;
63
+ // A resize reported after the dialog has gone (the popover closes as soon as its event is saved) has nothing left to place.
64
+ if (!dialog) {
65
+ return;
66
+ }
67
+ if (variant !== "popover") {
68
+ dialog.style.left = "";
69
+ dialog.style.top = "";
70
+ return;
71
+ }
72
+ const size = { width: dialog.offsetWidth, height: dialog.offsetHeight };
73
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
74
+ const base = placePopover(anchor, size, viewport);
75
+ const left = Math.max(VIEWPORT_MARGIN, Math.min(base.left + offsetRef.current.x, viewport.width - size.width - VIEWPORT_MARGIN));
76
+ const top = Math.max(VIEWPORT_MARGIN, Math.min(base.top + offsetRef.current.y, viewport.height - size.height - VIEWPORT_MARGIN));
77
+ dialog.style.left = `${Math.round(left)}px`;
78
+ dialog.style.top = `${Math.round(top)}px`;
79
+ }, [anchor, variant]);
80
+ useLayoutEffect(() => {
81
+ place();
82
+ }, [place, offset]);
83
+ // Declared after `useOverlayDialog()`'s own effect, which focuses the dialog itself: the face's first field wins over that.
84
+ useEffect(() => {
85
+ (dialogRef.current.querySelector("[data-autofocus]") ?? dialogRef.current).focus();
86
+ }, [focusKey]);
87
+ // The popover grows and shrinks with its rows (the time controls expanding, a guest added): keep it inside the window.
88
+ useEffect(() => {
89
+ if (variant !== "popover" || typeof ResizeObserver === "undefined") {
90
+ return;
91
+ }
92
+ const observer = new ResizeObserver(() => place());
93
+ observer.observe(dialogRef.current);
94
+ return () => observer.disconnect();
95
+ }, [place, variant]);
96
+ useEffect(() => {
97
+ function handleMove(event) {
98
+ const start = dragStart.current;
99
+ if (start) {
100
+ setOffset({ x: start.from.x + event.clientX - start.x, y: start.from.y + event.clientY - start.y });
101
+ }
102
+ }
103
+ function handleUp() {
104
+ dragStart.current = null;
105
+ }
106
+ window.addEventListener("pointermove", handleMove);
107
+ window.addEventListener("pointerup", handleUp);
108
+ return () => {
109
+ window.removeEventListener("pointermove", handleMove);
110
+ window.removeEventListener("pointerup", handleUp);
111
+ };
112
+ }, []);
113
+ const context = {
114
+ dragHandleProps: variant === "popover"
115
+ ? {
116
+ onPointerDown: (event) => {
117
+ dragStart.current = { x: event.clientX, y: event.clientY, from: offsetRef.current };
118
+ },
119
+ }
120
+ : undefined,
121
+ };
122
+ const backdropClass = variant === "card"
123
+ ? "fixed inset-0 bg-black/50 flex items-center justify-center p-3 md:p-5 z-[1000]"
124
+ : variant === "sheet"
125
+ ? "fixed inset-0 bg-black/50 flex items-end z-[1000]"
126
+ : "fixed inset-0 z-[1000]";
127
+ const dialogClass = variant === "card"
128
+ ? "w-full max-h-[calc(100vh-1.5rem)] md:max-h-[calc(100vh-2.5rem)] overflow-y-auto bg-surface border border-border rounded-xl shadow-modal focus:outline-none"
129
+ : variant === "sheet"
130
+ ? "w-full max-h-[90vh] overflow-y-auto bg-surface border-t border-border rounded-t-2xl shadow-modal focus:outline-none pb-[env(safe-area-inset-bottom)]"
131
+ : "fixed max-h-[calc(100vh-1rem)] overflow-y-auto bg-surface border border-border rounded-xl shadow-modal focus:outline-none";
132
+ const dialogStyle = variant === "popover" ? { width, maxWidth: `calc(100vw - ${VIEWPORT_MARGIN * 2}px)` } : variant === "card" ? { maxWidth: width } : {};
133
+ return createPortal(_jsx("div", { className: backdropClass, "data-event-shell": variant, onMouseDown: (event) => event.target === event.currentTarget && (onBackdropPress ?? onClose)(), children: _jsx("div", { ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-label": label, tabIndex: -1, className: dialogClass, style: dialogStyle, children: _jsx(OverlayDepthContext.Provider, { value: depth, children: _jsx(EventShellContext.Provider, { value: context, children: children }) }) }) }), document.body);
134
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { EventFormController } from "./eventForm.js";
3
+ /** How long the grid waits after the last change of day or guests before asking the server, so typing a list of guests is one look-up. */
4
+ export declare const FIND_TIME_DEBOUNCE_MS = 300;
5
+ /**
6
+ * The event dialog's "Find a time" tab: a day of the guests' calendars side by side, as Google Calendar draws it. A row for you and one for each guest,
7
+ * the hours of the day across (working hours in view first), each person's busy time as a block (a tentative one hatched) and the event's proposed time as a
8
+ * highlight across every row; pressing an hour, or a place in a row, moves the event's start there (its length is kept). Under the grid: whether the proposed
9
+ * time suits everyone, and up to five suggested times - the next slots of the event's length, 8:00am to 6:00pm, when everyone who can be checked is free.
10
+ *
11
+ * Who can be checked: `POST /calendar-events/free-busy` tells a guest's busy time only for a mailbox on this server whose owner shares it with you. A guest it
12
+ * cannot see - an external address, or somebody who hides their calendar - is shown as such, in the grid and in the summary, and is never counted as free;
13
+ * suggestions ignore them (and say so). The look-up is made after a short pause (`FIND_TIME_DEBOUNCE_MS`) whenever the day or the guests change, one answer at
14
+ * a time (an answer for a look-up that has since been replaced is dropped), and a failure - too many look-ups, or no connection - says so with a retry.
15
+ */
16
+ export default function FindATime({ c }: {
17
+ c: EventFormController;
18
+ }): React.JSX.Element;
@@ -0,0 +1,190 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
7
+ import { format } from "date-fns";
8
+ import { FREE_BUSY_MAX_ADDRESSES, availabilityDuring, availabilityOf, getFreeBusy, suggestTimes, summarizeAvailability, withoutOwnBlock, } from "@rapidmx/react-shared/calendar/freeBusyApi.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+ import { addDaysToKey } from "./allDay.js";
11
+ import { formatFormWhen, localDateFromKey, msToWallString, wallStringToMs } from "./eventFormat.js";
12
+ import { SEARCH_DAYS, WORK_END_HOUR, WORK_START_HOUR, clockLabel, dayStartMs, freeBusyErrorMessage, isDayKey, minuteOfDay, summaryLine, workingWindows, } from "./findTimeGrid.js";
13
+ /** How long the grid waits after the last change of day or guests before asking the server, so typing a list of guests is one look-up. */
14
+ export const FIND_TIME_DEBOUNCE_MS = 300;
15
+ const HOUR_WIDTH = 52;
16
+ const NAME_WIDTH = 136;
17
+ const HEADER_HEIGHT = 28;
18
+ const ROW_HEIGHT = 44;
19
+ const TIMELINE_WIDTH = HOUR_WIDTH * 24;
20
+ const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
21
+ const GRID_LINES = `repeating-linear-gradient(to right, transparent 0, transparent ${HOUR_WIDTH - 1}px, var(--color-border) ${HOUR_WIDTH - 1}px, var(--color-border) ${HOUR_WIDTH}px)`;
22
+ const HATCH = "repeating-linear-gradient(45deg, transparent 0, transparent 4px, var(--color-text-muted) 4px, var(--color-text-muted) 5px)";
23
+ const TENTATIVE_HATCH = "repeating-linear-gradient(45deg, var(--color-danger-bg) 0, var(--color-danger-bg) 4px, var(--color-danger) 4px, var(--color-danger) 6px)";
24
+ /** "12am", "9am", "12pm" for the header cells and their labels. */
25
+ function hourLabel(hour) {
26
+ return format(new Date(2000, 0, 1, hour), "haaa");
27
+ }
28
+ /** What a row says about the proposed time, under the person's name. */
29
+ function stateText(state, status) {
30
+ if (status === "restricted") {
31
+ return "Hides their calendar";
32
+ }
33
+ if (status === "unknown") {
34
+ return "Availability unknown";
35
+ }
36
+ if (status === undefined) {
37
+ return "Checking…";
38
+ }
39
+ // Their calendar was read, but the proposed time is not on the days it covers.
40
+ if (state === undefined) {
41
+ return "";
42
+ }
43
+ return state === "free" ? "Free" : state === "busy" ? "Busy at this time" : "Maybe busy at this time";
44
+ }
45
+ /**
46
+ * The event dialog's "Find a time" tab: a day of the guests' calendars side by side, as Google Calendar draws it. A row for you and one for each guest,
47
+ * the hours of the day across (working hours in view first), each person's busy time as a block (a tentative one hatched) and the event's proposed time as a
48
+ * highlight across every row; pressing an hour, or a place in a row, moves the event's start there (its length is kept). Under the grid: whether the proposed
49
+ * time suits everyone, and up to five suggested times - the next slots of the event's length, 8:00am to 6:00pm, when everyone who can be checked is free.
50
+ *
51
+ * Who can be checked: `POST /calendar-events/free-busy` tells a guest's busy time only for a mailbox on this server whose owner shares it with you. A guest it
52
+ * cannot see - an external address, or somebody who hides their calendar - is shown as such, in the grid and in the summary, and is never counted as free;
53
+ * suggestions ignore them (and say so). The look-up is made after a short pause (`FIND_TIME_DEBOUNCE_MS`) whenever the day or the guests change, one answer at
54
+ * a time (an answer for a look-up that has since been replaced is dropped), and a failure - too many look-ups, or no connection - says so with a retry.
55
+ */
56
+ export default function FindATime({ c }) {
57
+ const { values, deviceZone, organizerAddress, occurrence } = c;
58
+ const { start, end, allDay, formZone } = values;
59
+ const startDay = start.slice(0, 10);
60
+ const [dayKey, setDayKey] = useState(() => (isDayKey(startDay) ? startDay : format(new Date(), "yyyy-MM-dd")));
61
+ const [retry, setRetry] = useState(0);
62
+ // Picking another date in the form moves the grid to it.
63
+ useEffect(() => {
64
+ if (isDayKey(startDay)) {
65
+ setDayKey(startDay);
66
+ }
67
+ }, [startDay]);
68
+ // You, then each guest once (case-insensitively), at most as many as one look-up may ask about.
69
+ const rows = useMemo(() => {
70
+ const seen = new Set([organizerAddress.toLowerCase()]);
71
+ const guests = [];
72
+ for (const attendee of values.attendees) {
73
+ const key = attendee.address.toLowerCase();
74
+ if (!seen.has(key)) {
75
+ seen.add(key);
76
+ guests.push({ address: attendee.address, name: attendee.displayName || attendee.address, you: false });
77
+ }
78
+ }
79
+ return [{ address: organizerAddress, name: "You", you: true }, ...guests].slice(0, FREE_BUSY_MAX_ADDRESSES);
80
+ }, [values.attendees, organizerAddress]);
81
+ const guestCount = rows.length - 1;
82
+ const addresses = rows.map((row) => row.address);
83
+ const addressesKey = addresses.map((address) => address.toLowerCase()).join(",");
84
+ const rangeStart = dayStartMs(dayKey, formZone, deviceZone);
85
+ const rangeEnd = dayStartMs(addDaysToKey(dayKey, SEARCH_DAYS), formZone, deviceZone);
86
+ const eventStart = allDay ? Number.NaN : wallStringToMs(start, formZone, deviceZone);
87
+ const eventEnd = allDay ? Number.NaN : wallStringToMs(end, formZone, deviceZone);
88
+ const timeIsValid = eventEnd > eventStart;
89
+ const [lookUp, setLookUp] = useState({ status: "loading", people: [] });
90
+ useEffect(() => {
91
+ if (allDay || guestCount === 0 || Number.isNaN(rangeStart) || Number.isNaN(rangeEnd)) {
92
+ return;
93
+ }
94
+ // Each run of this effect owns its answer: once the day or the guests change (or this unmounts) a slower earlier look-up is ignored.
95
+ let current = true;
96
+ setLookUp((previous) => ({ status: "loading", people: previous.people }));
97
+ const timer = setTimeout(() => {
98
+ getFreeBusy(addresses, new Date(rangeStart), new Date(rangeEnd)).then((response) => current && setLookUp({ status: "ready", people: availabilityOf(response) }), (err) => current && setLookUp({ status: "error", people: [], message: freeBusyErrorMessage(err) }));
99
+ }, FIND_TIME_DEBOUNCE_MS);
100
+ return () => {
101
+ current = false;
102
+ clearTimeout(timer);
103
+ };
104
+ // `addresses` is what `addressesKey` says.
105
+ }, [addressesKey, rangeStart, rangeEnd, allDay, retry]);
106
+ // The event's own time, when it is being rescheduled, is on the calendars of the people already invited: not their conflict with themselves.
107
+ const people = useMemo(() => {
108
+ if (!occurrence || occurrence.allDay) {
109
+ return lookUp.people;
110
+ }
111
+ const invited = new Set([occurrence.organizer.address, ...occurrence.attendees.map((attendee) => attendee.address), organizerAddress].map((a) => a.toLowerCase()));
112
+ return withoutOwnBlock(lookUp.people, invited, Date.parse(occurrence.startDate), Date.parse(occurrence.endDate));
113
+ }, [lookUp.people, occurrence, organizerAddress]);
114
+ // Working hours scrolled into view, or the event's own start when that lies outside them.
115
+ const scroller = useRef(null);
116
+ useLayoutEffect(() => {
117
+ if (!scroller.current) {
118
+ return;
119
+ }
120
+ const startHour = timeIsValid && msToWallString(eventStart, formZone, deviceZone).slice(0, 10) === dayKey ? Number(msToWallString(eventStart, formZone, deviceZone).slice(11, 13)) : undefined;
121
+ const firstHour = startHour !== undefined && (startHour < WORK_START_HOUR || startHour >= WORK_END_HOUR - 3) ? Math.max(0, startHour - 1) : WORK_START_HOUR;
122
+ scroller.current.scrollLeft = firstHour * HOUR_WIDTH;
123
+ // Only a change of day moves the view: moving the event within it must not.
124
+ }, [dayKey, guestCount > 0 && !allDay]);
125
+ function startAt(minutes) {
126
+ c.setStart(`${dayKey}T${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`);
127
+ }
128
+ function handleTimelineClick(event) {
129
+ const box = event.currentTarget.getBoundingClientRect();
130
+ if (box.width > 0) {
131
+ // Half hours: the smallest step of the form's own time inputs is a minute, but a row is only 52px an hour wide.
132
+ startAt(Math.min(47, Math.max(0, Math.floor(((event.clientX - box.left) / box.width) * 48))) * 30);
133
+ }
134
+ }
135
+ if (allDay) {
136
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Finding a time works for an event with a start and end time. Uncheck All day to see when your guests are free." });
137
+ }
138
+ if (guestCount === 0) {
139
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Add guests to see when they are free." });
140
+ }
141
+ const dayLabel = format(localDateFromKey(dayKey), "EEEE, MMMM d");
142
+ const proposedFrom = timeIsValid ? minuteOfDay(eventStart, dayKey, formZone, deviceZone) : 0;
143
+ const proposedTo = timeIsValid ? minuteOfDay(eventEnd, dayKey, formZone, deviceZone) : 0;
144
+ const showProposed = timeIsValid && proposedTo > proposedFrom;
145
+ const covered = timeIsValid && eventStart >= rangeStart && eventEnd <= rangeEnd;
146
+ const answered = lookUp.status === "ready";
147
+ const byAddress = new Map(people.map((person) => [person.address.toLowerCase(), person]));
148
+ const summary = answered && covered ? summarizeAvailability(people, eventStart, eventEnd) : undefined;
149
+ const suggestions = answered && timeIsValid
150
+ ? suggestTimes(people, {
151
+ windows: workingWindows(dayKey, formZone, deviceZone),
152
+ durationMs: eventEnd - eventStart,
153
+ notBeforeMs: Date.now(),
154
+ })
155
+ : [];
156
+ const seen = people.filter((person) => person.status === "available").length;
157
+ const unseen = people.length - seen;
158
+ /** What a screen reader hears of a person whose calendar was read: their busy times on the day shown. */
159
+ function busyDescription(person) {
160
+ const dayEnd = dayStartMs(addDaysToKey(dayKey, 1), formZone, deviceZone);
161
+ const blocks = person.busy.filter((block) => block.startMs < dayEnd && block.endMs > rangeStart);
162
+ return blocks.length === 0
163
+ ? "Free all day."
164
+ : blocks
165
+ .map((block) => `${block.tentative ? "Tentatively busy" : "Busy"} ${clockLabel(block.startMs, formZone, deviceZone)} to ${clockLabel(block.endMs, formZone, deviceZone)}`)
166
+ .join(", ");
167
+ }
168
+ return (_jsxs("div", { className: "flex flex-col gap-3 min-w-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("button", { type: "button", "aria-label": "Previous day", onClick: () => setDayKey((key) => addDaysToKey(key, -1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u2039" }), _jsx("span", { className: "text-sm font-medium min-w-[9rem] text-center", children: dayLabel }), _jsx("button", { type: "button", "aria-label": "Next day", onClick: () => setDayKey((key) => addDaysToKey(key, 1)), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: "\u203A" }), _jsx("span", { className: "text-xs text-text-muted", children: formZone.replace(/_/g, " ") })] }), lookUp.status === "error" && (_jsxs("div", { role: "alert", className: "flex flex-wrap items-center gap-3 text-sm text-danger", children: [_jsx("span", { children: lookUp.message }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setRetry((count) => count + 1), children: "Try again" })] })), _jsx("div", { ref: scroller, role: "group", "aria-label": "Guests' availability", "aria-busy": lookUp.status === "loading", className: "overflow-x-auto border border-border rounded-md", children: _jsxs("div", { className: "relative", style: { width: NAME_WIDTH + TIMELINE_WIDTH }, children: [_jsxs("div", { className: "flex bg-surface border-b border-border", style: { height: HEADER_HEIGHT }, children: [_jsx("div", { className: "sticky left-0 z-20 shrink-0 bg-surface border-r border-border", style: { width: NAME_WIDTH } }), HOURS.map((hour) => (_jsx("button", { type: "button", "aria-label": `Start at ${hourLabel(hour)}`, title: `Start at ${hourLabel(hour)}`, onClick: () => startAt(hour * 60), className: "shrink-0 text-[10px] text-text-muted hover:bg-surface-alt hover:text-text text-left pl-1", style: { width: HOUR_WIDTH }, children: hourLabel(hour) }, hour)))] }), rows.map((row) => {
169
+ const person = byAddress.get(row.address.toLowerCase());
170
+ const state = person && timeIsValid && covered ? availabilityDuring(person, eventStart, eventEnd) : undefined;
171
+ const hidden = person && person.status !== "available" ? person : undefined;
172
+ return (_jsxs("div", { className: "flex border-b border-border last:border-b-0", style: { height: ROW_HEIGHT }, children: [_jsxs("div", { className: "sticky left-0 z-10 shrink-0 flex items-center gap-2 px-2 bg-surface border-r border-border", style: { width: NAME_WIDTH }, children: [_jsx("span", { "aria-hidden": "true", className: "w-6 h-6 shrink-0 rounded-full bg-primary/15 text-primary-dark text-[11px] font-semibold flex items-center justify-center uppercase", children: row.name.charAt(0) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "text-xs font-medium truncate", children: row.name }), _jsx("div", { className: "text-[10px] text-text-muted truncate", children: stateText(state, person?.status) })] })] }), _jsxs("div", { role: "img", "aria-label": `${row.name}: ${hidden ? stateText(undefined, hidden.status) : person ? busyDescription(person) : "Checking availability"}`, onClick: handleTimelineClick, className: ["relative shrink-0 cursor-pointer", !person && lookUp.status === "loading" ? "animate-pulse bg-surface-alt" : ""].join(" "), style: { width: TIMELINE_WIDTH, backgroundImage: GRID_LINES }, children: [_jsx("div", { "aria-hidden": "true", className: "absolute inset-y-0 left-0 bg-surface-alt/70", style: { width: WORK_START_HOUR * HOUR_WIDTH } }), _jsx("div", { "aria-hidden": "true", className: "absolute inset-y-0 right-0 bg-surface-alt/70", style: { width: (24 - WORK_END_HOUR) * HOUR_WIDTH } }), hidden && (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": "true", className: "absolute inset-0 opacity-30", style: { backgroundImage: HATCH } }), _jsx("span", { "aria-hidden": "true", className: "sticky left-2 inline-block px-2 mt-3 text-[11px] text-text bg-surface rounded-sm", children: stateText(undefined, hidden.status) })] })), person?.status === "available" &&
173
+ person.busy.map((block, index) => {
174
+ const from = minuteOfDay(block.startMs, dayKey, formZone, deviceZone);
175
+ const to = minuteOfDay(block.endMs, dayKey, formZone, deviceZone);
176
+ return to > from ? (_jsx("div", { "aria-hidden": "true", "data-busy": block.tentative ? "tentative" : "busy", className: ["absolute top-2 bottom-2 rounded-sm border border-danger", block.tentative ? "" : "bg-danger/60"].join(" "), style: { left: (from / 60) * HOUR_WIDTH, width: ((to - from) / 60) * HOUR_WIDTH, backgroundImage: block.tentative ? TENTATIVE_HATCH : undefined } }, index)) : null;
177
+ })] })] }, row.address.toLowerCase()));
178
+ }), showProposed && (_jsx("div", { "aria-hidden": "true", "data-proposed-time": "", className: "absolute border-x-2 border-primary bg-primary/10 pointer-events-none z-[5]", style: {
179
+ top: HEADER_HEIGHT,
180
+ bottom: 0,
181
+ left: NAME_WIDTH + (proposedFrom / 60) * HOUR_WIDTH,
182
+ width: ((proposedTo - proposedFrom) / 60) * HOUR_WIDTH,
183
+ } }))] }) }), _jsxs("div", { role: "status", "aria-live": "polite", className: "text-sm", children: [lookUp.status === "loading" && "Checking availability…", lookUp.status === "ready" && !timeIsValid && "Set a start and an end time to see who is free.", lookUp.status === "ready" && timeIsValid && !covered && "Pick a time within this week to see who is free.", summary && _jsx("span", { className: summary.conflicts > 0 ? "font-medium text-danger" : "font-medium", children: summaryLine(summary) })] }), answered && timeIsValid && (_jsxs("section", { "aria-labelledby": "find-time-suggestions", className: "flex flex-col gap-1.5", children: [_jsx("h4", { id: "find-time-suggestions", className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Suggested times" }), suggestions.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1", children: suggestions.map((ms) => {
184
+ const startWall = msToWallString(ms, formZone, deviceZone);
185
+ const endWall = msToWallString(ms + (eventEnd - eventStart), formZone, deviceZone);
186
+ return (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => c.setStart(startWall), className: "text-sm text-primary-dark hover:underline text-left", children: formatFormWhen(startWall, endWall, false) }) }, ms));
187
+ }) })) : (_jsx("p", { className: "text-sm text-text-muted", children: seen === 0
188
+ ? "There is no calendar to check, so no time can be suggested."
189
+ : `No time this week has everyone free between ${hourLabel(WORK_START_HOUR)} and ${hourLabel(WORK_END_HOUR)}.` })), unseen > 0 && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Suggestions leave out ", unseen, " ", unseen === 1 ? "person whose calendar" : "people whose calendars", " can\u2019t be seen."] }))] }))] }));
190
+ }
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ import type { DescriptionEditorProps } from "./DescriptionEditor.js";
3
+ /**
4
+ * `DescriptionEditor`, loaded when it is first drawn. The editor is TipTap and ProseMirror - a good deal of code that the calendar, and the reading pane's
5
+ * invitation card, only need once somebody opens a description - so it is fetched then rather than with the page. Not `React.lazy()`: a chunk that fails to
6
+ * download (offline, or a deploy that replaced it) is said so here with a retry, instead of throwing through the tree above.
7
+ */
8
+ export default function LazyDescriptionEditor(props: DescriptionEditorProps): React.JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ let loading;
8
+ /** The editor once its chunk has arrived, so an editor drawn again (the quick popover growing into the card) starts as the editor, not as the placeholder. */
9
+ let loaded;
10
+ /** Fetches the editor's chunk once. A failed download is forgotten, so asking again tries again. */
11
+ function loadDescriptionEditor() {
12
+ loading ?? (loading = import("./DescriptionEditor.js").then((module) => (loaded = module.default), (err) => {
13
+ loading = undefined;
14
+ throw err;
15
+ }));
16
+ return loading;
17
+ }
18
+ /**
19
+ * `DescriptionEditor`, loaded when it is first drawn. The editor is TipTap and ProseMirror - a good deal of code that the calendar, and the reading pane's
20
+ * invitation card, only need once somebody opens a description - so it is fetched then rather than with the page. Not `React.lazy()`: a chunk that fails to
21
+ * download (offline, or a deploy that replaced it) is said so here with a retry, instead of throwing through the tree above.
22
+ */
23
+ export default function LazyDescriptionEditor(props) {
24
+ const [Editor, setEditor] = useState(() => loaded ?? null);
25
+ const [failed, setFailed] = useState(false);
26
+ const [attempt, setAttempt] = useState(0);
27
+ useEffect(() => {
28
+ let current = true;
29
+ loadDescriptionEditor().then((component) => current && setEditor(() => component), () => current && setFailed(true));
30
+ return () => {
31
+ current = false;
32
+ };
33
+ }, [attempt]);
34
+ if (Editor) {
35
+ return _jsx(Editor, { ...props });
36
+ }
37
+ if (failed) {
38
+ return (_jsxs("p", { role: "alert", className: "text-sm text-danger", children: ["The description editor couldn\u2019t be loaded.", " ", _jsx("button", { type: "button", className: "underline", onClick: () => {
39
+ setFailed(false);
40
+ setAttempt((count) => count + 1);
41
+ }, children: "Try again" })] }));
42
+ }
43
+ return (_jsx("div", { "aria-busy": "true", className: "min-h-[7rem] rounded-md border border-border bg-surface-alt text-sm text-text-muted px-3 py-2", children: "Loading the editor\u2026" }));
44
+ }
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
3
+ import { EventAnchor } from "./EventShell.js";
3
4
  export interface MonthViewProps {
4
5
  /** Any date within the month to display. */
5
6
  viewDate: Date;
@@ -9,7 +10,10 @@ export interface MonthViewProps {
9
10
  folderColors: Record<string, string>;
10
11
  onSelectDay: (date: Date) => void;
11
12
  onSelectEvent: (occurrence: CalendarOccurrence) => void;
13
+ /** Click on the empty part of a day: the day (from midnight to the next midnight) and the cell, for a new all-day event's quick-create
14
+ * popover to open beside. */
15
+ onSelectSlot?: (start: Date, end: Date, anchor: EventAnchor) => void;
12
16
  }
13
17
  /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
14
18
  * events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
15
- export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent }: MonthViewProps): React.JSX.Element;
19
+ export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: MonthViewProps): React.JSX.Element;
@@ -1,22 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useDraggable, useDroppable } from "@dnd-kit/core";
3
- import { eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
3
+ import { addDays, eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
4
4
  import { dayDropId, eventDragId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
5
5
  import { occursOnDay, startsOnDay } from "./allDay.js";
6
+ import { anchorOf } from "./EventShell.js";
6
7
  const MAX_CHIPS_PER_DAY = 3;
7
8
  /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
8
9
  * events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
9
- export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent }) {
10
+ export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }) {
10
11
  const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
11
12
  const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
12
13
  const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
13
- return (_jsx("div", { className: "flex-1 grid grid-cols-7 grid-rows-6 min-h-0", role: "grid", "aria-label": "Month", children: days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent }, day.toISOString()))) }));
14
+ return (_jsx("div", { className: "flex-1 grid grid-cols-7 grid-rows-6 min-h-0", role: "grid", "aria-label": "Month", children: days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString()))) }));
14
15
  }
15
- function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent }) {
16
+ function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }) {
16
17
  const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
17
18
  const visible = occurrences.slice(0, MAX_CHIPS_PER_DAY);
18
19
  const overflowCount = occurrences.length - visible.length;
19
- return (_jsxs("div", { ref: setNodeRef, className: [
20
+ return (_jsxs("div", { ref: setNodeRef, onClick: (e) => {
21
+ // Only the empty part of the cell: the day number, the chips and "+N more" have their own clicks.
22
+ if (onSelectSlot && !e.target.closest("button")) {
23
+ onSelectSlot(day, addDays(day, 1), anchorOf(e.currentTarget));
24
+ }
25
+ }, className: [
20
26
  "border-b border-r border-border p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden",
21
27
  isOver ? "bg-primary/5" : "",
22
28
  inCurrentMonth ? "bg-surface" : "bg-surface-alt",
@@ -0,0 +1,30 @@
1
+ import React, { MutableRefObject } from "react";
2
+ import { EventFormController } from "./eventForm.js";
3
+ import { QuickTab } from "./QuickCreateTabs.js";
4
+ /** What `EventModal` tells the editor about the tabs of a new event's popover. */
5
+ export interface QuickCreateConfig {
6
+ tab: QuickTab;
7
+ onTabChange: (tab: QuickTab) => void;
8
+ /** Where the booking plugin's Settings pages are, or `undefined` when the plugin is not running (and there is no Appointment schedule tab). */
9
+ bookingHref?: string;
10
+ }
11
+ export interface QuickCreateFacesProps {
12
+ /** The Event tab's form, which owns the title and the mailbox and calendar the other tabs share. */
13
+ c: EventFormController;
14
+ layout: "quick" | "expanded";
15
+ onExpand: () => void;
16
+ config: QuickCreateConfig;
17
+ dirtyRef: MutableRefObject<boolean>;
18
+ /** The signed-in mailbox's address: the host's name on booking pages when its display name is not known. */
19
+ organizerAddress: string;
20
+ }
21
+ /**
22
+ * The faces of a new event, one for each tab: Event (the quick popover or, after More options, the full card - unchanged), Task and
23
+ * Appointment schedule. Stays mounted while the tab changes, so it is what keeps the other tabs' fields (the Event tab's live in
24
+ * `EventEditor`) when the user looks at another one and comes back. The title, the mailbox and the calendar are the Event tab's, so they
25
+ * are shared by all three.
26
+ *
27
+ * The tab strip is redrawn by whichever face is showing, so a tab change moves keyboard focus back to the selected tab here - what an
28
+ * arrow-key walk along the strip needs.
29
+ */
30
+ export default function QuickCreateFaces({ c, layout, onExpand, config, dirtyRef, organizerAddress }: QuickCreateFacesProps): React.JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { deviceTimeZone } from "@rapidmx/react-shared/util/timeZone.js";
8
+ import AppointmentQuickForm, { initialAppointmentDraft } from "./AppointmentQuickForm.js";
9
+ import EventExpandedForm from "./EventExpandedForm.js";
10
+ import EventQuickForm from "./EventQuickForm.js";
11
+ import QuickCreateTabs, { quickPanelProps, quickTabId } from "./QuickCreateTabs.js";
12
+ import TaskQuickForm, { initialTaskDraft } from "./TaskQuickForm.js";
13
+ /**
14
+ * The faces of a new event, one for each tab: Event (the quick popover or, after More options, the full card - unchanged), Task and
15
+ * Appointment schedule. Stays mounted while the tab changes, so it is what keeps the other tabs' fields (the Event tab's live in
16
+ * `EventEditor`) when the user looks at another one and comes back. The title, the mailbox and the calendar are the Event tab's, so they
17
+ * are shared by all three.
18
+ *
19
+ * The tab strip is redrawn by whichever face is showing, so a tab change moves keyboard focus back to the selected tab here - what an
20
+ * arrow-key walk along the strip needs.
21
+ */
22
+ export default function QuickCreateFaces({ c, layout, onExpand, config, dirtyRef, organizerAddress }) {
23
+ const { tab, onTabChange, bookingHref } = config;
24
+ const [task, setTask] = useState(() => initialTaskDraft(c.values.start, c.values.allDay));
25
+ const [appointment, setAppointment] = useState(initialAppointmentDraft);
26
+ const shownTab = useRef(tab);
27
+ useEffect(() => {
28
+ if (shownTab.current !== tab) {
29
+ shownTab.current = tab;
30
+ document.getElementById(quickTabId(tab))?.focus();
31
+ }
32
+ }, [tab]);
33
+ // Notes typed on the Task tab are worth a second thought before a stray click closes the popover, like a title.
34
+ dirtyRef.current = dirtyRef.current || task.body.trim() !== "";
35
+ const tabs = _jsx(QuickCreateTabs, { tab: tab, onChange: onTabChange, showAppointment: !!bookingHref });
36
+ if (tab === "task") {
37
+ return (_jsx(TaskQuickForm, { c: c, draft: task, onDraftChange: (patch) => setTask((prev) => ({ ...prev, ...patch })), tabs: tabs, expanded: layout === "expanded", onExpand: onExpand }));
38
+ }
39
+ if (tab === "appointment") {
40
+ const hostMailbox = c.mailboxOptions?.find((option) => option.mailbox.uid === c.values.targetMailboxUid)?.mailbox;
41
+ return (_jsx(AppointmentQuickForm, { c: c, draft: appointment, onDraftChange: (patch) => setAppointment((prev) => ({ ...prev, ...patch })), tabs: tabs, settingsHref: bookingHref, hostName: hostMailbox?.displayName ?? organizerAddress, timeZone: hostMailbox?.timezone ?? deviceTimeZone() }));
42
+ }
43
+ return layout === "quick" ? _jsx(EventQuickForm, { c: c, onExpand: onExpand, tabs: tabs, tabPanelProps: quickPanelProps("event") }) : _jsx(EventExpandedForm, { c: c });
44
+ }