@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,159 @@
|
|
|
1
|
+
import { AlertTriangle, Info, Wand2 } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import type { PhraseParse } from "../lib/event-phrase.js";
|
|
4
|
+
|
|
5
|
+
export interface EventQuickEntryProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
/** The reading of `value`, recomputed by the caller on every keystroke. */
|
|
9
|
+
parse: PhraseParse;
|
|
10
|
+
/** Commits the reading to the form. */
|
|
11
|
+
onCommit: () => void;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
touch?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ReadingProps {
|
|
18
|
+
label: string;
|
|
19
|
+
value: string;
|
|
20
|
+
source: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Reading({ label, value, source }: ReadingProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
26
|
+
<span className="shrink-0 text-2xs uppercase tracking-wider text-fg-subtle">
|
|
27
|
+
{label}
|
|
28
|
+
</span>
|
|
29
|
+
<span className="truncate text-xs font-medium text-fg">{value}</span>
|
|
30
|
+
{source !== "" && (
|
|
31
|
+
<span className="shrink-0 rounded-xs bg-accent-2-soft px-1 text-2xs text-accent-2">
|
|
32
|
+
{source}
|
|
33
|
+
</span>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Typing is the fastest way to make an event, so the field takes a sentence.
|
|
41
|
+
* What it does with the sentence is on screen while you type: every reading is
|
|
42
|
+
* shown next to the words it was taken from, and anything the reader filled in
|
|
43
|
+
* or could not settle is said out loud. Correcting the machine happens before
|
|
44
|
+
* the event exists, not after it is on the grid.
|
|
45
|
+
*/
|
|
46
|
+
export function EventQuickEntry({
|
|
47
|
+
value,
|
|
48
|
+
onChange,
|
|
49
|
+
parse,
|
|
50
|
+
onCommit,
|
|
51
|
+
placeholder = "lunch with Jane friday 1pm",
|
|
52
|
+
touch,
|
|
53
|
+
className,
|
|
54
|
+
}: EventQuickEntryProps) {
|
|
55
|
+
const hasReading = value.trim() !== "";
|
|
56
|
+
const endTime =
|
|
57
|
+
parse.startTime === ""
|
|
58
|
+
? ""
|
|
59
|
+
: ` – ${clockAfter(parse.startTime, parse.durationMinutes)}`;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={cn("flex flex-col gap-2", className)}>
|
|
63
|
+
<div
|
|
64
|
+
className={cn(
|
|
65
|
+
"flex items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30",
|
|
66
|
+
touch ? "min-h-11" : "h-9",
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
<Wand2 className="size-4 shrink-0 text-fg-subtle" aria-hidden />
|
|
70
|
+
<input
|
|
71
|
+
value={value}
|
|
72
|
+
aria-label="Describe the event"
|
|
73
|
+
placeholder={placeholder}
|
|
74
|
+
onChange={(e) => onChange(e.target.value)}
|
|
75
|
+
onKeyDown={(e) => {
|
|
76
|
+
if (e.key === "Enter") onCommit();
|
|
77
|
+
}}
|
|
78
|
+
className="min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{hasReading && (
|
|
83
|
+
<div className="flex flex-col gap-1 rounded-md border border-line bg-surface px-2.5 py-2">
|
|
84
|
+
<Reading
|
|
85
|
+
label="What"
|
|
86
|
+
value={parse.title === "" ? "—" : parse.title}
|
|
87
|
+
source=""
|
|
88
|
+
/>
|
|
89
|
+
<Reading
|
|
90
|
+
label="When"
|
|
91
|
+
value={`${formatDay(parse.date)}${
|
|
92
|
+
parse.startTime === "" ? "" : ` ${parse.startTime}${endTime}`
|
|
93
|
+
}`}
|
|
94
|
+
source={[parse.dateText, parse.startTimeText]
|
|
95
|
+
.filter((part) => part !== "")
|
|
96
|
+
.join(" ")}
|
|
97
|
+
/>
|
|
98
|
+
{parse.durationText !== "" && (
|
|
99
|
+
<Reading
|
|
100
|
+
label="Long"
|
|
101
|
+
value={formatDuration(parse.durationMinutes)}
|
|
102
|
+
source={parse.durationText}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
{parse.attendees.length > 0 && (
|
|
106
|
+
<Reading
|
|
107
|
+
label="Who"
|
|
108
|
+
value={parse.attendees.join(", ")}
|
|
109
|
+
source={parse.attendeesText}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
{parse.assumptions.map((note) => (
|
|
113
|
+
<p
|
|
114
|
+
key={note}
|
|
115
|
+
className="flex items-center gap-1.5 text-2xs text-fg-subtle"
|
|
116
|
+
>
|
|
117
|
+
<Info className="size-3 shrink-0" aria-hidden />
|
|
118
|
+
{note}
|
|
119
|
+
</p>
|
|
120
|
+
))}
|
|
121
|
+
{parse.unresolved.map((note) => (
|
|
122
|
+
<p
|
|
123
|
+
key={note}
|
|
124
|
+
className="flex items-center gap-1.5 text-2xs text-warning"
|
|
125
|
+
>
|
|
126
|
+
<AlertTriangle className="size-3 shrink-0" aria-hidden />
|
|
127
|
+
{note}
|
|
128
|
+
</p>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clockAfter(clock: string, minutes: number): string {
|
|
137
|
+
const [hours, mins] = clock.split(":").map(Number);
|
|
138
|
+
const total = (hours * 60 + mins + minutes) % 1440;
|
|
139
|
+
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
|
|
140
|
+
total % 60,
|
|
141
|
+
).padStart(2, "0")}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatDay(iso: string): string {
|
|
145
|
+
if (iso === "") return "—";
|
|
146
|
+
const [year, month, day] = iso.split("-").map(Number);
|
|
147
|
+
return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
|
|
148
|
+
weekday: "short",
|
|
149
|
+
day: "numeric",
|
|
150
|
+
month: "short",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatDuration(minutes: number): string {
|
|
155
|
+
if (minutes === 0) return "—";
|
|
156
|
+
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
|
157
|
+
if (minutes < 60) return `${minutes}m`;
|
|
158
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
159
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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 { EventSuggestion } from "./calendar-types.js";
|
|
6
|
+
import { EventSuggestionCard } from "./event-suggestion-card.js";
|
|
7
|
+
|
|
8
|
+
const suggestion: EventSuggestion = {
|
|
9
|
+
id: "s1",
|
|
10
|
+
title: "Stay in Lisbon",
|
|
11
|
+
start: "2026-06-19",
|
|
12
|
+
end: "2026-06-23",
|
|
13
|
+
allDay: true,
|
|
14
|
+
location: "Alfama, Lisbon",
|
|
15
|
+
threadId: "thr_airbnb",
|
|
16
|
+
threadSubject: "Your reservation in Lisbon is confirmed",
|
|
17
|
+
sender: "Airbnb",
|
|
18
|
+
confidence: 0.94,
|
|
19
|
+
ambiguity: "",
|
|
20
|
+
suggestedCalendarId: "c5",
|
|
21
|
+
timeZone: "Europe/Lisbon",
|
|
22
|
+
zoneCertainty: "explicit",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const render = (overrides: Partial<EventSuggestion>) =>
|
|
26
|
+
renderToString(
|
|
27
|
+
createElement(EventSuggestionCard, {
|
|
28
|
+
suggestion: { ...suggestion, ...overrides },
|
|
29
|
+
whenText: "Friday 19 June – Monday 22 June",
|
|
30
|
+
onAdd: () => undefined,
|
|
31
|
+
onReview: () => undefined,
|
|
32
|
+
onDismiss: () => undefined,
|
|
33
|
+
onOpenThread: () => undefined,
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
describe("EventSuggestionCard", () => {
|
|
38
|
+
it("says it is a suggestion and not an event", () => {
|
|
39
|
+
const html = render({});
|
|
40
|
+
assert.match(html, /Suggested from mail/);
|
|
41
|
+
assert.match(html, /border-dashed/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("puts the reading into words rather than a percentage", () => {
|
|
45
|
+
assert.match(render({}), /Read cleanly/);
|
|
46
|
+
assert.match(render({ confidence: 0.7 }), /Read with gaps/);
|
|
47
|
+
assert.match(render({ confidence: 0.2 }), /Barely read/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("names what it could not settle", () => {
|
|
51
|
+
assert.match(
|
|
52
|
+
render({ ambiguity: "Two Tuesdays fit." }),
|
|
53
|
+
/Two Tuesdays fit\./,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("credits the mail it was read out of", () => {
|
|
58
|
+
assert.match(render({}), /Airbnb/);
|
|
59
|
+
assert.match(render({}), /Your reservation in Lisbon is confirmed/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("needs a person to press Add", () => {
|
|
63
|
+
assert.match(render({}), />Add</);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import type { EventSuggestion } from "./calendar-types.js";
|
|
3
|
+
import { EventSuggestionCard } from "./event-suggestion-card.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the reader found in a mail, waiting for a person. It sits on a dashed
|
|
7
|
+
* card off the grid: a suggestion is never a provisional event that someone has
|
|
8
|
+
* to notice and take back off the calendar.
|
|
9
|
+
*/
|
|
10
|
+
const meta: Meta<typeof EventSuggestionCard> = {
|
|
11
|
+
title: "Calendar/Suggestion card",
|
|
12
|
+
component: EventSuggestionCard,
|
|
13
|
+
parameters: { layout: "padded" },
|
|
14
|
+
decorators: [
|
|
15
|
+
(Story) => (
|
|
16
|
+
<div className="max-w-sm">
|
|
17
|
+
<Story />
|
|
18
|
+
</div>
|
|
19
|
+
),
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
export default meta;
|
|
23
|
+
|
|
24
|
+
type Story = StoryObj<typeof EventSuggestionCard>;
|
|
25
|
+
|
|
26
|
+
const base: EventSuggestion = {
|
|
27
|
+
id: "s1",
|
|
28
|
+
title: "Stay in Lisbon",
|
|
29
|
+
start: "2026-06-19",
|
|
30
|
+
end: "2026-06-23",
|
|
31
|
+
allDay: true,
|
|
32
|
+
location: "Alfama, Lisbon",
|
|
33
|
+
threadId: "thr_airbnb",
|
|
34
|
+
threadSubject: "Your reservation in Lisbon is confirmed",
|
|
35
|
+
sender: "Airbnb",
|
|
36
|
+
confidence: 0.94,
|
|
37
|
+
ambiguity: "",
|
|
38
|
+
suggestedCalendarId: "c5",
|
|
39
|
+
timeZone: "Europe/Lisbon",
|
|
40
|
+
zoneCertainty: "explicit",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handlers = {
|
|
44
|
+
onAdd: () => {},
|
|
45
|
+
onReview: () => {},
|
|
46
|
+
onDismiss: () => {},
|
|
47
|
+
onOpenThread: () => {},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A booking mail that gave every field it needed to. */
|
|
51
|
+
export const ReadCleanly: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<EventSuggestionCard
|
|
54
|
+
suggestion={base}
|
|
55
|
+
whenText="Friday 19 June – Monday 22 June"
|
|
56
|
+
{...handlers}
|
|
57
|
+
/>
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** What it could not settle is named, not smoothed over. */
|
|
62
|
+
export const WithAmbiguity: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<EventSuggestionCard
|
|
65
|
+
suggestion={{
|
|
66
|
+
...base,
|
|
67
|
+
id: "s2",
|
|
68
|
+
title: "Analytics pilot — first call",
|
|
69
|
+
allDay: false,
|
|
70
|
+
location: "",
|
|
71
|
+
threadSubject: "Following up: analytics pilot proposal",
|
|
72
|
+
sender: "Erik Wahlberg",
|
|
73
|
+
confidence: 0.38,
|
|
74
|
+
ambiguity:
|
|
75
|
+
'Asked for "some time Tuesday" and named no hour. Two Tuesdays fit.',
|
|
76
|
+
}}
|
|
77
|
+
whenText="Tuesday 16 June · 09:00 – 10:00"
|
|
78
|
+
{...handlers}
|
|
79
|
+
/>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The same card sized for a phone sheet. */
|
|
84
|
+
export const Touch: Story = {
|
|
85
|
+
render: () => (
|
|
86
|
+
<EventSuggestionCard
|
|
87
|
+
suggestion={base}
|
|
88
|
+
whenText="Friday 19 June – Monday 22 June"
|
|
89
|
+
touch
|
|
90
|
+
{...handlers}
|
|
91
|
+
/>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { AlertTriangle, Mail, Plus, SlidersHorizontal, X } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
import type { EventSuggestion } from "./calendar-types.js";
|
|
5
|
+
|
|
6
|
+
export interface EventSuggestionCardProps {
|
|
7
|
+
suggestion: EventSuggestion;
|
|
8
|
+
/** Already formatted by the caller. */
|
|
9
|
+
whenText: string;
|
|
10
|
+
onAdd: () => void;
|
|
11
|
+
onReview: () => void;
|
|
12
|
+
onDismiss: () => void;
|
|
13
|
+
onOpenThread: () => void;
|
|
14
|
+
touch?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Words for a number, so the card never shows a false 87%. */
|
|
19
|
+
function confidenceText(confidence: number): string {
|
|
20
|
+
if (confidence >= 0.85) return "Read cleanly";
|
|
21
|
+
if (confidence >= 0.6) return "Read with gaps";
|
|
22
|
+
return "Barely read";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A candidate, and unmistakably not an event. It lives off the grid, on a
|
|
27
|
+
* dashed card, and only a person pressing Add puts anything on the calendar —
|
|
28
|
+
* a machine reading of a mail never becomes a provisional block that someone
|
|
29
|
+
* has to notice and remove.
|
|
30
|
+
*/
|
|
31
|
+
export function EventSuggestionCard({
|
|
32
|
+
suggestion,
|
|
33
|
+
whenText,
|
|
34
|
+
onAdd,
|
|
35
|
+
onReview,
|
|
36
|
+
onDismiss,
|
|
37
|
+
onOpenThread,
|
|
38
|
+
touch,
|
|
39
|
+
className,
|
|
40
|
+
}: EventSuggestionCardProps) {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className={cn(
|
|
44
|
+
"flex flex-col gap-2 rounded-lg border border-dashed border-line-strong bg-surface-sunken p-3",
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div className="flex items-start gap-2">
|
|
49
|
+
<span className="min-w-0 flex-1">
|
|
50
|
+
<span className="block text-2xs uppercase tracking-wider text-fg-subtle">
|
|
51
|
+
Suggested from mail · {confidenceText(suggestion.confidence)}
|
|
52
|
+
</span>
|
|
53
|
+
<span className="block truncate text-sm font-medium text-fg">
|
|
54
|
+
{suggestion.title}
|
|
55
|
+
</span>
|
|
56
|
+
<span className="block text-xs text-fg-muted">{whenText}</span>
|
|
57
|
+
{suggestion.location !== "" && (
|
|
58
|
+
<span className="block truncate text-xs text-fg-muted">
|
|
59
|
+
{suggestion.location}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</span>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
aria-label="Dismiss suggestion"
|
|
66
|
+
onClick={onDismiss}
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex shrink-0 items-center justify-center rounded-md text-fg-subtle outline-none hover:bg-surface hover:text-fg focus-visible:ring-2 focus-visible:ring-ring",
|
|
69
|
+
touch ? "size-11" : "size-7",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<X className="size-4" />
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{suggestion.ambiguity !== "" && (
|
|
77
|
+
<p className="flex items-start gap-1.5 text-2xs text-warning">
|
|
78
|
+
<AlertTriangle className="mt-0.5 size-3 shrink-0" aria-hidden />
|
|
79
|
+
{suggestion.ambiguity}
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={onOpenThread}
|
|
86
|
+
className="flex w-full min-w-0 items-center gap-1.5 rounded-sm text-left text-2xs text-accent-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
87
|
+
>
|
|
88
|
+
<Mail className="size-3 shrink-0" />
|
|
89
|
+
<span className="truncate">
|
|
90
|
+
{suggestion.sender} — {suggestion.threadSubject}
|
|
91
|
+
</span>
|
|
92
|
+
</button>
|
|
93
|
+
|
|
94
|
+
<div className="flex items-center gap-2">
|
|
95
|
+
<Button
|
|
96
|
+
variant="primary"
|
|
97
|
+
size={touch ? "md" : "sm"}
|
|
98
|
+
icon={<Plus className="size-3.5" />}
|
|
99
|
+
onClick={onAdd}
|
|
100
|
+
className={touch ? "min-h-11 flex-1" : ""}
|
|
101
|
+
>
|
|
102
|
+
Add
|
|
103
|
+
</Button>
|
|
104
|
+
<Button
|
|
105
|
+
variant="secondary"
|
|
106
|
+
size={touch ? "md" : "sm"}
|
|
107
|
+
icon={<SlidersHorizontal className="size-3.5" />}
|
|
108
|
+
onClick={onReview}
|
|
109
|
+
className={touch ? "min-h-11" : ""}
|
|
110
|
+
>
|
|
111
|
+
Change first
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The full-screen flow chrome: a fixed header carrying back and cancel, one
|
|
3
|
+
* scrolling body, and a fixed footer in the thumb zone. Every multi-decision
|
|
4
|
+
* surface on a phone is this shape, so there is one implementation of it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ArrowLeft, X } from "lucide-react";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { cn } from "../lib/cn.js";
|
|
10
|
+
|
|
11
|
+
export interface FlowStepRailProps {
|
|
12
|
+
/** How many steps the flow walks. */
|
|
13
|
+
count: number;
|
|
14
|
+
/** Zero-based index of the step on screen. */
|
|
15
|
+
activeStep: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function FlowStepRail({ count, activeStep }: FlowStepRailProps) {
|
|
19
|
+
return (
|
|
20
|
+
<ol className="flex items-center gap-1.5" aria-label="Progress">
|
|
21
|
+
{Array.from({ length: count }, (_, i) => (
|
|
22
|
+
<li
|
|
23
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a rail segment is its position
|
|
24
|
+
key={i}
|
|
25
|
+
className="flex flex-1 items-center gap-1.5"
|
|
26
|
+
>
|
|
27
|
+
<span
|
|
28
|
+
className={cn(
|
|
29
|
+
"h-1 flex-1 rounded-full transition-colors",
|
|
30
|
+
i <= activeStep ? "bg-accent" : "bg-surface-sunken",
|
|
31
|
+
)}
|
|
32
|
+
aria-current={i === activeStep ? "step" : undefined}
|
|
33
|
+
/>
|
|
34
|
+
</li>
|
|
35
|
+
))}
|
|
36
|
+
</ol>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FlowScreenProps {
|
|
41
|
+
title: string;
|
|
42
|
+
subtitle?: string;
|
|
43
|
+
/** The step labels in order. One label means one page and no rail. */
|
|
44
|
+
steps: readonly string[];
|
|
45
|
+
activeStep: number;
|
|
46
|
+
onBack: () => void;
|
|
47
|
+
onExit: () => void;
|
|
48
|
+
/** Omitted where the screen is read rather than answered. */
|
|
49
|
+
footer?: ReactNode;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
/**
|
|
52
|
+
* `scroll` pads the body and scrolls it. `fill` hands the whole region to a
|
|
53
|
+
* child that carries its own header and scrolling.
|
|
54
|
+
*/
|
|
55
|
+
bodyFit?: "scroll" | "fill";
|
|
56
|
+
/**
|
|
57
|
+
* `viewport` covers the window and becomes a centred modal from 768px up.
|
|
58
|
+
* `container` fills the nearest positioned ancestor and stays full-bleed,
|
|
59
|
+
* for a surface that is already sized to a phone.
|
|
60
|
+
*/
|
|
61
|
+
anchor?: "viewport" | "container";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The body is the only scrolling region, so back and the footer's controls
|
|
66
|
+
* never leave the screen. The screen covers what opened it, which is why it
|
|
67
|
+
* takes the accessibility tree with it.
|
|
68
|
+
*/
|
|
69
|
+
export function FlowScreen({
|
|
70
|
+
title,
|
|
71
|
+
subtitle,
|
|
72
|
+
steps,
|
|
73
|
+
activeStep,
|
|
74
|
+
onBack,
|
|
75
|
+
onExit,
|
|
76
|
+
footer,
|
|
77
|
+
children,
|
|
78
|
+
anchor = "viewport",
|
|
79
|
+
bodyFit = "scroll",
|
|
80
|
+
}: FlowScreenProps) {
|
|
81
|
+
const contained = anchor === "container";
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
role="dialog"
|
|
85
|
+
aria-modal="true"
|
|
86
|
+
aria-label={title}
|
|
87
|
+
className={cn(
|
|
88
|
+
"z-50 flex flex-col font-sans text-fg",
|
|
89
|
+
contained
|
|
90
|
+
? "absolute inset-0"
|
|
91
|
+
: "fixed inset-0 md:items-center md:justify-center md:bg-black/40 md:p-6",
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<div
|
|
95
|
+
className={cn(
|
|
96
|
+
"flex min-h-0 w-full flex-1 flex-col bg-canvas",
|
|
97
|
+
!contained &&
|
|
98
|
+
"md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg",
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<header
|
|
102
|
+
className={cn(
|
|
103
|
+
"shrink-0 border-b border-line px-3 pb-2",
|
|
104
|
+
contained
|
|
105
|
+
? "pt-3"
|
|
106
|
+
: "pt-[calc(0.75rem+env(safe-area-inset-top,0px))] md:pt-3",
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<div className="flex items-center gap-1">
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={onBack}
|
|
113
|
+
aria-label="Back"
|
|
114
|
+
className="flex size-11 items-center justify-center rounded-md text-fg-muted"
|
|
115
|
+
>
|
|
116
|
+
<ArrowLeft className="size-5" />
|
|
117
|
+
</button>
|
|
118
|
+
<div className="min-w-0 flex-1 text-center">
|
|
119
|
+
<h1 className="truncate text-sm font-semibold">{title}</h1>
|
|
120
|
+
{subtitle && (
|
|
121
|
+
<p className="truncate text-2xs text-fg-muted">{subtitle}</p>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={onExit}
|
|
127
|
+
aria-label="Cancel"
|
|
128
|
+
className="flex size-11 items-center justify-center rounded-md text-fg-muted"
|
|
129
|
+
>
|
|
130
|
+
<X className="size-5" />
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
{steps.length > 1 && (
|
|
134
|
+
<div className="space-y-1 px-1 pt-2">
|
|
135
|
+
<FlowStepRail count={steps.length} activeStep={activeStep} />
|
|
136
|
+
<p className="text-2xs text-fg-subtle">
|
|
137
|
+
Step {activeStep + 1} of {steps.length} · {steps[activeStep]}
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</header>
|
|
142
|
+
|
|
143
|
+
<div
|
|
144
|
+
className={cn(
|
|
145
|
+
"min-h-0 flex-1",
|
|
146
|
+
bodyFit === "fill"
|
|
147
|
+
? "overflow-hidden"
|
|
148
|
+
: "overflow-y-auto px-4 py-4",
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{footer && (
|
|
155
|
+
<footer
|
|
156
|
+
className={cn(
|
|
157
|
+
"shrink-0 border-t border-line px-4 py-3",
|
|
158
|
+
contained
|
|
159
|
+
? ""
|
|
160
|
+
: "pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3",
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{footer}
|
|
164
|
+
</footer>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -152,13 +152,14 @@ export const WithSimilarMessages: Story = {
|
|
|
152
152
|
args: {
|
|
153
153
|
similarLinkComponent: ({
|
|
154
154
|
mailboxId,
|
|
155
|
+
threadId,
|
|
155
156
|
messageId,
|
|
156
157
|
className,
|
|
157
158
|
ariaLabel,
|
|
158
159
|
children,
|
|
159
160
|
}) => (
|
|
160
161
|
<a
|
|
161
|
-
href={`/mail/${mailboxId}
|
|
162
|
+
href={`/mail/${mailboxId}/${threadId}/${messageId}`}
|
|
162
163
|
className={className}
|
|
163
164
|
aria-label={ariaLabel}
|
|
164
165
|
>
|
|
@@ -171,6 +172,7 @@ export const WithSimilarMessages: Story = {
|
|
|
171
172
|
{
|
|
172
173
|
id: "msg-1",
|
|
173
174
|
mailboxId: "mbx-1",
|
|
175
|
+
threadId: "thr-msg-1",
|
|
174
176
|
fromName: "Alex Rivera",
|
|
175
177
|
subject: "Re: Q3 planning notes",
|
|
176
178
|
timeLabel: "Jan 17",
|
|
@@ -179,6 +181,7 @@ export const WithSimilarMessages: Story = {
|
|
|
179
181
|
{
|
|
180
182
|
id: "msg-2",
|
|
181
183
|
mailboxId: "mbx-1",
|
|
184
|
+
threadId: "thr-msg-2",
|
|
182
185
|
fromName: "Billing",
|
|
183
186
|
subject: "Your invoice is ready",
|
|
184
187
|
timeLabel: "Yesterday",
|
|
@@ -187,6 +190,7 @@ export const WithSimilarMessages: Story = {
|
|
|
187
190
|
{
|
|
188
191
|
id: "msg-3",
|
|
189
192
|
mailboxId: "mbx-2",
|
|
193
|
+
threadId: "thr-msg-3",
|
|
190
194
|
fromName: "",
|
|
191
195
|
subject: "(No subject)",
|
|
192
196
|
timeLabel: "Dec 4, 2024",
|
|
@@ -85,6 +85,8 @@ export interface SimilarMessageIntel {
|
|
|
85
85
|
id: string;
|
|
86
86
|
/** Mailbox the message lives in — the route param for opening it. */
|
|
87
87
|
mailboxId: string;
|
|
88
|
+
/** Conversation the message belongs to, which is what the reading pane opens. */
|
|
89
|
+
threadId: string;
|
|
88
90
|
fromName: string;
|
|
89
91
|
subject: string;
|
|
90
92
|
timeLabel: string;
|
|
@@ -99,6 +101,7 @@ export interface SimilarMessageIntel {
|
|
|
99
101
|
*/
|
|
100
102
|
export interface SimilarMessageLinkProps {
|
|
101
103
|
mailboxId: string;
|
|
104
|
+
threadId: string;
|
|
102
105
|
messageId: string;
|
|
103
106
|
className: string;
|
|
104
107
|
ariaLabel?: string;
|
|
@@ -540,6 +543,7 @@ export function IntelligencePanel({
|
|
|
540
543
|
{similarLinkComponent ? (
|
|
541
544
|
similarLinkComponent({
|
|
542
545
|
mailboxId: s.mailboxId,
|
|
546
|
+
threadId: s.threadId,
|
|
543
547
|
messageId: s.id,
|
|
544
548
|
className: rowClass,
|
|
545
549
|
ariaLabel,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useMatchMedia } from "../lib/use-match-media.js";
|
|
2
3
|
import { buildEmailSrcDoc, type EmailFrameVariant } from "./email-frame-css.js";
|
|
3
4
|
|
|
4
5
|
export interface IsolatedEmailFrameProps {
|
|
@@ -86,24 +87,6 @@ export const computeFitScale = (
|
|
|
86
87
|
return Math.max(MIN_SCALE, containerWidth / contentWidth);
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
const useMatchMedia = (query: string): boolean => {
|
|
90
|
-
const [matches, setMatches] = useState(() => {
|
|
91
|
-
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
92
|
-
return window.matchMedia(query).matches;
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
useEffect(() => {
|
|
96
|
-
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
97
|
-
const mql = window.matchMedia(query);
|
|
98
|
-
setMatches(mql.matches);
|
|
99
|
-
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
|
|
100
|
-
mql.addEventListener("change", handler);
|
|
101
|
-
return () => mql.removeEventListener("change", handler);
|
|
102
|
-
}, [query]);
|
|
103
|
-
|
|
104
|
-
return matches;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
90
|
/** Named (non-character) keys worth replaying: moving around and closing. */
|
|
108
91
|
const FORWARDED_NAMED_KEYS = new Set([
|
|
109
92
|
"Enter",
|