@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,188 @@
1
+ import {
2
+ Clock,
3
+ Globe,
4
+ Mail,
5
+ MapPin,
6
+ Pencil,
7
+ Repeat,
8
+ Trash2,
9
+ X,
10
+ } from "lucide-react";
11
+ import { calendarColorClasses } from "../lib/calendar-color.js";
12
+ import { cn } from "../lib/cn.js";
13
+ import { AttendeeList, RsvpBadge } from "./attendee-row.js";
14
+ import { Button } from "./button.js";
15
+ import type {
16
+ CalendarDescriptor,
17
+ CalendarEventData,
18
+ } from "./calendar-types.js";
19
+
20
+ export interface EventDetailProps {
21
+ event: CalendarEventData;
22
+ calendar: CalendarDescriptor;
23
+ /** Already formatted by the caller — the component owns no clock. */
24
+ whenText: string;
25
+ onEdit: () => void;
26
+ onDelete: () => void;
27
+ /** Absent when the event was never born from a mail. */
28
+ onOpenThread?: () => void;
29
+ /** Puts the pane back to whatever it shows with nothing selected. */
30
+ onClose?: () => void;
31
+ /**
32
+ * `bare` drops the header and the scrolling frame, for a surface that already
33
+ * carries both — a full-screen flow whose footer holds the same actions.
34
+ */
35
+ chrome?: "header" | "bare";
36
+ className?: string;
37
+ }
38
+
39
+ function Line({
40
+ icon,
41
+ children,
42
+ }: {
43
+ icon: React.ReactNode;
44
+ children: React.ReactNode;
45
+ }) {
46
+ return (
47
+ <div className="flex items-start gap-2.5 py-1">
48
+ <span className="mt-0.5 shrink-0 text-fg-subtle">{icon}</span>
49
+ <div className="min-w-0 flex-1 text-sm text-fg">{children}</div>
50
+ </div>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * One event, opened. The link back to the mail it came from is part of the
56
+ * event rather than a footnote: an event with a thread behind it is never a
57
+ * dead end, and the way back is one click from the detail you are reading.
58
+ */
59
+ export function EventDetail({
60
+ event,
61
+ calendar,
62
+ whenText,
63
+ onEdit,
64
+ onDelete,
65
+ onOpenThread,
66
+ onClose,
67
+ chrome = "header",
68
+ className,
69
+ }: EventDetailProps) {
70
+ const hue = calendarColorClasses(calendar.color);
71
+
72
+ const facts = (
73
+ <>
74
+ <h1 className="text-xl font-semibold text-fg">{event.title}</h1>
75
+
76
+ <div className="mt-3 flex flex-col border-b border-line pb-3">
77
+ <Line icon={<Clock className="size-4" />}>
78
+ <span className="flex flex-wrap items-center gap-2">
79
+ {whenText}
80
+ {event.attendees.length > 0 && <RsvpBadge rsvp={event.myRsvp} />}
81
+ </span>
82
+ </Line>
83
+ {event.zoneCertainty === "ambiguous" ? (
84
+ <Line icon={<Globe className="size-4 text-warning" />}>
85
+ <span className="text-warning">
86
+ The mail said a time but never a zone. Shown in your own; confirm
87
+ before you travel for it.
88
+ </span>
89
+ </Line>
90
+ ) : (
91
+ event.timeZone !== "" && (
92
+ <Line icon={<Globe className="size-4" />}>{event.timeZone}</Line>
93
+ )
94
+ )}
95
+ {event.recurrenceRule !== "" && (
96
+ <Line icon={<Repeat className="size-4" />}>
97
+ <span className="flex flex-wrap items-center gap-2">
98
+ {event.recurrenceRule}
99
+ {event.seriesException && (
100
+ <span className="rounded-xs bg-warning-soft px-1.5 py-0.5 text-2xs text-warning">
101
+ Moved out of the series
102
+ </span>
103
+ )}
104
+ </span>
105
+ </Line>
106
+ )}
107
+ {event.location !== "" && (
108
+ <Line icon={<MapPin className="size-4" />}>{event.location}</Line>
109
+ )}
110
+ </div>
111
+
112
+ {event.threadId !== "" && onOpenThread && (
113
+ <button
114
+ type="button"
115
+ onClick={onOpenThread}
116
+ className="mt-3 flex w-full items-center gap-2.5 rounded-md border border-line bg-surface-sunken px-3 py-2 text-left outline-none hover:border-line-strong focus-visible:ring-2 focus-visible:ring-ring"
117
+ >
118
+ <Mail className="size-4 shrink-0 text-accent-2" />
119
+ <span className="min-w-0 flex-1">
120
+ <span className="block text-2xs uppercase tracking-wider text-fg-subtle">
121
+ From this thread
122
+ </span>
123
+ <span className="block truncate text-sm text-fg">
124
+ {event.threadSubject}
125
+ </span>
126
+ </span>
127
+ </button>
128
+ )}
129
+
130
+ {event.attendees.length > 0 && (
131
+ <AttendeeList attendees={event.attendees} className="mt-3" />
132
+ )}
133
+
134
+ {event.notes !== "" && (
135
+ <p className="mt-3 whitespace-pre-wrap text-sm text-fg-muted">
136
+ {event.notes}
137
+ </p>
138
+ )}
139
+ </>
140
+ );
141
+
142
+ if (chrome === "bare")
143
+ return <div className={cn("flex flex-col", className)}>{facts}</div>;
144
+
145
+ return (
146
+ <article
147
+ className={cn("flex h-full w-full flex-col bg-surface", className)}
148
+ >
149
+ <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
150
+ <span className={cn("size-2.5 shrink-0 rounded-full", hue.solid)} />
151
+ <span className="min-w-0 flex-1 truncate text-xs text-fg-muted">
152
+ {calendar.name} · {calendar.accountLabel}
153
+ </span>
154
+ <Button
155
+ variant="ghost"
156
+ size="sm"
157
+ icon={<Pencil className="size-3.5" />}
158
+ onClick={onEdit}
159
+ >
160
+ Edit
161
+ </Button>
162
+ <Button
163
+ variant="ghost"
164
+ size="sm"
165
+ icon={<Trash2 className="size-3.5" />}
166
+ onClick={onDelete}
167
+ className="text-danger"
168
+ >
169
+ Delete
170
+ </Button>
171
+ {onClose && (
172
+ <button
173
+ type="button"
174
+ aria-label="Close event"
175
+ onClick={onClose}
176
+ className="flex size-7 items-center justify-center rounded-md text-fg-subtle outline-none hover:bg-surface-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
177
+ >
178
+ <X className="size-4" />
179
+ </button>
180
+ )}
181
+ </header>
182
+
183
+ <div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-3">
184
+ {facts}
185
+ </div>
186
+ </article>
187
+ );
188
+ }
@@ -0,0 +1,54 @@
1
+ import { X } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+
5
+ export interface EventEditorPaneProps {
6
+ /** What the pane is doing: "New event", "Edit event". */
7
+ title: string;
8
+ /** The one line of context the title cannot carry — which day, which series. */
9
+ subtitle?: string;
10
+ onClose: () => void;
11
+ children: ReactNode;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * Writing an event happens in the pane the event is read in, at the width that
17
+ * pane has. A form floating over the grid has to stay small enough not to bury
18
+ * it, which is how the fields got crushed; the pane is already where the reader
19
+ * looks after clicking something, it grows with the window, and it can be
20
+ * dragged wider when the form is the work.
21
+ */
22
+ export function EventEditorPane({
23
+ title,
24
+ subtitle,
25
+ onClose,
26
+ children,
27
+ className,
28
+ }: EventEditorPaneProps) {
29
+ return (
30
+ <section
31
+ className={cn("flex h-full w-full flex-col bg-surface", className)}
32
+ >
33
+ <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
34
+ <h2 className="shrink-0 text-xs font-medium text-fg">{title}</h2>
35
+ {subtitle !== undefined && subtitle !== "" && (
36
+ <span className="min-w-0 flex-1 truncate text-xs text-fg-muted">
37
+ {subtitle}
38
+ </span>
39
+ )}
40
+ <button
41
+ type="button"
42
+ aria-label="Close editor"
43
+ onClick={onClose}
44
+ className="ml-auto flex size-7 shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none hover:bg-surface-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-ring"
45
+ >
46
+ <X className="size-4" />
47
+ </button>
48
+ </header>
49
+ <div className="min-h-0 flex-1 overflow-y-auto px-row-inset py-5">
50
+ <div className="mx-auto w-full max-w-2xl">{children}</div>
51
+ </div>
52
+ </section>
53
+ );
54
+ }
@@ -0,0 +1,83 @@
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 { CalendarDescriptor, EventDraft } from "./calendar-types.js";
6
+ import { EventEditor } from "./event-editor.js";
7
+
8
+ const calendars: CalendarDescriptor[] = [
9
+ {
10
+ id: "c1",
11
+ accountId: "a1",
12
+ accountLabel: "Work",
13
+ name: "Northwind",
14
+ color: "cal-1",
15
+ },
16
+ {
17
+ id: "c2",
18
+ accountId: "a2",
19
+ accountLabel: "Personal",
20
+ name: "Family",
21
+ color: "cal-3",
22
+ },
23
+ ];
24
+
25
+ const draft: EventDraft = {
26
+ title: "Coffee",
27
+ date: "2026-06-12",
28
+ startTime: "13:00",
29
+ endTime: "14:00",
30
+ allDay: false,
31
+ calendarId: "c1",
32
+ location: "",
33
+ guests: "",
34
+ notes: "",
35
+ repeat: "",
36
+ };
37
+
38
+ const render = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
39
+ renderToString(
40
+ createElement(EventEditor, {
41
+ draft,
42
+ onChange: () => undefined,
43
+ calendars,
44
+ expanded: false,
45
+ onToggleExpanded: () => undefined,
46
+ onSave: () => undefined,
47
+ onCancel: () => undefined,
48
+ ...props,
49
+ }),
50
+ );
51
+
52
+ describe("EventEditor", () => {
53
+ it("folds everything but title, when and calendar", () => {
54
+ const html = render({});
55
+ assert.match(html, /aria-label="Title"/);
56
+ assert.match(html, /aria-label="Start time"/);
57
+ assert.match(html, /Northwind/);
58
+ assert.doesNotMatch(html, /aria-label="Location"/);
59
+ assert.match(html, /More details/);
60
+ });
61
+
62
+ it("reveals the rest on request", () => {
63
+ const html = render({ expanded: true });
64
+ assert.match(html, /aria-label="Location"/);
65
+ assert.match(html, /aria-label="Guests"/);
66
+ assert.match(html, /aria-label="Repeat"/);
67
+ assert.match(html, /Fewer details/);
68
+ });
69
+
70
+ it("drops the clock fields for an all-day entry", () => {
71
+ const html = render({ draft: { ...draft, allDay: true } });
72
+ assert.doesNotMatch(html, /aria-label="Start time"/);
73
+ });
74
+
75
+ it("offers Delete only when editing something that exists", () => {
76
+ assert.doesNotMatch(render({}), /Delete/);
77
+ assert.match(render({ onDelete: () => undefined }), /Delete/);
78
+ });
79
+
80
+ it("names the save action the caller asked for", () => {
81
+ assert.match(render({ saveLabel: "Save changes" }), /Save changes/);
82
+ });
83
+ });
@@ -0,0 +1,99 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import type { CalendarDescriptor, EventDraft } from "./calendar-types.js";
4
+ import { EventEditor } from "./event-editor.js";
5
+
6
+ /**
7
+ * Three fields make an event. Location, guests, notes and repeat are real and
8
+ * one click away, but they do not charge the common case for their existence.
9
+ */
10
+ const meta: Meta<typeof EventEditor> = {
11
+ title: "Calendar/Event editor",
12
+ component: EventEditor,
13
+ parameters: { layout: "padded" },
14
+ };
15
+ export default meta;
16
+
17
+ type Story = StoryObj<typeof EventEditor>;
18
+
19
+ const calendars: CalendarDescriptor[] = [
20
+ {
21
+ id: "c1",
22
+ accountId: "a1",
23
+ accountLabel: "Work",
24
+ name: "Northwind",
25
+ color: "cal-1",
26
+ },
27
+ {
28
+ id: "c3",
29
+ accountId: "a2",
30
+ accountLabel: "Personal",
31
+ name: "Personal",
32
+ color: "cal-2",
33
+ },
34
+ {
35
+ id: "c4",
36
+ accountId: "a2",
37
+ accountLabel: "Personal",
38
+ name: "Family",
39
+ color: "cal-3",
40
+ },
41
+ ];
42
+
43
+ const seed: EventDraft = {
44
+ title: "",
45
+ date: "2026-06-12",
46
+ startTime: "13:00",
47
+ endTime: "14:00",
48
+ allDay: false,
49
+ calendarId: "c1",
50
+ location: "",
51
+ guests: "",
52
+ notes: "",
53
+ repeat: "",
54
+ };
55
+
56
+ function Live({ startExpanded }: { startExpanded: boolean }) {
57
+ const [draft, setDraft] = useState(seed);
58
+ const [expanded, setExpanded] = useState(startExpanded);
59
+ return (
60
+ <div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
61
+ <EventEditor
62
+ draft={draft}
63
+ onChange={setDraft}
64
+ calendars={calendars}
65
+ expanded={expanded}
66
+ onToggleExpanded={() => setExpanded((open) => !open)}
67
+ onSave={() => {}}
68
+ onCancel={() => {}}
69
+ />
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export const Folded: Story = { render: () => <Live startExpanded={false} /> };
75
+
76
+ /** Everything the folded form was hiding. */
77
+ export const Unfolded: Story = { render: () => <Live startExpanded /> };
78
+
79
+ /** The same form sized for a bottom sheet: every control a thumb target. */
80
+ export const Touch: Story = {
81
+ render: () => {
82
+ const [draft, setDraft] = useState(seed);
83
+ const [expanded, setExpanded] = useState(false);
84
+ return (
85
+ <div className="max-w-[390px] rounded-lg border border-line bg-surface-raised p-4">
86
+ <EventEditor
87
+ draft={draft}
88
+ onChange={setDraft}
89
+ calendars={calendars}
90
+ expanded={expanded}
91
+ onToggleExpanded={() => setExpanded((open) => !open)}
92
+ onSave={() => {}}
93
+ onCancel={() => {}}
94
+ touch
95
+ />
96
+ </div>
97
+ );
98
+ },
99
+ };