@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.
Files changed (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -0,0 +1,480 @@
1
+ import { ChevronDown, ChevronRight, Repeat } from "lucide-react";
2
+ import { type ReactNode, useId } from "react";
3
+ import { calendarColorClasses } from "../lib/calendar-color.js";
4
+ import { cn } from "../lib/cn.js";
5
+ import { NO_REPEAT, repeatChoices } from "../lib/recurrence.js";
6
+ import { Button } from "./button.js";
7
+ import type { CalendarDescriptor, EventDraft } from "./calendar-types.js";
8
+ import { Input } from "./input.js";
9
+ import { Select } from "./select.js";
10
+
11
+ /**
12
+ * `compact` folds everything past the three core fields behind a disclosure,
13
+ * for a rail. `pane` has the room to show the whole event at once and labels
14
+ * every field.
15
+ */
16
+ export type EventEditorLayout = "compact" | "pane";
17
+
18
+ export interface EventFieldProps {
19
+ draft: EventDraft;
20
+ onChange: (draft: EventDraft) => void;
21
+ layout?: EventEditorLayout;
22
+ /** Grows the controls to a thumb target. */
23
+ touch?: boolean;
24
+ }
25
+
26
+ const NOTES_CLASS =
27
+ "w-full rounded-md border border-line bg-surface-sunken px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-subtle focus-visible:border-line-strong focus-visible:ring-2 focus-visible:ring-ring/30";
28
+
29
+ const setField = <K extends keyof EventDraft>(
30
+ { draft, onChange }: Pick<EventFieldProps, "draft" | "onChange">,
31
+ key: K,
32
+ value: EventDraft[K],
33
+ ) => onChange({ ...draft, [key]: value });
34
+
35
+ /** A labelled field group, as the pane and the wizard steps lay fields out. */
36
+ export function EventField({
37
+ label,
38
+ children,
39
+ }: {
40
+ label: string;
41
+ children: ReactNode;
42
+ }) {
43
+ return (
44
+ <div className="flex flex-col gap-2">
45
+ <span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
46
+ {label}
47
+ </span>
48
+ {children}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export function EventTitleField({
54
+ draft,
55
+ onChange,
56
+ layout = "compact",
57
+ touch,
58
+ }: EventFieldProps) {
59
+ const roomy = layout === "pane";
60
+ return (
61
+ <Input
62
+ value={draft.title}
63
+ placeholder="Title"
64
+ aria-label="Title"
65
+ onChange={(e) => setField({ draft, onChange }, "title", e.target.value)}
66
+ className={cn(
67
+ roomy ? "text-lg font-medium" : "text-md font-medium",
68
+ roomy ? "h-11" : touch ? "min-h-11" : "",
69
+ )}
70
+ />
71
+ );
72
+ }
73
+
74
+ export function EventWhenField({ draft, onChange, touch }: EventFieldProps) {
75
+ const fieldHeight = touch ? "min-h-11" : "";
76
+ return (
77
+ <div className="flex flex-wrap items-center gap-2">
78
+ <Input
79
+ type="date"
80
+ value={draft.date}
81
+ aria-label="Date"
82
+ onChange={(e) => setField({ draft, onChange }, "date", e.target.value)}
83
+ className={cn("w-40", fieldHeight)}
84
+ />
85
+ {draft.allDay ? (
86
+ <span className="text-sm text-fg-muted">All day</span>
87
+ ) : (
88
+ <>
89
+ <Input
90
+ type="time"
91
+ value={draft.startTime}
92
+ aria-label="Start time"
93
+ onChange={(e) =>
94
+ setField({ draft, onChange }, "startTime", e.target.value)
95
+ }
96
+ className={cn("w-28", fieldHeight)}
97
+ />
98
+ <span className="text-sm text-fg-subtle">to</span>
99
+ <Input
100
+ type="time"
101
+ value={draft.endTime}
102
+ aria-label="End time"
103
+ onChange={(e) =>
104
+ setField({ draft, onChange }, "endTime", e.target.value)
105
+ }
106
+ className={cn("w-28", fieldHeight)}
107
+ />
108
+ </>
109
+ )}
110
+ <label className="flex items-center gap-2 text-sm text-fg-muted">
111
+ <input
112
+ type="checkbox"
113
+ checked={draft.allDay}
114
+ onChange={(e) =>
115
+ setField({ draft, onChange }, "allDay", e.target.checked)
116
+ }
117
+ className="size-4 accent-current"
118
+ />
119
+ All day
120
+ </label>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export function EventCalendarField({
126
+ draft,
127
+ onChange,
128
+ calendars,
129
+ touch,
130
+ }: EventFieldProps & { calendars: CalendarDescriptor[] }) {
131
+ return (
132
+ <CalendarPicker
133
+ calendars={calendars}
134
+ value={draft.calendarId}
135
+ onChange={(id) => setField({ draft, onChange }, "calendarId", id)}
136
+ touch={touch}
137
+ />
138
+ );
139
+ }
140
+
141
+ /**
142
+ * The rule belongs to the series, not to one morning of it. An edit scoped to a
143
+ * single instance reads the rule back instead of offering to change it.
144
+ */
145
+ export function EventRepeatField({
146
+ draft,
147
+ onChange,
148
+ touch,
149
+ repeatEditable = true,
150
+ onCustom,
151
+ }: EventFieldProps & {
152
+ repeatEditable?: boolean;
153
+ /** Absent leaves the picker to its derived choices. */
154
+ onCustom?: () => void;
155
+ }) {
156
+ if (!repeatEditable)
157
+ return (
158
+ <p className="flex items-center gap-2 text-sm text-fg-muted">
159
+ <Repeat className="size-4 shrink-0 text-fg-subtle" aria-hidden />
160
+ {draft.repeat === NO_REPEAT ? "Does not repeat" : draft.repeat}
161
+ </p>
162
+ );
163
+ return (
164
+ <RepeatPicker
165
+ value={draft.repeat}
166
+ date={draft.date}
167
+ startTime={draft.startTime}
168
+ onChange={(next) => setField({ draft, onChange }, "repeat", next)}
169
+ onCustom={onCustom}
170
+ className={touch ? "min-h-11" : ""}
171
+ />
172
+ );
173
+ }
174
+
175
+ export function EventLocationField({
176
+ draft,
177
+ onChange,
178
+ touch,
179
+ }: EventFieldProps) {
180
+ return (
181
+ <Input
182
+ value={draft.location}
183
+ placeholder="Location"
184
+ aria-label="Location"
185
+ onChange={(e) =>
186
+ setField({ draft, onChange }, "location", e.target.value)
187
+ }
188
+ className={touch ? "min-h-11" : ""}
189
+ />
190
+ );
191
+ }
192
+
193
+ export function EventGuestsField({ draft, onChange, touch }: EventFieldProps) {
194
+ return (
195
+ <Input
196
+ value={draft.guests}
197
+ placeholder="Guests, comma separated"
198
+ aria-label="Guests"
199
+ onChange={(e) => setField({ draft, onChange }, "guests", e.target.value)}
200
+ className={touch ? "min-h-11" : ""}
201
+ />
202
+ );
203
+ }
204
+
205
+ export function EventNotesField({
206
+ draft,
207
+ onChange,
208
+ layout = "compact",
209
+ }: EventFieldProps) {
210
+ return (
211
+ <textarea
212
+ value={draft.notes}
213
+ placeholder="Notes"
214
+ aria-label="Notes"
215
+ onChange={(e) => setField({ draft, onChange }, "notes", e.target.value)}
216
+ rows={layout === "pane" ? 6 : 3}
217
+ className={NOTES_CLASS}
218
+ />
219
+ );
220
+ }
221
+
222
+ export interface EventEditorProps {
223
+ draft: EventDraft;
224
+ onChange: (draft: EventDraft) => void;
225
+ calendars: CalendarDescriptor[];
226
+ layout?: EventEditorLayout;
227
+ /** The folded section is open. The caller owns it so a story can start open. */
228
+ expanded?: boolean;
229
+ /** Absent means there is no fold: every field is on screen. */
230
+ onToggleExpanded?: () => void;
231
+ onSave: () => void;
232
+ onCancel: () => void;
233
+ onDelete?: () => void;
234
+ saveLabel?: string;
235
+ /** Sits above the fields — the quick-entry field, or a scope reminder. */
236
+ header?: ReactNode;
237
+ /**
238
+ * The rule belongs to the series, not to one morning of it. An edit scoped to
239
+ * a single instance reads the rule back instead of offering to change it.
240
+ */
241
+ repeatEditable?: boolean;
242
+ /** Opens the custom-rule editor. Absent leaves the derived choices alone. */
243
+ onCustomRepeat?: () => void;
244
+ /** Grows the controls for a rail. */
245
+ touch?: boolean;
246
+ className?: string;
247
+ }
248
+
249
+ /**
250
+ * Three fields make an event: what, when, and which calendar. Everything else
251
+ * — where, who, notes, repeat — is real and reachable, folded away where the
252
+ * form is a rail and open where the form is a pane, because a fold buys room
253
+ * only where room is scarce.
254
+ */
255
+ export function EventEditor({
256
+ draft,
257
+ onChange,
258
+ calendars,
259
+ layout = "compact",
260
+ expanded = false,
261
+ onToggleExpanded,
262
+ onSave,
263
+ onCancel,
264
+ onDelete,
265
+ saveLabel = "Add",
266
+ header,
267
+ repeatEditable = true,
268
+ onCustomRepeat,
269
+ touch,
270
+ className,
271
+ }: EventEditorProps) {
272
+ const roomy = layout === "pane";
273
+ const common = { draft, onChange, layout, touch };
274
+
275
+ const title = <EventTitleField {...common} />;
276
+ const when = <EventWhenField {...common} />;
277
+ const calendarPicker = (
278
+ <EventCalendarField {...common} calendars={calendars} />
279
+ );
280
+ const repeat = (
281
+ <EventRepeatField
282
+ {...common}
283
+ repeatEditable={repeatEditable}
284
+ onCustom={onCustomRepeat}
285
+ />
286
+ );
287
+ const location = <EventLocationField {...common} />;
288
+ const guests = <EventGuestsField {...common} />;
289
+ const notes = <EventNotesField {...common} />;
290
+
291
+ const actions = (
292
+ <div className={cn("flex items-center gap-2", roomy ? "pt-2" : "pt-1")}>
293
+ <Button
294
+ variant="primary"
295
+ size={touch || roomy ? "md" : "sm"}
296
+ onClick={onSave}
297
+ className={touch ? "min-h-11 flex-1" : ""}
298
+ >
299
+ {saveLabel}
300
+ </Button>
301
+ <Button
302
+ variant="ghost"
303
+ size={touch || roomy ? "md" : "sm"}
304
+ onClick={onCancel}
305
+ className={touch ? "min-h-11" : ""}
306
+ >
307
+ Cancel
308
+ </Button>
309
+ {onDelete && (
310
+ <Button
311
+ variant="ghost"
312
+ size={touch || roomy ? "md" : "sm"}
313
+ onClick={onDelete}
314
+ className={cn("ml-auto text-danger", touch && "min-h-11")}
315
+ >
316
+ Delete
317
+ </Button>
318
+ )}
319
+ </div>
320
+ );
321
+
322
+ if (roomy) {
323
+ return (
324
+ <div className={cn("flex flex-col gap-5", className)}>
325
+ {header}
326
+ <EventField label="Title">{title}</EventField>
327
+ <EventField label="When">{when}</EventField>
328
+ <EventField label="Calendar">{calendarPicker}</EventField>
329
+ <EventField label="Repeat">{repeat}</EventField>
330
+ <EventField label="Location">{location}</EventField>
331
+ <EventField label="Guests">{guests}</EventField>
332
+ <EventField label="Notes">{notes}</EventField>
333
+ {actions}
334
+ </div>
335
+ );
336
+ }
337
+
338
+ const folded = onToggleExpanded !== undefined && !expanded;
339
+
340
+ return (
341
+ <div className={cn("flex flex-col gap-3", className)}>
342
+ {header}
343
+
344
+ {title}
345
+ {when}
346
+ {calendarPicker}
347
+
348
+ {onToggleExpanded && (
349
+ <button
350
+ type="button"
351
+ onClick={onToggleExpanded}
352
+ aria-expanded={expanded}
353
+ className={cn(
354
+ "flex items-center gap-1 self-start rounded-sm text-xs font-medium text-fg-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-ring",
355
+ touch && "min-h-11",
356
+ )}
357
+ >
358
+ {expanded ? (
359
+ <ChevronDown className="size-3.5" />
360
+ ) : (
361
+ <ChevronRight className="size-3.5" />
362
+ )}
363
+ {expanded ? "Fewer details" : "More details"}
364
+ </button>
365
+ )}
366
+
367
+ {!folded && (
368
+ <div className="flex flex-col gap-2 border-t border-line pt-3">
369
+ {location}
370
+ {guests}
371
+ {repeat}
372
+ {notes}
373
+ </div>
374
+ )}
375
+
376
+ {actions}
377
+ </div>
378
+ );
379
+ }
380
+
381
+ /** The value "Custom…" carries. No rule reads like this, so nothing collides. */
382
+ const CUSTOM_OPTION = "__custom__";
383
+
384
+ /**
385
+ * The rules a date can repeat by, plus whatever rule the event already carries.
386
+ * A rule read out of a mail or typed in a sentence is kept verbatim rather than
387
+ * snapped to the nearest offer — the picker never silently rewrites a rule.
388
+ */
389
+ function RepeatPicker({
390
+ value,
391
+ date,
392
+ startTime,
393
+ onChange,
394
+ onCustom,
395
+ className,
396
+ }: {
397
+ value: string;
398
+ date: string;
399
+ startTime: string;
400
+ onChange: (repeat: string) => void;
401
+ onCustom?: () => void;
402
+ className?: string;
403
+ }) {
404
+ const offered = repeatChoices(date, startTime);
405
+ const choices =
406
+ value === NO_REPEAT || offered.includes(value)
407
+ ? offered
408
+ : [value, ...offered];
409
+
410
+ return (
411
+ <Select
412
+ icon={<Repeat className="size-4" />}
413
+ aria-label="Repeat"
414
+ value={value}
415
+ onChange={(e) => {
416
+ if (e.target.value === CUSTOM_OPTION) {
417
+ onCustom?.();
418
+ return;
419
+ }
420
+ onChange(e.target.value);
421
+ }}
422
+ className={className}
423
+ >
424
+ <option value={NO_REPEAT}>Does not repeat</option>
425
+ {choices.map((choice) => (
426
+ <option key={choice} value={choice}>
427
+ {choice}
428
+ </option>
429
+ ))}
430
+ {onCustom && <option value={CUSTOM_OPTION}>Custom…</option>}
431
+ </Select>
432
+ );
433
+ }
434
+
435
+ function CalendarPicker({
436
+ calendars,
437
+ value,
438
+ onChange,
439
+ touch,
440
+ }: {
441
+ calendars: CalendarDescriptor[];
442
+ value: string;
443
+ onChange: (calendarId: string) => void;
444
+ touch?: boolean;
445
+ }) {
446
+ const group = useId();
447
+ return (
448
+ <fieldset className="flex flex-wrap gap-1.5">
449
+ <legend className="sr-only">Calendar</legend>
450
+ {calendars.map((calendar) => {
451
+ const hue = calendarColorClasses(calendar.color);
452
+ const active = calendar.id === value;
453
+ return (
454
+ <label
455
+ key={calendar.id}
456
+ className={cn(
457
+ "inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs transition-colors",
458
+ "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
459
+ touch ? "min-h-11" : "h-7",
460
+ active
461
+ ? cn(hue.soft, hue.text, hue.border)
462
+ : "border-line text-fg-muted hover:bg-surface-sunken",
463
+ )}
464
+ >
465
+ <input
466
+ type="radio"
467
+ name={group}
468
+ value={calendar.id}
469
+ checked={active}
470
+ onChange={() => onChange(calendar.id)}
471
+ className="sr-only"
472
+ />
473
+ <span className={cn("size-2 rounded-full", hue.solid)} />
474
+ {calendar.name}
475
+ </label>
476
+ );
477
+ })}
478
+ </fieldset>
479
+ );
480
+ }
@@ -0,0 +1,40 @@
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 { parseEventPhrase } from "../lib/event-phrase.js";
6
+ import { EventQuickEntry } from "./event-quick-entry.js";
7
+
8
+ const NOW = new Date(2026, 5, 10, 9, 30);
9
+
10
+ const render = (phrase: string) =>
11
+ renderToString(
12
+ createElement(EventQuickEntry, {
13
+ value: phrase,
14
+ onChange: () => undefined,
15
+ parse: parseEventPhrase(phrase, NOW),
16
+ onCommit: () => undefined,
17
+ }),
18
+ );
19
+
20
+ describe("EventQuickEntry", () => {
21
+ it("attributes each reading to the words it came from", () => {
22
+ const html = render("lunch with Jane friday 1pm");
23
+ assert.match(html, /lunch/);
24
+ assert.match(html, /friday 1pm/);
25
+ assert.match(html, /Jane/);
26
+ });
27
+
28
+ it("says what it assumed", () => {
29
+ assert.match(render("standup tomorrow 9:30"), /No length given/);
30
+ });
31
+
32
+ it("says what it could not settle", () => {
33
+ assert.match(render("design review monday"), /No time given/);
34
+ });
35
+
36
+ it("shows no reading before anything is typed", () => {
37
+ const html = render("");
38
+ assert.doesNotMatch(html, /WHEN|When<\/span>/);
39
+ });
40
+ });
@@ -0,0 +1,56 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { parseEventPhrase } from "../lib/event-phrase.js";
4
+ import { EventQuickEntry } from "./event-quick-entry.js";
5
+
6
+ /**
7
+ * Typing a sentence is the fastest way to make an event, and the machine's
8
+ * reading of that sentence is on screen while you type — each field next to the
9
+ * words it came from, with anything assumed or missing said out loud. The
10
+ * correction happens before the event exists.
11
+ */
12
+ const meta: Meta<typeof EventQuickEntry> = {
13
+ title: "Calendar/Quick entry",
14
+ component: EventQuickEntry,
15
+ parameters: { layout: "padded" },
16
+ };
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof EventQuickEntry>;
20
+
21
+ /** Wednesday 10 June 2026 — the same fixed "now" as the mail fixtures. */
22
+ const NOW = new Date(2026, 5, 10, 9, 30);
23
+
24
+ function Live({ initial }: { initial: string }) {
25
+ const [phrase, setPhrase] = useState(initial);
26
+ return (
27
+ <div className="max-w-md">
28
+ <EventQuickEntry
29
+ value={phrase}
30
+ onChange={setPhrase}
31
+ parse={parseEventPhrase(phrase, NOW)}
32
+ onCommit={() => {}}
33
+ />
34
+ </div>
35
+ );
36
+ }
37
+
38
+ /** Type into it. Every keystroke moves the reading below the field. */
39
+ export const Typing: Story = {
40
+ render: () => <Live initial="lunch with Jane friday 1pm" />,
41
+ };
42
+
43
+ /** A length in the phrase is read; without one the reader says it picked an hour. */
44
+ export const WithLength: Story = {
45
+ render: () => <Live initial="dentist next tuesday 15:00 for 45m" />,
46
+ };
47
+
48
+ /** No time in the sentence, so no time in the event — and it says so. */
49
+ export const MissingTime: Story = {
50
+ render: () => <Live initial="design review monday" />,
51
+ };
52
+
53
+ /** Nothing typed yet: the reading stays out of the way until there is one. */
54
+ export const Empty: Story = {
55
+ render: () => <Live initial="" />,
56
+ };