@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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule the offered choices do not cover.
|
|
3
|
+
*
|
|
4
|
+
* Every second Tuesday, Mondays and Thursdays until October, thirteen times and
|
|
5
|
+
* then stop — a picker of derived choices cannot express any of those, and the
|
|
6
|
+
* alternative is asking someone to type an RRULE. So the parts of a rule are
|
|
7
|
+
* controls: how often, which days, and when it stops. What comes out is still a
|
|
8
|
+
* sentence.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
12
|
+
import { useId } from "react";
|
|
13
|
+
import { cn } from "../lib/cn.js";
|
|
14
|
+
import {
|
|
15
|
+
type CustomRecurrence,
|
|
16
|
+
dayOfMonthLabel,
|
|
17
|
+
ordinalWeekdayLabel,
|
|
18
|
+
type RecurrenceEnd,
|
|
19
|
+
type RecurrenceUnit,
|
|
20
|
+
WEEKDAY_INITIALS,
|
|
21
|
+
weekdayName,
|
|
22
|
+
} from "../lib/recurrence.js";
|
|
23
|
+
import { Button } from "./button.js";
|
|
24
|
+
import { Dialog } from "./dialog.js";
|
|
25
|
+
import { Input } from "./input.js";
|
|
26
|
+
import { Select } from "./select.js";
|
|
27
|
+
|
|
28
|
+
const UNITS: RecurrenceUnit[] = ["day", "week", "month", "year"];
|
|
29
|
+
const MAX_INTERVAL = 99;
|
|
30
|
+
const MAX_COUNT = 999;
|
|
31
|
+
|
|
32
|
+
/** What the counted ending shows before anyone counts: a field, not a gap. */
|
|
33
|
+
const SUGGESTED_COUNT = 13;
|
|
34
|
+
|
|
35
|
+
export interface CustomRecurrenceEditorProps {
|
|
36
|
+
value: CustomRecurrence;
|
|
37
|
+
onChange: (next: CustomRecurrence) => void;
|
|
38
|
+
/** The event's own day — what the monthly readings are derived from. */
|
|
39
|
+
date: string;
|
|
40
|
+
/** The date the "on" ending offers before anyone picks one. */
|
|
41
|
+
suggestedEndDate: string;
|
|
42
|
+
touch?: boolean;
|
|
43
|
+
className?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The heading names the group it sits over, so the group points back at it. */
|
|
47
|
+
function Section({
|
|
48
|
+
label,
|
|
49
|
+
children,
|
|
50
|
+
}: {
|
|
51
|
+
label: string;
|
|
52
|
+
children: (labelId: string) => React.ReactNode;
|
|
53
|
+
}) {
|
|
54
|
+
const labelId = useId();
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex flex-col gap-2">
|
|
57
|
+
<span
|
|
58
|
+
id={labelId}
|
|
59
|
+
className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle"
|
|
60
|
+
>
|
|
61
|
+
{label}
|
|
62
|
+
</span>
|
|
63
|
+
{children(labelId)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Stepper({
|
|
69
|
+
value,
|
|
70
|
+
min,
|
|
71
|
+
max,
|
|
72
|
+
onChange,
|
|
73
|
+
label,
|
|
74
|
+
touch,
|
|
75
|
+
}: {
|
|
76
|
+
value: number;
|
|
77
|
+
min: number;
|
|
78
|
+
max: number;
|
|
79
|
+
onChange: (next: number) => void;
|
|
80
|
+
label: string;
|
|
81
|
+
touch?: boolean;
|
|
82
|
+
}) {
|
|
83
|
+
const clamp = (next: number) => Math.min(Math.max(next, min), max);
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex items-stretch gap-1">
|
|
86
|
+
<Input
|
|
87
|
+
type="number"
|
|
88
|
+
inputMode="numeric"
|
|
89
|
+
min={min}
|
|
90
|
+
max={max}
|
|
91
|
+
value={String(value)}
|
|
92
|
+
aria-label={label}
|
|
93
|
+
onChange={(e) => onChange(clamp(Number(e.target.value) || min))}
|
|
94
|
+
className={cn("own-steppers w-20 tabular-nums", touch && "min-h-11")}
|
|
95
|
+
/>
|
|
96
|
+
<div className="flex flex-col justify-between">
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
aria-label={`${label} up`}
|
|
100
|
+
onClick={() => onChange(clamp(value + 1))}
|
|
101
|
+
className="flex h-4 w-6 items-center justify-center rounded-xs text-fg-subtle outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
+
>
|
|
103
|
+
<ChevronUp className="size-3.5" />
|
|
104
|
+
</button>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
aria-label={`${label} down`}
|
|
108
|
+
onClick={() => onChange(clamp(value - 1))}
|
|
109
|
+
className="flex h-4 w-6 items-center justify-center rounded-xs text-fg-subtle outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
|
|
110
|
+
>
|
|
111
|
+
<ChevronDown className="size-3.5" />
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function CustomRecurrenceEditor({
|
|
119
|
+
value,
|
|
120
|
+
onChange,
|
|
121
|
+
date,
|
|
122
|
+
suggestedEndDate,
|
|
123
|
+
touch,
|
|
124
|
+
className,
|
|
125
|
+
}: CustomRecurrenceEditorProps) {
|
|
126
|
+
const endsName = useId();
|
|
127
|
+
const monthlyName = useId();
|
|
128
|
+
const set = (patch: Partial<CustomRecurrence>) =>
|
|
129
|
+
onChange({ ...value, ...patch });
|
|
130
|
+
const setEnds = (ends: RecurrenceEnd) => set({ ends });
|
|
131
|
+
|
|
132
|
+
const endDate =
|
|
133
|
+
value.ends.kind === "onDate" ? value.ends.date : suggestedEndDate;
|
|
134
|
+
const endCount =
|
|
135
|
+
value.ends.kind === "afterCount" ? value.ends.count : SUGGESTED_COUNT;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className={cn("flex flex-col gap-5", className)}>
|
|
139
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
140
|
+
<span className="text-sm text-fg">Repeat every</span>
|
|
141
|
+
<Stepper
|
|
142
|
+
value={value.interval}
|
|
143
|
+
min={1}
|
|
144
|
+
max={MAX_INTERVAL}
|
|
145
|
+
onChange={(interval) => set({ interval })}
|
|
146
|
+
label="Repeat every"
|
|
147
|
+
touch={touch}
|
|
148
|
+
/>
|
|
149
|
+
<Select
|
|
150
|
+
aria-label="Unit"
|
|
151
|
+
value={value.unit}
|
|
152
|
+
onChange={(e) => set({ unit: e.target.value as RecurrenceUnit })}
|
|
153
|
+
className={cn("w-32", touch && "min-h-11")}
|
|
154
|
+
>
|
|
155
|
+
{UNITS.map((unit) => (
|
|
156
|
+
<option key={unit} value={unit}>
|
|
157
|
+
{value.interval > 1 ? `${unit}s` : unit}
|
|
158
|
+
</option>
|
|
159
|
+
))}
|
|
160
|
+
</Select>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{value.unit === "week" && (
|
|
164
|
+
<Section label="Repeat on">
|
|
165
|
+
{(labelId) => (
|
|
166
|
+
<fieldset
|
|
167
|
+
aria-labelledby={labelId}
|
|
168
|
+
className="flex min-w-0 gap-1.5"
|
|
169
|
+
>
|
|
170
|
+
{WEEKDAY_INITIALS.map((initial, index) => {
|
|
171
|
+
const on = value.weekdays.includes(index);
|
|
172
|
+
return (
|
|
173
|
+
<label
|
|
174
|
+
key={weekdayName(index)}
|
|
175
|
+
className={cn(
|
|
176
|
+
"inline-flex flex-1 cursor-pointer items-center justify-center rounded-full text-xs font-medium transition-colors",
|
|
177
|
+
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
|
178
|
+
touch ? "h-11 max-w-11" : "h-9 max-w-9",
|
|
179
|
+
on
|
|
180
|
+
? "bg-accent text-accent-fg"
|
|
181
|
+
: "bg-surface-sunken text-fg-muted hover:bg-surface-raised hover:text-fg",
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
<input
|
|
185
|
+
type="checkbox"
|
|
186
|
+
checked={on}
|
|
187
|
+
aria-label={weekdayName(index)}
|
|
188
|
+
onChange={() =>
|
|
189
|
+
set({
|
|
190
|
+
weekdays: on
|
|
191
|
+
? value.weekdays.filter((day) => day !== index)
|
|
192
|
+
: [...value.weekdays, index],
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
className="sr-only"
|
|
196
|
+
/>
|
|
197
|
+
{initial}
|
|
198
|
+
</label>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
</fieldset>
|
|
202
|
+
)}
|
|
203
|
+
</Section>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{value.unit === "month" && (
|
|
207
|
+
<Section label="Repeat on">
|
|
208
|
+
{(labelId) => (
|
|
209
|
+
<div
|
|
210
|
+
role="radiogroup"
|
|
211
|
+
aria-labelledby={labelId}
|
|
212
|
+
className="flex flex-col gap-1.5"
|
|
213
|
+
>
|
|
214
|
+
{(
|
|
215
|
+
[
|
|
216
|
+
["dayOfMonth", dayOfMonthLabel(date)],
|
|
217
|
+
["weekdayOfMonth", ordinalWeekdayLabel(date)],
|
|
218
|
+
] as const
|
|
219
|
+
).map(([mode, label]) => (
|
|
220
|
+
<label
|
|
221
|
+
key={mode}
|
|
222
|
+
htmlFor={`${monthlyName}-${mode}`}
|
|
223
|
+
className={cn(
|
|
224
|
+
"flex cursor-pointer items-center gap-2.5 rounded-md px-1 text-sm text-fg",
|
|
225
|
+
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
|
226
|
+
touch ? "min-h-11" : "min-h-9",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<Radio
|
|
230
|
+
id={`${monthlyName}-${mode}`}
|
|
231
|
+
name={monthlyName}
|
|
232
|
+
checked={value.monthlyMode === mode}
|
|
233
|
+
onChange={() => set({ monthlyMode: mode })}
|
|
234
|
+
/>
|
|
235
|
+
on {label}
|
|
236
|
+
</label>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</Section>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
<Section label="Ends">
|
|
244
|
+
{(labelId) => (
|
|
245
|
+
<div
|
|
246
|
+
role="radiogroup"
|
|
247
|
+
aria-labelledby={labelId}
|
|
248
|
+
className="flex flex-col gap-1.5"
|
|
249
|
+
>
|
|
250
|
+
<label
|
|
251
|
+
htmlFor={`${endsName}-never`}
|
|
252
|
+
className={cn(
|
|
253
|
+
"flex cursor-pointer items-center gap-2.5 rounded-md px-1 text-sm text-fg",
|
|
254
|
+
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
|
|
255
|
+
touch ? "min-h-11" : "min-h-9",
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
<Radio
|
|
259
|
+
id={`${endsName}-never`}
|
|
260
|
+
name={endsName}
|
|
261
|
+
checked={value.ends.kind === "never"}
|
|
262
|
+
onChange={() => setEnds({ kind: "never" })}
|
|
263
|
+
/>
|
|
264
|
+
Never
|
|
265
|
+
</label>
|
|
266
|
+
|
|
267
|
+
<div
|
|
268
|
+
className={cn(
|
|
269
|
+
"flex items-center gap-2.5 px-1",
|
|
270
|
+
touch ? "min-h-11" : "min-h-9",
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<label
|
|
274
|
+
htmlFor={`${endsName}-on`}
|
|
275
|
+
className="flex cursor-pointer items-center gap-2.5 text-sm text-fg"
|
|
276
|
+
>
|
|
277
|
+
<Radio
|
|
278
|
+
id={`${endsName}-on`}
|
|
279
|
+
name={endsName}
|
|
280
|
+
checked={value.ends.kind === "onDate"}
|
|
281
|
+
onChange={() => setEnds({ kind: "onDate", date: endDate })}
|
|
282
|
+
/>
|
|
283
|
+
On
|
|
284
|
+
</label>
|
|
285
|
+
<Input
|
|
286
|
+
type="date"
|
|
287
|
+
value={endDate}
|
|
288
|
+
aria-label="Ends on"
|
|
289
|
+
onChange={(e) =>
|
|
290
|
+
setEnds({ kind: "onDate", date: e.target.value })
|
|
291
|
+
}
|
|
292
|
+
onFocus={() => setEnds({ kind: "onDate", date: endDate })}
|
|
293
|
+
className={cn(
|
|
294
|
+
"w-40",
|
|
295
|
+
touch && "min-h-11",
|
|
296
|
+
value.ends.kind !== "onDate" && "opacity-55",
|
|
297
|
+
)}
|
|
298
|
+
/>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div
|
|
302
|
+
className={cn(
|
|
303
|
+
"flex flex-wrap items-center gap-2.5 px-1",
|
|
304
|
+
touch ? "min-h-11" : "min-h-9",
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
<label
|
|
308
|
+
htmlFor={`${endsName}-after`}
|
|
309
|
+
className="flex cursor-pointer items-center gap-2.5 text-sm text-fg"
|
|
310
|
+
>
|
|
311
|
+
<Radio
|
|
312
|
+
id={`${endsName}-after`}
|
|
313
|
+
name={endsName}
|
|
314
|
+
checked={value.ends.kind === "afterCount"}
|
|
315
|
+
onChange={() =>
|
|
316
|
+
setEnds({ kind: "afterCount", count: endCount })
|
|
317
|
+
}
|
|
318
|
+
/>
|
|
319
|
+
After
|
|
320
|
+
</label>
|
|
321
|
+
<div
|
|
322
|
+
className={cn(value.ends.kind !== "afterCount" && "opacity-55")}
|
|
323
|
+
>
|
|
324
|
+
<Stepper
|
|
325
|
+
value={endCount}
|
|
326
|
+
min={1}
|
|
327
|
+
max={MAX_COUNT}
|
|
328
|
+
onChange={(count) => setEnds({ kind: "afterCount", count })}
|
|
329
|
+
label="Occurrences"
|
|
330
|
+
touch={touch}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
<span className="text-sm text-fg-muted">occurrences</span>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
</Section>
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface CustomRecurrenceDialogProps
|
|
343
|
+
extends CustomRecurrenceEditorProps {
|
|
344
|
+
open: boolean;
|
|
345
|
+
onCancel: () => void;
|
|
346
|
+
onDone: () => void;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* On a screen with a pane there is one editing surface and nothing floats over
|
|
351
|
+
* it — except this. A custom rule is a decision nested inside the form rather
|
|
352
|
+
* than a second form, so it is a small card over the pane that stays readable
|
|
353
|
+
* behind it, and it leaves as soon as it is answered.
|
|
354
|
+
*/
|
|
355
|
+
export function CustomRecurrenceDialog({
|
|
356
|
+
open,
|
|
357
|
+
onCancel,
|
|
358
|
+
onDone,
|
|
359
|
+
...editor
|
|
360
|
+
}: CustomRecurrenceDialogProps) {
|
|
361
|
+
return (
|
|
362
|
+
<Dialog
|
|
363
|
+
open={open}
|
|
364
|
+
onClose={onCancel}
|
|
365
|
+
title="Custom recurrence"
|
|
366
|
+
className="max-w-sm"
|
|
367
|
+
>
|
|
368
|
+
<div className="flex flex-col gap-5 p-5">
|
|
369
|
+
<h3 className="text-lg font-semibold text-fg">Custom recurrence</h3>
|
|
370
|
+
<CustomRecurrenceEditor {...editor} />
|
|
371
|
+
<div className="flex items-center justify-end gap-2">
|
|
372
|
+
<Button variant="ghost" onClick={onCancel}>
|
|
373
|
+
Cancel
|
|
374
|
+
</Button>
|
|
375
|
+
<Button variant="primary" onClick={onDone}>
|
|
376
|
+
Done
|
|
377
|
+
</Button>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</Dialog>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function Radio({
|
|
385
|
+
id,
|
|
386
|
+
name,
|
|
387
|
+
checked,
|
|
388
|
+
onChange,
|
|
389
|
+
}: {
|
|
390
|
+
id: string;
|
|
391
|
+
name: string;
|
|
392
|
+
checked: boolean;
|
|
393
|
+
onChange: () => void;
|
|
394
|
+
}) {
|
|
395
|
+
return (
|
|
396
|
+
<span className="relative inline-flex">
|
|
397
|
+
<input
|
|
398
|
+
id={id}
|
|
399
|
+
type="radio"
|
|
400
|
+
name={name}
|
|
401
|
+
checked={checked}
|
|
402
|
+
onChange={onChange}
|
|
403
|
+
className={cn(
|
|
404
|
+
"peer size-5 shrink-0 cursor-pointer appearance-none rounded-full border border-line-strong bg-surface outline-none transition-colors",
|
|
405
|
+
"checked:border-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface",
|
|
406
|
+
)}
|
|
407
|
+
/>
|
|
408
|
+
<span className="pointer-events-none absolute inset-0 m-auto hidden size-2.5 rounded-full bg-accent peer-checked:block" />
|
|
409
|
+
</span>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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 type {
|
|
6
|
+
CalendarDescriptor,
|
|
7
|
+
CalendarEventData,
|
|
8
|
+
} from "./calendar-types.js";
|
|
9
|
+
import { EventDetail } from "./event-detail.js";
|
|
10
|
+
|
|
11
|
+
const calendar: CalendarDescriptor = {
|
|
12
|
+
id: "c1",
|
|
13
|
+
accountId: "a1",
|
|
14
|
+
accountLabel: "Work",
|
|
15
|
+
name: "Northwind",
|
|
16
|
+
color: "cal-1",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const event: CalendarEventData = {
|
|
20
|
+
id: "e1",
|
|
21
|
+
calendarId: "c1",
|
|
22
|
+
title: "Q3 roadmap review",
|
|
23
|
+
start: "2026-06-10T10:00:00+02:00",
|
|
24
|
+
end: "2026-06-10T11:30:00+02:00",
|
|
25
|
+
allDay: false,
|
|
26
|
+
location: "Room Zuid",
|
|
27
|
+
notes: "Bring the staffing numbers.",
|
|
28
|
+
attendees: [
|
|
29
|
+
{
|
|
30
|
+
name: "Priya Natarajan",
|
|
31
|
+
email: "priya@northwind.example",
|
|
32
|
+
rsvp: "accepted",
|
|
33
|
+
role: "organizer",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
myRsvp: "accepted",
|
|
37
|
+
threadId: "thr_q3",
|
|
38
|
+
threadSubject: "Q3 roadmap review — agenda + pre-read",
|
|
39
|
+
timeZone: "Europe/Amsterdam",
|
|
40
|
+
zoneCertainty: "local",
|
|
41
|
+
recurrenceRule: "",
|
|
42
|
+
seriesId: "",
|
|
43
|
+
seriesException: false,
|
|
44
|
+
status: "confirmed",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const render = (overrides: Partial<CalendarEventData>, withThread = true) =>
|
|
48
|
+
renderToString(
|
|
49
|
+
createElement(EventDetail, {
|
|
50
|
+
event: { ...event, ...overrides },
|
|
51
|
+
calendar,
|
|
52
|
+
whenText: "Wednesday 10 June · 10:00 – 11:30",
|
|
53
|
+
onEdit: () => undefined,
|
|
54
|
+
onDelete: () => undefined,
|
|
55
|
+
...(withThread ? { onOpenThread: () => undefined } : {}),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
describe("EventDetail", () => {
|
|
60
|
+
it("offers the way back to the mail the event came from", () => {
|
|
61
|
+
const html = render({});
|
|
62
|
+
assert.match(html, /From this thread/);
|
|
63
|
+
assert.match(html, /Q3 roadmap review — agenda \+ pre-read/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("offers no thread link for an event nobody mailed about", () => {
|
|
67
|
+
const html = render({ threadId: "", threadSubject: "" });
|
|
68
|
+
assert.doesNotMatch(html, /From this thread/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("names the repeat rule on a series instance", () => {
|
|
72
|
+
assert.match(
|
|
73
|
+
render({ recurrenceRule: "Every weekday, 09:15" }),
|
|
74
|
+
/Every weekday, 09:15/,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("marks an instance that no longer matches its series rule", () => {
|
|
79
|
+
const html = render({
|
|
80
|
+
recurrenceRule: "Every weekday, 09:15",
|
|
81
|
+
seriesId: "ser_standup",
|
|
82
|
+
seriesException: true,
|
|
83
|
+
});
|
|
84
|
+
assert.match(html, /Every weekday, 09:15/);
|
|
85
|
+
assert.match(html, /Moved out of the series/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("says nothing about exceptions on an instance that follows the rule", () => {
|
|
89
|
+
assert.doesNotMatch(
|
|
90
|
+
render({ recurrenceRule: "Every weekday, 09:15" }),
|
|
91
|
+
/Moved out of the series/,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("says a zone is unknown instead of naming a guess", () => {
|
|
96
|
+
const html = render({ timeZone: "", zoneCertainty: "ambiguous" });
|
|
97
|
+
assert.match(html, /never a zone/);
|
|
98
|
+
assert.doesNotMatch(html, /Europe\/Amsterdam/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("names the calendar and its account", () => {
|
|
102
|
+
assert.match(render({}), /Northwind/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import type {
|
|
3
|
+
CalendarDescriptor,
|
|
4
|
+
CalendarEventData,
|
|
5
|
+
} from "./calendar-types.js";
|
|
6
|
+
import { EventDetail } from "./event-detail.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An event opened. The thread it came out of is part of the event rather than a
|
|
10
|
+
* footnote, so nothing that started as mail is ever a dead end.
|
|
11
|
+
*/
|
|
12
|
+
const meta: Meta<typeof EventDetail> = {
|
|
13
|
+
title: "Calendar/Event detail",
|
|
14
|
+
component: EventDetail,
|
|
15
|
+
parameters: { layout: "fullscreen" },
|
|
16
|
+
decorators: [
|
|
17
|
+
(Story) => (
|
|
18
|
+
<div className="h-[560px] max-w-lg border border-line">
|
|
19
|
+
<Story />
|
|
20
|
+
</div>
|
|
21
|
+
),
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
export default meta;
|
|
25
|
+
|
|
26
|
+
type Story = StoryObj<typeof EventDetail>;
|
|
27
|
+
|
|
28
|
+
const calendar: CalendarDescriptor = {
|
|
29
|
+
id: "c1",
|
|
30
|
+
accountId: "a1",
|
|
31
|
+
accountLabel: "Work",
|
|
32
|
+
name: "Northwind",
|
|
33
|
+
color: "cal-1",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const base: CalendarEventData = {
|
|
37
|
+
id: "e1",
|
|
38
|
+
calendarId: "c1",
|
|
39
|
+
title: "Q3 roadmap review",
|
|
40
|
+
start: "2026-06-10T10:00:00+02:00",
|
|
41
|
+
end: "2026-06-10T11:30:00+02:00",
|
|
42
|
+
allDay: false,
|
|
43
|
+
location: "Room Zuid",
|
|
44
|
+
notes: "Pre-read is in the thread. Bring the staffing numbers.",
|
|
45
|
+
attendees: [
|
|
46
|
+
{
|
|
47
|
+
name: "Priya Natarajan",
|
|
48
|
+
email: "priya@northwind.example",
|
|
49
|
+
rsvp: "accepted",
|
|
50
|
+
role: "organizer",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "Marcus Webb",
|
|
54
|
+
email: "marcus@northwind.example",
|
|
55
|
+
rsvp: "accepted",
|
|
56
|
+
role: "attendee",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "Dana Okafor",
|
|
60
|
+
email: "dana@northwind.example",
|
|
61
|
+
rsvp: "tentative",
|
|
62
|
+
role: "attendee",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "Sven Larsen",
|
|
66
|
+
email: "sven@northwind.example",
|
|
67
|
+
rsvp: "declined",
|
|
68
|
+
role: "attendee",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
myRsvp: "accepted",
|
|
72
|
+
threadId: "thr_q3",
|
|
73
|
+
threadSubject: "Q3 roadmap review — agenda + pre-read",
|
|
74
|
+
timeZone: "Europe/Amsterdam",
|
|
75
|
+
zoneCertainty: "local",
|
|
76
|
+
recurrenceRule: "",
|
|
77
|
+
seriesId: "",
|
|
78
|
+
seriesException: false,
|
|
79
|
+
status: "confirmed",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Born from a thread, and one click from it. */
|
|
83
|
+
export const FromMail: Story = {
|
|
84
|
+
render: () => (
|
|
85
|
+
<EventDetail
|
|
86
|
+
event={base}
|
|
87
|
+
calendar={calendar}
|
|
88
|
+
whenText="Wednesday 10 June · 10:00 – 11:30"
|
|
89
|
+
onEdit={() => {}}
|
|
90
|
+
onDelete={() => {}}
|
|
91
|
+
onOpenThread={() => {}}
|
|
92
|
+
/>
|
|
93
|
+
),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A repeating instance names its rule in the words a person would say it, not
|
|
98
|
+
* as an RRULE, and not folded into a settings pane.
|
|
99
|
+
*/
|
|
100
|
+
export const Recurring: Story = {
|
|
101
|
+
render: () => (
|
|
102
|
+
<EventDetail
|
|
103
|
+
event={{
|
|
104
|
+
...base,
|
|
105
|
+
title: "Standup",
|
|
106
|
+
start: "2026-06-10T09:15:00+02:00",
|
|
107
|
+
end: "2026-06-10T09:30:00+02:00",
|
|
108
|
+
location: "Huddle room",
|
|
109
|
+
notes: "",
|
|
110
|
+
threadId: "",
|
|
111
|
+
threadSubject: "",
|
|
112
|
+
recurrenceRule: "Every weekday, 09:15",
|
|
113
|
+
seriesId: "ser_standup",
|
|
114
|
+
}}
|
|
115
|
+
calendar={calendar}
|
|
116
|
+
whenText="Wednesday 10 June · 09:15 – 09:30"
|
|
117
|
+
onEdit={() => {}}
|
|
118
|
+
onDelete={() => {}}
|
|
119
|
+
/>
|
|
120
|
+
),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* This Thursday was moved and the rest of the week was not. The rule still
|
|
125
|
+
* reads back, because the series still owns the morning — what changed is that
|
|
126
|
+
* this instance no longer matches it, and the badge is where that is said.
|
|
127
|
+
*/
|
|
128
|
+
export const SeriesException: Story = {
|
|
129
|
+
render: () => (
|
|
130
|
+
<EventDetail
|
|
131
|
+
event={{
|
|
132
|
+
...base,
|
|
133
|
+
title: "Standup",
|
|
134
|
+
start: "2026-06-11T10:30:00+02:00",
|
|
135
|
+
end: "2026-06-11T10:45:00+02:00",
|
|
136
|
+
location: "Meet",
|
|
137
|
+
notes: "Pushed an hour and a quarter for the offsite travel window.",
|
|
138
|
+
threadId: "",
|
|
139
|
+
threadSubject: "",
|
|
140
|
+
recurrenceRule: "Every weekday, 09:15",
|
|
141
|
+
seriesId: "ser_standup",
|
|
142
|
+
seriesException: true,
|
|
143
|
+
}}
|
|
144
|
+
calendar={calendar}
|
|
145
|
+
whenText="Thursday 11 June · 10:30 – 10:45"
|
|
146
|
+
onEdit={() => {}}
|
|
147
|
+
onDelete={() => {}}
|
|
148
|
+
/>
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A zone we cannot determine is shown as unknown. Guessing quietly is how
|
|
154
|
+
* someone ends up on the wrong side of a two-hour gap.
|
|
155
|
+
*/
|
|
156
|
+
export const AmbiguousZone: Story = {
|
|
157
|
+
render: () => (
|
|
158
|
+
<EventDetail
|
|
159
|
+
event={{
|
|
160
|
+
...base,
|
|
161
|
+
title: "Offsite dinner",
|
|
162
|
+
start: "2026-06-11T19:00:00+02:00",
|
|
163
|
+
end: "2026-06-11T22:00:00+02:00",
|
|
164
|
+
location: "Toscanini",
|
|
165
|
+
notes: 'The thread says "dinner at 7" and never says where anyone is.',
|
|
166
|
+
timeZone: "",
|
|
167
|
+
zoneCertainty: "ambiguous",
|
|
168
|
+
threadId: "thr_dana",
|
|
169
|
+
threadSubject: "Offsite logistics — rooms, travel, the dinner",
|
|
170
|
+
}}
|
|
171
|
+
calendar={calendar}
|
|
172
|
+
whenText="Thursday 11 June · 19:00 – 22:00"
|
|
173
|
+
onEdit={() => {}}
|
|
174
|
+
onDelete={() => {}}
|
|
175
|
+
onOpenThread={() => {}}
|
|
176
|
+
/>
|
|
177
|
+
),
|
|
178
|
+
};
|