@remit/ui 0.0.113 → 0.0.115
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/package.json +3 -3
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.stories.tsx +5 -1
- package/src/components/intelligence-panel.tsx +4 -0
- package/src/components/isolated-email-frame.tsx +1 -18
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/popover-menu.tsx +230 -27
- package/src/components/pull-to-refresh.tsx +1 -19
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/rich-text-correction-menu.tsx +220 -0
- package/src/components/rich-text-editor.stories.tsx +507 -5
- package/src/components/rich-text-editor.tsx +482 -83
- package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
- package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
- package/src/components/rich-text-spellcheck-provider.ts +68 -3
- package/src/components/rich-text-spellcheck-words.ts +168 -4
- package/src/components/rich-text-spellcheck-worker.ts +11 -0
- package/src/components/rich-text-spellcheck.test.ts +7 -0
- package/src/components/rich-text-spellcheck.ts +28 -2
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +116 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- package/src/lib/use-match-media.ts +25 -0
- package/src/rich-text.ts +12 -0
- package/src/tokens.css +63 -0
|
@@ -93,13 +93,13 @@ describe("MessageListPane", () => {
|
|
|
93
93
|
listState: "ready",
|
|
94
94
|
listBody: createElement(
|
|
95
95
|
"a",
|
|
96
|
-
{ href: "/mail/inbox
|
|
96
|
+
{ href: "/mail/inbox/thr-t1/t1" },
|
|
97
97
|
"Q3 planning notes",
|
|
98
98
|
),
|
|
99
99
|
}),
|
|
100
100
|
);
|
|
101
101
|
// The consumer's anchor row is rendered on the mobile/touch path…
|
|
102
|
-
assert.match(html, /href="\/mail\/inbox
|
|
102
|
+
assert.match(html, /href="\/mail\/inbox\/thr-t1\/t1"/);
|
|
103
103
|
// …and the built-in TouchListBody mock fallback is NOT substituted.
|
|
104
104
|
assert.doesNotMatch(html, /Pull to refresh/);
|
|
105
105
|
});
|
|
@@ -229,7 +229,7 @@ export const CustomListBody: Story = {
|
|
|
229
229
|
s.threads.map((t) => (
|
|
230
230
|
<a
|
|
231
231
|
key={t.id}
|
|
232
|
-
href={
|
|
232
|
+
href={`/mail/mbx-1/thr-${t.id}/${t.id}`}
|
|
233
233
|
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-sunken"
|
|
234
234
|
>
|
|
235
235
|
<span className="font-medium text-sm">{t.fromName}</span>
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Archive,
|
|
5
5
|
BellOff,
|
|
6
6
|
BookmarkPlus,
|
|
7
|
+
CalendarDays,
|
|
7
8
|
ChevronDown,
|
|
8
9
|
ChevronRight,
|
|
9
10
|
FileText,
|
|
@@ -558,6 +559,12 @@ export interface NavSidebarProps
|
|
|
558
559
|
* fall back to programmatic buttons (static stories / AppShell preview).
|
|
559
560
|
*/
|
|
560
561
|
linkComponent?: NavLinkComponent;
|
|
562
|
+
/**
|
|
563
|
+
* Whether the calendar destination sits under the daily brief. It is
|
|
564
|
+
* "hidden" until the app has a route behind it — the prototype turns it on,
|
|
565
|
+
* the shipping nav does not offer an entry that leads nowhere.
|
|
566
|
+
*/
|
|
567
|
+
calendarNav?: "hidden" | "shown";
|
|
561
568
|
/** Every saved query, most recently saved first. See `SavedSearchesGroup`. */
|
|
562
569
|
savedSearches?: string[];
|
|
563
570
|
/**
|
|
@@ -577,6 +584,7 @@ export function NavSidebar({
|
|
|
577
584
|
onSelectNav,
|
|
578
585
|
variant = "desktop",
|
|
579
586
|
linkComponent,
|
|
587
|
+
calendarNav = "hidden",
|
|
580
588
|
savedSearches = [],
|
|
581
589
|
saveableQuery,
|
|
582
590
|
onSelectSavedSearch,
|
|
@@ -604,6 +612,18 @@ export function NavSidebar({
|
|
|
604
612
|
onClick={() => onSelectNav?.("brief")}
|
|
605
613
|
/>
|
|
606
614
|
|
|
615
|
+
{calendarNav === "shown" && (
|
|
616
|
+
<NavItem
|
|
617
|
+
navId="calendar"
|
|
618
|
+
linkComponent={linkComponent}
|
|
619
|
+
icon={<CalendarDays className="size-4" />}
|
|
620
|
+
label="Calendar"
|
|
621
|
+
ariaLabel="Calendar"
|
|
622
|
+
active={selectedNavId === "calendar"}
|
|
623
|
+
onClick={() => onSelectNav?.("calendar")}
|
|
624
|
+
/>
|
|
625
|
+
)}
|
|
626
|
+
|
|
607
627
|
<NavItem
|
|
608
628
|
navId="flagged"
|
|
609
629
|
linkComponent={linkComponent}
|
|
@@ -1,8 +1,139 @@
|
|
|
1
1
|
import { EllipsisVertical } from "lucide-react";
|
|
2
|
-
import
|
|
2
|
+
import type { CSSProperties, Ref, RefObject } from "react";
|
|
3
|
+
import {
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
3
11
|
import { cn } from "../lib/cn.js";
|
|
4
12
|
import { Button } from "./button.js";
|
|
5
13
|
|
|
14
|
+
/** Viewport-relative bounding box of whatever a menu opens against. */
|
|
15
|
+
export interface PopoverMenuAnchor {
|
|
16
|
+
readonly left: number;
|
|
17
|
+
readonly right: number;
|
|
18
|
+
readonly top: number;
|
|
19
|
+
readonly bottom: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ANCHOR_GAP_PX = 4;
|
|
23
|
+
const VIEWPORT_MARGIN_PX = 8;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the panel lands: below the anchor and aligned to whichever edge
|
|
27
|
+
* `align` names, pulled back onto the screen on every side it would
|
|
28
|
+
* otherwise cross. A panel too tall for the space below flips above the
|
|
29
|
+
* anchor instead of running past the bottom edge.
|
|
30
|
+
*/
|
|
31
|
+
function clampToViewport(
|
|
32
|
+
anchor: PopoverMenuAnchor,
|
|
33
|
+
panel: { width: number; height: number },
|
|
34
|
+
align: "start" | "end",
|
|
35
|
+
): { left: number; top: number } {
|
|
36
|
+
const maxLeft = Math.max(
|
|
37
|
+
VIEWPORT_MARGIN_PX,
|
|
38
|
+
window.innerWidth - panel.width - VIEWPORT_MARGIN_PX,
|
|
39
|
+
);
|
|
40
|
+
const preferredLeft =
|
|
41
|
+
align === "end" ? anchor.right - panel.width : anchor.left;
|
|
42
|
+
const left = Math.min(Math.max(preferredLeft, VIEWPORT_MARGIN_PX), maxLeft);
|
|
43
|
+
|
|
44
|
+
const below = anchor.bottom + ANCHOR_GAP_PX;
|
|
45
|
+
const above = anchor.top - ANCHOR_GAP_PX - panel.height;
|
|
46
|
+
const fitsBelow =
|
|
47
|
+
below + panel.height <= window.innerHeight - VIEWPORT_MARGIN_PX;
|
|
48
|
+
const top = fitsBelow || above < VIEWPORT_MARGIN_PX ? below : above;
|
|
49
|
+
|
|
50
|
+
return { left, top };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Measures the anchor and the panel's own size, then keeps the panel's fixed
|
|
55
|
+
* position clamped to the viewport for as long as it is open — reset on every
|
|
56
|
+
* resize and on scroll anywhere in the ancestor chain, since a fixed position
|
|
57
|
+
* does not follow a scrolled anchor on its own.
|
|
58
|
+
*/
|
|
59
|
+
function useAnchoredPlacement(
|
|
60
|
+
panelRef: RefObject<HTMLElement | null>,
|
|
61
|
+
open: boolean,
|
|
62
|
+
align: "start" | "end",
|
|
63
|
+
getAnchor: () => PopoverMenuAnchor | null,
|
|
64
|
+
): CSSProperties | null {
|
|
65
|
+
const [style, setStyle] = useState<CSSProperties | null>(null);
|
|
66
|
+
const getAnchorRef = useRef(getAnchor);
|
|
67
|
+
getAnchorRef.current = getAnchor;
|
|
68
|
+
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
if (!open) {
|
|
71
|
+
setStyle(null);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const place = () => {
|
|
75
|
+
const panel = panelRef.current;
|
|
76
|
+
const anchor = getAnchorRef.current();
|
|
77
|
+
if (!panel || !anchor) return;
|
|
78
|
+
// `.width`/`.height` over the rect's own edges: a `getBoundingClientRect`
|
|
79
|
+
// stand-in in a test carries the edges without the derived pair.
|
|
80
|
+
const rect = panel.getBoundingClientRect();
|
|
81
|
+
const size = {
|
|
82
|
+
width: rect.right - rect.left,
|
|
83
|
+
height: rect.bottom - rect.top,
|
|
84
|
+
};
|
|
85
|
+
const placement = clampToViewport(anchor, size, align);
|
|
86
|
+
setStyle((previous) =>
|
|
87
|
+
previous?.left === placement.left && previous.top === placement.top
|
|
88
|
+
? previous
|
|
89
|
+
: { position: "fixed", left: placement.left, top: placement.top },
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
place();
|
|
93
|
+
window.addEventListener("resize", place);
|
|
94
|
+
window.addEventListener("scroll", place, true);
|
|
95
|
+
return () => {
|
|
96
|
+
window.removeEventListener("resize", place);
|
|
97
|
+
window.removeEventListener("scroll", place, true);
|
|
98
|
+
};
|
|
99
|
+
}, [open, align, panelRef]);
|
|
100
|
+
|
|
101
|
+
return style;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PopoverMenuPortalProps {
|
|
105
|
+
open: boolean;
|
|
106
|
+
align?: "start" | "end";
|
|
107
|
+
getAnchor: () => PopoverMenuAnchor | null;
|
|
108
|
+
panelRef: RefObject<HTMLElement | null>;
|
|
109
|
+
children: ReactNode;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Carries a menu panel out of the DOM subtree it opened from and into the
|
|
114
|
+
* document body, positioned at a fixed, viewport-clamped point. A panel left
|
|
115
|
+
* in place is clipped by the first scrolling ancestor between it and the
|
|
116
|
+
* page — the compose body, a card, anything with its own `overflow` — no
|
|
117
|
+
* matter how high its `z-index` climbs; escaping that ancestor takes leaving
|
|
118
|
+
* its DOM subtree, which only a portal does.
|
|
119
|
+
*/
|
|
120
|
+
export function PopoverMenuPortal({
|
|
121
|
+
open,
|
|
122
|
+
align = "start",
|
|
123
|
+
getAnchor,
|
|
124
|
+
panelRef,
|
|
125
|
+
children,
|
|
126
|
+
}: PopoverMenuPortalProps) {
|
|
127
|
+
const style = useAnchoredPlacement(panelRef, open, align, getAnchor);
|
|
128
|
+
if (!open) return null;
|
|
129
|
+
return createPortal(
|
|
130
|
+
<div style={style ?? { position: "fixed", visibility: "hidden" }}>
|
|
131
|
+
{children}
|
|
132
|
+
</div>,
|
|
133
|
+
document.body,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
6
137
|
export interface PopoverMenuItem {
|
|
7
138
|
/** Stable key, also the accessible text of the row. */
|
|
8
139
|
key: string;
|
|
@@ -11,6 +142,83 @@ export interface PopoverMenuItem {
|
|
|
11
142
|
onSelect: () => void;
|
|
12
143
|
}
|
|
13
144
|
|
|
145
|
+
export interface PopoverMenuPanelProps {
|
|
146
|
+
label?: string;
|
|
147
|
+
children: ReactNode;
|
|
148
|
+
className?: string;
|
|
149
|
+
ref?: Ref<HTMLDivElement>;
|
|
150
|
+
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
|
151
|
+
"data-testid"?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The menu itself, without a trigger: the surface, the scrolling and the
|
|
156
|
+
* rounding every dropdown in the kit shares. A menu that hangs off a button
|
|
157
|
+
* gets it through {@link PopoverMenu}; one anchored to something else — the
|
|
158
|
+
* misspelt word under the pointer — positions this and keeps the same chrome.
|
|
159
|
+
*/
|
|
160
|
+
export function PopoverMenuPanel({
|
|
161
|
+
label,
|
|
162
|
+
children,
|
|
163
|
+
className,
|
|
164
|
+
ref,
|
|
165
|
+
onKeyDown,
|
|
166
|
+
"data-testid": testId,
|
|
167
|
+
}: PopoverMenuPanelProps) {
|
|
168
|
+
return (
|
|
169
|
+
<div
|
|
170
|
+
ref={ref}
|
|
171
|
+
role="menu"
|
|
172
|
+
aria-label={label}
|
|
173
|
+
tabIndex={-1}
|
|
174
|
+
onKeyDown={onKeyDown}
|
|
175
|
+
data-testid={testId}
|
|
176
|
+
className={cn(
|
|
177
|
+
"z-50 flex max-h-[60dvh] min-w-44 flex-col overflow-y-auto overscroll-contain rounded-md border border-line bg-surface py-1 shadow-lg outline-none",
|
|
178
|
+
className,
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
{children}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PopoverMenuRowProps {
|
|
187
|
+
label: string;
|
|
188
|
+
icon?: ReactNode;
|
|
189
|
+
onSelect: () => void;
|
|
190
|
+
className?: string;
|
|
191
|
+
lang?: string;
|
|
192
|
+
"data-testid"?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One selectable row of a menu panel, at the kit's touch height. */
|
|
196
|
+
export function PopoverMenuRow({
|
|
197
|
+
label,
|
|
198
|
+
icon,
|
|
199
|
+
onSelect,
|
|
200
|
+
className,
|
|
201
|
+
lang,
|
|
202
|
+
"data-testid": testId,
|
|
203
|
+
}: PopoverMenuRowProps) {
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
role="menuitem"
|
|
208
|
+
lang={lang}
|
|
209
|
+
onClick={onSelect}
|
|
210
|
+
data-testid={testId}
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex min-h-11 items-center gap-3 px-4 py-2.5 text-left text-sm text-fg transition-colors hover:bg-surface-sunken",
|
|
213
|
+
className,
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{icon && <span className="shrink-0 text-fg-subtle">{icon}</span>}
|
|
217
|
+
{label}
|
|
218
|
+
</button>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
14
222
|
export interface PopoverMenuProps {
|
|
15
223
|
/** Accessible label for the trigger button. */
|
|
16
224
|
triggerLabel: string;
|
|
@@ -65,16 +273,18 @@ export function PopoverMenu({
|
|
|
65
273
|
}: PopoverMenuProps) {
|
|
66
274
|
const [open, setOpen] = useState(false);
|
|
67
275
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
276
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
68
277
|
|
|
69
278
|
useEffect(() => {
|
|
70
279
|
if (!open) return;
|
|
71
280
|
const onPointer = (event: MouseEvent) => {
|
|
281
|
+
const target = event.target as Node;
|
|
72
282
|
if (
|
|
73
|
-
containerRef.current
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
283
|
+
containerRef.current?.contains(target) ||
|
|
284
|
+
panelRef.current?.contains(target)
|
|
285
|
+
)
|
|
286
|
+
return;
|
|
287
|
+
setOpen(false);
|
|
78
288
|
};
|
|
79
289
|
const onKey = (event: KeyboardEvent) => {
|
|
80
290
|
if (event.key === "Escape") setOpen(false);
|
|
@@ -108,34 +318,27 @@ export function PopoverMenu({
|
|
|
108
318
|
>
|
|
109
319
|
{triggerText}
|
|
110
320
|
</Button>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
>
|
|
321
|
+
<PopoverMenuPortal
|
|
322
|
+
open={open}
|
|
323
|
+
align={align}
|
|
324
|
+
panelRef={panelRef}
|
|
325
|
+
getAnchor={() => containerRef.current?.getBoundingClientRect() ?? null}
|
|
326
|
+
>
|
|
327
|
+
<PopoverMenuPanel ref={panelRef}>
|
|
119
328
|
{items.map((item) => (
|
|
120
|
-
<
|
|
329
|
+
<PopoverMenuRow
|
|
121
330
|
key={item.key}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
331
|
+
label={item.label}
|
|
332
|
+
icon={item.icon}
|
|
333
|
+
onSelect={() => {
|
|
125
334
|
setOpen(false);
|
|
126
335
|
item.onSelect();
|
|
127
336
|
}}
|
|
128
|
-
|
|
129
|
-
>
|
|
130
|
-
{item.icon && (
|
|
131
|
-
<span className="shrink-0 text-fg-subtle">{item.icon}</span>
|
|
132
|
-
)}
|
|
133
|
-
{item.label}
|
|
134
|
-
</button>
|
|
337
|
+
/>
|
|
135
338
|
))}
|
|
136
339
|
{children}
|
|
137
|
-
</
|
|
138
|
-
|
|
340
|
+
</PopoverMenuPanel>
|
|
341
|
+
</PopoverMenuPortal>
|
|
139
342
|
</div>
|
|
140
343
|
);
|
|
141
344
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
3
2
|
import ReactPullToRefresh from "react-simple-pull-to-refresh";
|
|
4
3
|
import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
|
|
4
|
+
import { useMatchMedia } from "../lib/use-match-media.js";
|
|
5
5
|
|
|
6
6
|
export interface PullToRefreshProps {
|
|
7
7
|
children: ReactElement;
|
|
@@ -9,24 +9,6 @@ export interface PullToRefreshProps {
|
|
|
9
9
|
isRefreshing?: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
const useMatchMedia = (query: string): boolean => {
|
|
13
|
-
const [matches, setMatches] = useState(() => {
|
|
14
|
-
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
15
|
-
return window.matchMedia(query).matches;
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
useEffect(() => {
|
|
19
|
-
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
20
|
-
const mql = window.matchMedia(query);
|
|
21
|
-
setMatches(mql.matches);
|
|
22
|
-
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
|
23
|
-
mql.addEventListener("change", handler);
|
|
24
|
-
return () => mql.removeEventListener("change", handler);
|
|
25
|
-
}, [query]);
|
|
26
|
-
|
|
27
|
-
return matches;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
12
|
/**
|
|
31
13
|
* Wraps a scrollable list with a pull-to-refresh gesture on mobile. Below the
|
|
32
14
|
* desktop breakpoint (Tailwind `lg`) a downward pull at the top of the list
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
|
|
6
|
+
|
|
7
|
+
const render = (touch = false) =>
|
|
8
|
+
renderToString(
|
|
9
|
+
createElement(RecurrenceScopePrompt, {
|
|
10
|
+
title: "Standup",
|
|
11
|
+
ruleText: "Every weekday, 09:15",
|
|
12
|
+
instanceText: "Wednesday 10 June",
|
|
13
|
+
onChoose: () => undefined,
|
|
14
|
+
onCancel: () => undefined,
|
|
15
|
+
touch,
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("RecurrenceScopePrompt", () => {
|
|
20
|
+
it("offers all three scopes", () => {
|
|
21
|
+
const html = render();
|
|
22
|
+
assert.match(html, /This event/);
|
|
23
|
+
assert.match(html, /This and everything after/);
|
|
24
|
+
assert.match(html, /The whole series/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("states the rule and the instance that was clicked", () => {
|
|
28
|
+
const html = render();
|
|
29
|
+
assert.match(html, /Every weekday, 09:15/);
|
|
30
|
+
assert.match(html, /Wednesday 10 June/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("grows the choices for a thumb", () => {
|
|
34
|
+
assert.match(render(true), /min-h-11/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The scope question, asked before the form opens. Editing one instance and
|
|
6
|
+
* editing the rule are different acts, so the choice is made while the change
|
|
7
|
+
* is still an intention.
|
|
8
|
+
*/
|
|
9
|
+
const meta: Meta<typeof RecurrenceScopePrompt> = {
|
|
10
|
+
title: "Calendar/Recurrence scope",
|
|
11
|
+
component: RecurrenceScopePrompt,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
};
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof RecurrenceScopePrompt>;
|
|
17
|
+
|
|
18
|
+
export const Desktop: Story = {
|
|
19
|
+
render: () => (
|
|
20
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
|
|
21
|
+
<RecurrenceScopePrompt
|
|
22
|
+
title="Standup"
|
|
23
|
+
ruleText="Every weekday, 09:15"
|
|
24
|
+
instanceText="Wednesday 10 June"
|
|
25
|
+
onChoose={() => {}}
|
|
26
|
+
onCancel={() => {}}
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The same question with thumb-sized targets. */
|
|
33
|
+
export const Touch: Story = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
|
|
36
|
+
<RecurrenceScopePrompt
|
|
37
|
+
title="Standup"
|
|
38
|
+
ruleText="Every weekday, 09:15"
|
|
39
|
+
instanceText="Wednesday 10 June"
|
|
40
|
+
onChoose={() => {}}
|
|
41
|
+
onCancel={() => {}}
|
|
42
|
+
touch
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
),
|
|
46
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
import type { RecurrenceScope } from "./calendar-types.js";
|
|
4
|
+
|
|
5
|
+
export interface RecurrenceScopePromptProps {
|
|
6
|
+
/** The series being edited, e.g. "Standup". */
|
|
7
|
+
title: string;
|
|
8
|
+
/** The rule in words, e.g. "Every weekday, 09:15". */
|
|
9
|
+
ruleText: string;
|
|
10
|
+
/** The instance that was clicked, already formatted. */
|
|
11
|
+
instanceText: string;
|
|
12
|
+
onChoose: (scope: RecurrenceScope) => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
touch?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const choices: { scope: RecurrenceScope; label: string; note: string }[] = [
|
|
19
|
+
{
|
|
20
|
+
scope: "this",
|
|
21
|
+
label: "This event",
|
|
22
|
+
note: "Leaves the rest of the series alone",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
scope: "following",
|
|
26
|
+
label: "This and everything after",
|
|
27
|
+
note: "Splits the series here",
|
|
28
|
+
},
|
|
29
|
+
{ scope: "all", label: "The whole series", note: "Rewrites the rule itself" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The scope question, asked first. A series is one object with one rule, and
|
|
34
|
+
* which instances an edit reaches changes what the edit means — so it is
|
|
35
|
+
* settled before the form opens, not confirmed on the way out when the change
|
|
36
|
+
* has already been typed.
|
|
37
|
+
*/
|
|
38
|
+
export function RecurrenceScopePrompt({
|
|
39
|
+
title,
|
|
40
|
+
ruleText,
|
|
41
|
+
instanceText,
|
|
42
|
+
onChoose,
|
|
43
|
+
onCancel,
|
|
44
|
+
touch,
|
|
45
|
+
className,
|
|
46
|
+
}: RecurrenceScopePromptProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div className={cn("flex flex-col gap-3", className)}>
|
|
49
|
+
<div>
|
|
50
|
+
<h2 className="text-md font-semibold text-fg">{title} repeats</h2>
|
|
51
|
+
<p className="text-xs text-fg-muted">{ruleText}</p>
|
|
52
|
+
<p className="text-xs text-fg-subtle">You clicked {instanceText}.</p>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<p className="text-sm text-fg">What should the change apply to?</p>
|
|
56
|
+
|
|
57
|
+
<div className="flex flex-col gap-1.5">
|
|
58
|
+
{choices.map((choice) => (
|
|
59
|
+
<button
|
|
60
|
+
key={choice.scope}
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => onChoose(choice.scope)}
|
|
63
|
+
className={cn(
|
|
64
|
+
"flex flex-col rounded-md border border-line bg-surface px-3 py-2 text-left outline-none transition-colors hover:border-line-strong hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring",
|
|
65
|
+
touch && "min-h-11 justify-center",
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<span className="text-sm font-medium text-fg">{choice.label}</span>
|
|
69
|
+
<span className="text-2xs text-fg-subtle">{choice.note}</span>
|
|
70
|
+
</button>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<Button
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size={touch ? "md" : "sm"}
|
|
77
|
+
onClick={onCancel}
|
|
78
|
+
className={cn("self-start", touch && "min-h-11")}
|
|
79
|
+
>
|
|
80
|
+
Cancel
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|