@remit/ui 0.0.1
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 +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- package/src/tokens.css +217 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Forward,
|
|
3
|
+
Mail,
|
|
4
|
+
MailOpen,
|
|
5
|
+
Reply,
|
|
6
|
+
ReplyAll,
|
|
7
|
+
Star,
|
|
8
|
+
Trash2,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { cn } from "../lib/cn.js";
|
|
12
|
+
import { Button } from "./button.js";
|
|
13
|
+
import { PopoverMenu, type PopoverMenuItem } from "./popover-menu.js";
|
|
14
|
+
|
|
15
|
+
export type MobileMessageAction =
|
|
16
|
+
| "reply"
|
|
17
|
+
| "replyAll"
|
|
18
|
+
| "forward"
|
|
19
|
+
| "star"
|
|
20
|
+
| "delete"
|
|
21
|
+
| "toggleRead";
|
|
22
|
+
|
|
23
|
+
export interface MobileMessageActionBarProps {
|
|
24
|
+
/** Whether a message is open. Gates the verbs (never disables). */
|
|
25
|
+
hasThread: boolean;
|
|
26
|
+
/* ---- Reply verbs ----
|
|
27
|
+
* Per-message: replying targets the message this bar belongs to. Omit a
|
|
28
|
+
* handler to drop that verb. */
|
|
29
|
+
onReply?: () => void;
|
|
30
|
+
onReplyAll?: () => void;
|
|
31
|
+
onForward?: () => void;
|
|
32
|
+
isStarred?: boolean;
|
|
33
|
+
onToggleStar?: () => void;
|
|
34
|
+
onDelete?: () => void;
|
|
35
|
+
isRead?: boolean;
|
|
36
|
+
onToggleRead?: () => void;
|
|
37
|
+
/**
|
|
38
|
+
* Move-to-folder trigger. The kit owns the bar layout; the app supplies the
|
|
39
|
+
* picker trigger so the kit needn't depend on folder data. Sized to match the
|
|
40
|
+
* bar's touch buttons by the caller.
|
|
41
|
+
*/
|
|
42
|
+
moveSlot?: ReactNode;
|
|
43
|
+
/** Extra overflow rows appended after mark read/unread. */
|
|
44
|
+
overflowItems?: PopoverMenuItem[];
|
|
45
|
+
/** Fired when a verb is pressed with no message open, so the host can explain. */
|
|
46
|
+
onUnavailable?: (action: MobileMessageAction) => void;
|
|
47
|
+
/** One-line inline notice rendered under the bar when no message is open. */
|
|
48
|
+
unavailableHint?: ReactNode;
|
|
49
|
+
className?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TOUCH = "min-h-11 min-w-11 px-0";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The per-message action bar for the narrow-width (single-pane) reading view.
|
|
56
|
+
* It belongs to one expanded message and owns that message's verbs — reply /
|
|
57
|
+
* reply-all / forward on the left, then star, move, delete and the overflow
|
|
58
|
+
* (mark read/unread) on the right — so reply targets this message and nothing
|
|
59
|
+
* repeats it. Intelligence is not here: it lives once in the top app bar and
|
|
60
|
+
* reflects the active message. No archive either — Remit is IMAP-backed and
|
|
61
|
+
* IMAP has no archive concept. Built from the kit `Button`, `PopoverMenu` and a
|
|
62
|
+
* caller-supplied `moveSlot`; buttons stay pressable with no message open and
|
|
63
|
+
* call `onUnavailable` rather than greying out.
|
|
64
|
+
*/
|
|
65
|
+
export function MobileMessageActionBar({
|
|
66
|
+
hasThread,
|
|
67
|
+
onReply,
|
|
68
|
+
onReplyAll,
|
|
69
|
+
onForward,
|
|
70
|
+
isStarred,
|
|
71
|
+
onToggleStar,
|
|
72
|
+
onDelete,
|
|
73
|
+
isRead,
|
|
74
|
+
onToggleRead,
|
|
75
|
+
moveSlot,
|
|
76
|
+
overflowItems = [],
|
|
77
|
+
onUnavailable,
|
|
78
|
+
unavailableHint,
|
|
79
|
+
className,
|
|
80
|
+
}: MobileMessageActionBarProps) {
|
|
81
|
+
const act = (action: MobileMessageAction, handler?: () => void) => () => {
|
|
82
|
+
if (!hasThread) {
|
|
83
|
+
onUnavailable?.(action);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
handler?.();
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const readItem: PopoverMenuItem[] = onToggleRead
|
|
90
|
+
? [
|
|
91
|
+
{
|
|
92
|
+
key: "toggle-read",
|
|
93
|
+
label: isRead ? "Mark as unread" : "Mark as read",
|
|
94
|
+
icon: isRead ? (
|
|
95
|
+
<Mail className="size-4" />
|
|
96
|
+
) : (
|
|
97
|
+
<MailOpen className="size-4" />
|
|
98
|
+
),
|
|
99
|
+
onSelect: act("toggleRead", onToggleRead),
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
: [];
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className={cn("relative", className)}>
|
|
106
|
+
<div className="flex h-12 shrink-0 items-center gap-0.5 border-y border-line bg-canvas px-1">
|
|
107
|
+
<Button
|
|
108
|
+
variant="ghost"
|
|
109
|
+
size="sm"
|
|
110
|
+
icon={<Reply className="size-5" />}
|
|
111
|
+
onClick={act("reply", onReply)}
|
|
112
|
+
aria-label="Reply"
|
|
113
|
+
title="Reply"
|
|
114
|
+
className={TOUCH}
|
|
115
|
+
/>
|
|
116
|
+
<Button
|
|
117
|
+
variant="ghost"
|
|
118
|
+
size="sm"
|
|
119
|
+
icon={<ReplyAll className="size-5" />}
|
|
120
|
+
onClick={act("replyAll", onReplyAll)}
|
|
121
|
+
aria-label="Reply all"
|
|
122
|
+
title="Reply all"
|
|
123
|
+
className={TOUCH}
|
|
124
|
+
/>
|
|
125
|
+
<Button
|
|
126
|
+
variant="ghost"
|
|
127
|
+
size="sm"
|
|
128
|
+
icon={<Forward className="size-5" />}
|
|
129
|
+
onClick={act("forward", onForward)}
|
|
130
|
+
aria-label="Forward"
|
|
131
|
+
title="Forward"
|
|
132
|
+
className={TOUCH}
|
|
133
|
+
/>
|
|
134
|
+
<div className="flex-1" />
|
|
135
|
+
<Button
|
|
136
|
+
variant="ghost"
|
|
137
|
+
size="sm"
|
|
138
|
+
icon={
|
|
139
|
+
<Star
|
|
140
|
+
className={cn("size-5", isStarred && "fill-warning text-warning")}
|
|
141
|
+
/>
|
|
142
|
+
}
|
|
143
|
+
onClick={act("star", onToggleStar)}
|
|
144
|
+
aria-label={isStarred ? "Remove flag" : "Flag"}
|
|
145
|
+
aria-pressed={isStarred}
|
|
146
|
+
title={isStarred ? "Remove flag" : "Flag"}
|
|
147
|
+
className={TOUCH}
|
|
148
|
+
/>
|
|
149
|
+
{moveSlot}
|
|
150
|
+
<Button
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="sm"
|
|
153
|
+
icon={<Trash2 className="size-5" />}
|
|
154
|
+
onClick={act("delete", onDelete)}
|
|
155
|
+
aria-label="Move to Trash"
|
|
156
|
+
title="Move to Trash"
|
|
157
|
+
className={TOUCH}
|
|
158
|
+
/>
|
|
159
|
+
<PopoverMenu
|
|
160
|
+
triggerLabel="More actions"
|
|
161
|
+
items={[...readItem, ...overflowItems]}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
{!hasThread && unavailableHint && (
|
|
165
|
+
// biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
|
|
166
|
+
<p
|
|
167
|
+
role="status"
|
|
168
|
+
className="border-b border-line bg-surface-sunken px-3 py-1 text-2xs text-fg-subtle"
|
|
169
|
+
>
|
|
170
|
+
{unavailableHint}
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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 { ThreadData } from "./app-shell-types.js";
|
|
6
|
+
import type { IntelligenceData } from "./intelligence-panel.js";
|
|
7
|
+
import { MobileReadingPane } from "./mobile-reading-pane.js";
|
|
8
|
+
|
|
9
|
+
const thread: ThreadData = {
|
|
10
|
+
subject: "Q3 planning notes",
|
|
11
|
+
messages: [
|
|
12
|
+
{
|
|
13
|
+
id: "m1",
|
|
14
|
+
fromName: "Alex Rivera",
|
|
15
|
+
fromEmail: "alex@example.com",
|
|
16
|
+
toLabel: "you",
|
|
17
|
+
dateLabel: "Mon 9:42",
|
|
18
|
+
snippet: "Here are the notes.",
|
|
19
|
+
bodyHtml: "<p>Here are the notes.</p>",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "m2",
|
|
23
|
+
fromName: "Jamie Chen",
|
|
24
|
+
fromEmail: "jamie@example.com",
|
|
25
|
+
toLabel: "you",
|
|
26
|
+
dateLabel: "Mon 11:15",
|
|
27
|
+
snippet: "Thanks.",
|
|
28
|
+
bodyHtml: "<p>Thanks.</p>",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "m3",
|
|
32
|
+
fromName: "Alex Rivera",
|
|
33
|
+
fromEmail: "alex@example.com",
|
|
34
|
+
toLabel: "you",
|
|
35
|
+
dateLabel: "Tue 08:03",
|
|
36
|
+
snippet: "One more thing.",
|
|
37
|
+
bodyHtml: "<p>One more thing.</p>",
|
|
38
|
+
expanded: true,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const intelligence: IntelligenceData = {
|
|
44
|
+
sender: {
|
|
45
|
+
name: "Alex Rivera",
|
|
46
|
+
email: "alex@example.com",
|
|
47
|
+
trust: "wellknown",
|
|
48
|
+
firstSeenLabel: "Jan 2025",
|
|
49
|
+
},
|
|
50
|
+
authenticity: {
|
|
51
|
+
verdict: "aligned",
|
|
52
|
+
fromDomain: "example.com",
|
|
53
|
+
summary: "Verified.",
|
|
54
|
+
},
|
|
55
|
+
category: { value: "Personal" },
|
|
56
|
+
similar: [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const countMatches = (html: string, needle: string) =>
|
|
60
|
+
html.split(needle).length - 1;
|
|
61
|
+
|
|
62
|
+
describe("MobileReadingPane", () => {
|
|
63
|
+
it("renders the top bar with back, subject and the intelligence toggle", () => {
|
|
64
|
+
const html = renderToString(
|
|
65
|
+
createElement(MobileReadingPane, {
|
|
66
|
+
thread,
|
|
67
|
+
intelligence,
|
|
68
|
+
onBack: () => undefined,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
assert.match(html, /aria-label="Back to messages"/);
|
|
72
|
+
assert.match(html, /Q3 planning notes/);
|
|
73
|
+
assert.match(html, /aria-label="Show intelligence panel"/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("omits the intelligence toggle when no intelligence is given", () => {
|
|
77
|
+
const html = renderToString(
|
|
78
|
+
createElement(MobileReadingPane, { thread, onBack: () => undefined }),
|
|
79
|
+
);
|
|
80
|
+
assert.doesNotMatch(html, /aria-label="Show intelligence panel"/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows exactly one per-message action bar — the expanded message's", () => {
|
|
84
|
+
const html = renderToString(
|
|
85
|
+
createElement(MobileReadingPane, { thread, onBack: () => undefined }),
|
|
86
|
+
);
|
|
87
|
+
// One expanded message ⇒ one Reply / Flag / Trash cluster; the two
|
|
88
|
+
// collapsed rows carry no bar.
|
|
89
|
+
assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
|
|
90
|
+
assert.equal(countMatches(html, 'aria-label="Flag"'), 1);
|
|
91
|
+
assert.equal(countMatches(html, 'aria-label="Move to Trash"'), 1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("has no thread-level reply footer (reply belongs to the message)", () => {
|
|
95
|
+
// The desktop/legacy footer carries a "Reply (r)" title; the per-message
|
|
96
|
+
// bar titles are plain "Reply". Assert the footer affordance is absent.
|
|
97
|
+
const html = renderToString(
|
|
98
|
+
createElement(MobileReadingPane, { thread, onBack: () => undefined }),
|
|
99
|
+
);
|
|
100
|
+
assert.doesNotMatch(html, /title="Reply \(r\)"/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { ArrowLeft, Info } from "lucide-react";
|
|
2
|
+
import { type HTMLAttributes, type ReactNode, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import type { ThreadData } from "./app-shell-types.js";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { Dialog } from "./dialog.js";
|
|
7
|
+
import {
|
|
8
|
+
type IntelligenceData,
|
|
9
|
+
IntelligencePanel,
|
|
10
|
+
} from "./intelligence-panel.js";
|
|
11
|
+
import { MobileMessageActionBar } from "./mobile-message-action-bar.js";
|
|
12
|
+
import { CollapsedMessage, ExpandedMessage } from "./reading-pane.js";
|
|
13
|
+
|
|
14
|
+
export interface MobileReadingMessageActions {
|
|
15
|
+
/** Expand / collapse a message row. */
|
|
16
|
+
onToggleExpand?: (id: string) => void;
|
|
17
|
+
onReply?: (id: string) => void;
|
|
18
|
+
onReplyAll?: (id: string) => void;
|
|
19
|
+
onForward?: (id: string) => void;
|
|
20
|
+
onToggleStar?: (id: string) => void;
|
|
21
|
+
onDelete?: (id: string) => void;
|
|
22
|
+
onToggleRead?: (id: string) => void;
|
|
23
|
+
/** Per-message move-to-folder trigger (the app supplies the folder picker). */
|
|
24
|
+
moveSlot?: (id: string) => ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MobileReadingPaneProps {
|
|
28
|
+
thread: ThreadData;
|
|
29
|
+
/** Back to the message list. */
|
|
30
|
+
onBack: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Intelligence for the active (expanded) message. Drives the single top-bar
|
|
33
|
+
* toggle; in a multi-message thread the caller passes the data for whichever
|
|
34
|
+
* message is currently expanded. Omit to hide the toggle.
|
|
35
|
+
*/
|
|
36
|
+
intelligence?: IntelligenceData;
|
|
37
|
+
/** Controlled open state for the intelligence sheet. Omit for internal state. */
|
|
38
|
+
intelligenceOpen?: boolean;
|
|
39
|
+
onToggleIntelligence?: () => void;
|
|
40
|
+
actions?: MobileReadingMessageActions;
|
|
41
|
+
/**
|
|
42
|
+
* Live message content. When provided it replaces the static
|
|
43
|
+
* `thread.messages` rendering so the app can inject its own message cards
|
|
44
|
+
* (each expanded card owns its `MobileMessageActionBar`). `thread.subject`
|
|
45
|
+
* still drives the top bar.
|
|
46
|
+
*/
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
/** Touch handlers attached to the scroll area (swipe-between-messages). */
|
|
49
|
+
touchHandlers?: HTMLAttributes<HTMLDivElement>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The narrow-width (single-pane) mobile reading view, and the one shared shell
|
|
54
|
+
* every mobile reading mock composes. The chrome is fixed: a top app bar with
|
|
55
|
+
* back, the email subject and — its only other control — the intelligence
|
|
56
|
+
* toggle for the active message. Each expanded message carries its own
|
|
57
|
+
* `MobileMessageActionBar` (reply / star / move / delete / overflow); collapsed
|
|
58
|
+
* rows carry none. There is no thread-level reply footer — reply belongs to the
|
|
59
|
+
* message it answers. Feed it a `thread` and per-message `actions`; it owns the
|
|
60
|
+
* layout so no mock hand-rolls it.
|
|
61
|
+
*/
|
|
62
|
+
export function MobileReadingPane({
|
|
63
|
+
thread,
|
|
64
|
+
onBack,
|
|
65
|
+
intelligence,
|
|
66
|
+
intelligenceOpen,
|
|
67
|
+
onToggleIntelligence,
|
|
68
|
+
actions,
|
|
69
|
+
children,
|
|
70
|
+
touchHandlers,
|
|
71
|
+
}: MobileReadingPaneProps) {
|
|
72
|
+
const [localIntelligenceOpen, setLocalIntelligenceOpen] = useState(false);
|
|
73
|
+
const controlled = onToggleIntelligence !== undefined;
|
|
74
|
+
const isOpen = controlled
|
|
75
|
+
? (intelligenceOpen ?? false)
|
|
76
|
+
: localIntelligenceOpen;
|
|
77
|
+
const showIntelligenceToggle = Boolean(intelligence) || controlled;
|
|
78
|
+
|
|
79
|
+
const toggleIntelligence = () => {
|
|
80
|
+
if (controlled) {
|
|
81
|
+
onToggleIntelligence?.();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setLocalIntelligenceOpen((value) => !value);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const closeIntelligence = () => {
|
|
88
|
+
if (controlled) {
|
|
89
|
+
if (isOpen) onToggleIntelligence?.();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setLocalIntelligenceOpen(false);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<article className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
97
|
+
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line bg-surface px-row-inset">
|
|
98
|
+
<Button
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="sm"
|
|
101
|
+
icon={<ArrowLeft className="size-4" />}
|
|
102
|
+
onClick={onBack}
|
|
103
|
+
aria-label="Back to messages"
|
|
104
|
+
className="-ml-1 shrink-0"
|
|
105
|
+
/>
|
|
106
|
+
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
107
|
+
{thread.subject}
|
|
108
|
+
</h2>
|
|
109
|
+
{showIntelligenceToggle && (
|
|
110
|
+
<Button
|
|
111
|
+
variant="ghost"
|
|
112
|
+
size="sm"
|
|
113
|
+
icon={<Info className="size-4" />}
|
|
114
|
+
onClick={toggleIntelligence}
|
|
115
|
+
aria-label={
|
|
116
|
+
isOpen ? "Hide intelligence panel" : "Show intelligence panel"
|
|
117
|
+
}
|
|
118
|
+
aria-pressed={isOpen}
|
|
119
|
+
title="Intelligence"
|
|
120
|
+
className={cn(
|
|
121
|
+
"shrink-0",
|
|
122
|
+
isOpen && "bg-accent-2-soft text-accent-2",
|
|
123
|
+
)}
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
</header>
|
|
127
|
+
|
|
128
|
+
<div
|
|
129
|
+
className="flex-1 overflow-y-auto"
|
|
130
|
+
style={touchHandlers ? { touchAction: "pan-y" } : undefined}
|
|
131
|
+
{...touchHandlers}
|
|
132
|
+
>
|
|
133
|
+
{children ??
|
|
134
|
+
thread.messages.map((message) => {
|
|
135
|
+
const bind = (handler?: (id: string) => void) =>
|
|
136
|
+
handler ? () => handler(message.id) : undefined;
|
|
137
|
+
|
|
138
|
+
if (!message.expanded) {
|
|
139
|
+
return (
|
|
140
|
+
<CollapsedMessage
|
|
141
|
+
key={message.id}
|
|
142
|
+
message={message}
|
|
143
|
+
onClick={bind(actions?.onToggleExpand)}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<ExpandedMessage
|
|
150
|
+
key={message.id}
|
|
151
|
+
message={message}
|
|
152
|
+
warning={thread.warning}
|
|
153
|
+
onHeaderClick={bind(actions?.onToggleExpand)}
|
|
154
|
+
actionBar={
|
|
155
|
+
<MobileMessageActionBar
|
|
156
|
+
hasThread
|
|
157
|
+
onReply={bind(actions?.onReply)}
|
|
158
|
+
onReplyAll={bind(actions?.onReplyAll)}
|
|
159
|
+
onForward={bind(actions?.onForward)}
|
|
160
|
+
onToggleStar={bind(actions?.onToggleStar)}
|
|
161
|
+
onDelete={bind(actions?.onDelete)}
|
|
162
|
+
onToggleRead={bind(actions?.onToggleRead)}
|
|
163
|
+
moveSlot={actions?.moveSlot?.(message.id)}
|
|
164
|
+
/>
|
|
165
|
+
}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{intelligence && !controlled && (
|
|
172
|
+
<Dialog
|
|
173
|
+
open={isOpen}
|
|
174
|
+
onClose={closeIntelligence}
|
|
175
|
+
title="Intelligence"
|
|
176
|
+
anchor="right"
|
|
177
|
+
className="p-0"
|
|
178
|
+
>
|
|
179
|
+
<IntelligencePanel
|
|
180
|
+
data={intelligence}
|
|
181
|
+
onClose={closeIntelligence}
|
|
182
|
+
className="h-full w-full border-l-0"
|
|
183
|
+
/>
|
|
184
|
+
</Dialog>
|
|
185
|
+
)}
|
|
186
|
+
</article>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
briefFilterConfig,
|
|
5
|
+
type FilterAccount,
|
|
6
|
+
inboxFilterConfig,
|
|
7
|
+
} from "../filter-presets.js";
|
|
8
|
+
import { MobileSearchView } from "./mobile-search-view.js";
|
|
9
|
+
import type { SearchResult } from "./search-result-row.js";
|
|
10
|
+
import type { SearchResultSection } from "./search-results.js";
|
|
11
|
+
|
|
12
|
+
const phoneFrame: Decorator = (Story) => (
|
|
13
|
+
<div
|
|
14
|
+
className="overflow-hidden rounded-lg border border-line"
|
|
15
|
+
style={{ width: 390, height: 720 }}
|
|
16
|
+
>
|
|
17
|
+
<Story />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const accounts: FilterAccount[] = [
|
|
22
|
+
{ id: "personal", label: "matthijs@", count: 42, active: true },
|
|
23
|
+
{ id: "work", label: "work@acme", count: 17 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const recentSearches = ["invoice march", "from: stripe", "flight confirmation"];
|
|
27
|
+
|
|
28
|
+
const topMatches: SearchResult[] = [
|
|
29
|
+
{
|
|
30
|
+
id: "r1",
|
|
31
|
+
sender: "Stripe",
|
|
32
|
+
subject: "Your invoice for March is ready",
|
|
33
|
+
snippet: "Invoice #4821 — €149.00 paid on Visa ending 4242.",
|
|
34
|
+
date: "9:42",
|
|
35
|
+
unread: true,
|
|
36
|
+
category: { label: "Receipt", tone: "positive" },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "r2",
|
|
40
|
+
sender: "Hetzner Online",
|
|
41
|
+
subject: "Invoice 2026-03 available in your account",
|
|
42
|
+
snippet: "Dear customer, your invoice for the period is attached.",
|
|
43
|
+
date: "Mar 3",
|
|
44
|
+
category: { label: "Finance", tone: "accent" },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "r3",
|
|
48
|
+
sender: "Anna de Vries",
|
|
49
|
+
subject: "Re: Q1 invoice approval",
|
|
50
|
+
snippet: "Approved — can you forward the PDF invoice to finance?",
|
|
51
|
+
date: "Mar 1",
|
|
52
|
+
flagged: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "r4",
|
|
56
|
+
sender: "AWS Billing",
|
|
57
|
+
subject: "Your invoice is now available",
|
|
58
|
+
snippet: "Your total for February was $312.55 across 6 services.",
|
|
59
|
+
date: "Feb 28",
|
|
60
|
+
category: { label: "Finance", tone: "accent" },
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Semantic "Related" hits carry their own thread + mailbox so they open directly
|
|
65
|
+
// — the matching message often lives outside the loaded list.
|
|
66
|
+
const related: SearchResult[] = [
|
|
67
|
+
{
|
|
68
|
+
id: "r5",
|
|
69
|
+
sender: "QuickBooks",
|
|
70
|
+
subject: "Reminder: 2 invoices awaiting payment",
|
|
71
|
+
snippet: "You have outstanding invoices totalling €430.00.",
|
|
72
|
+
date: "Feb 20",
|
|
73
|
+
category: { label: "Reminder", tone: "warning" },
|
|
74
|
+
threadId: "thread-quickbooks",
|
|
75
|
+
mailboxId: "mailbox-personal",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "r6",
|
|
79
|
+
sender: "noreply@vendor.io",
|
|
80
|
+
subject: "Overdue invoice notice",
|
|
81
|
+
snippet: "This invoice is now 14 days overdue. Please remit payment.",
|
|
82
|
+
date: "Feb 12",
|
|
83
|
+
category: { label: "Overdue", tone: "danger" },
|
|
84
|
+
threadId: "thread-vendor",
|
|
85
|
+
mailboxId: "mailbox-personal",
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const resultSections: SearchResultSection[] = [
|
|
90
|
+
{ id: "top", label: "Top matches", results: topMatches },
|
|
91
|
+
{ id: "related", label: "Related", results: related },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const emptySections: SearchResultSection[] = [
|
|
95
|
+
{ id: "top", label: "Top matches", results: [] },
|
|
96
|
+
{ id: "related", label: "Related", results: [] },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
type Preset = "brief" | "inbox";
|
|
100
|
+
|
|
101
|
+
function Harness({
|
|
102
|
+
initialValue = "",
|
|
103
|
+
loading,
|
|
104
|
+
sections,
|
|
105
|
+
preset,
|
|
106
|
+
}: {
|
|
107
|
+
initialValue?: string;
|
|
108
|
+
loading?: boolean;
|
|
109
|
+
sections?: SearchResultSection[];
|
|
110
|
+
preset: Preset;
|
|
111
|
+
}) {
|
|
112
|
+
const [value, setValue] = useState(initialValue);
|
|
113
|
+
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
114
|
+
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
|
|
115
|
+
const [activeSource, setActiveSource] = useState("personal");
|
|
116
|
+
const [expanded, setExpanded] = useState(false);
|
|
117
|
+
const [opened, setOpened] = useState<SearchResult | null>(null);
|
|
118
|
+
|
|
119
|
+
const base =
|
|
120
|
+
preset === "brief"
|
|
121
|
+
? briefFilterConfig(
|
|
122
|
+
accounts.map((account) => ({
|
|
123
|
+
...account,
|
|
124
|
+
active: account.id === activeSource,
|
|
125
|
+
})),
|
|
126
|
+
)
|
|
127
|
+
: inboxFilterConfig();
|
|
128
|
+
|
|
129
|
+
if (opened) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="flex h-full flex-col items-center justify-center gap-2 bg-canvas p-6 text-center text-sm">
|
|
132
|
+
<p className="font-semibold text-fg">Opened conversation</p>
|
|
133
|
+
<p className="text-fg-muted">{opened.subject}</p>
|
|
134
|
+
<p className="text-2xs text-fg-subtle">
|
|
135
|
+
thread {opened.threadId ?? "(none)"} · mailbox{" "}
|
|
136
|
+
{opened.mailboxId ?? "(none)"}
|
|
137
|
+
</p>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
className="mt-2 text-2xs text-accent underline"
|
|
141
|
+
onClick={() => setOpened(null)}
|
|
142
|
+
>
|
|
143
|
+
Back to search
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<MobileSearchView
|
|
151
|
+
value={value}
|
|
152
|
+
onChange={setValue}
|
|
153
|
+
onClear={() => setValue("")}
|
|
154
|
+
onCancel={() => undefined}
|
|
155
|
+
filter={{
|
|
156
|
+
...base,
|
|
157
|
+
selectedCategory,
|
|
158
|
+
activeFilters,
|
|
159
|
+
expanded,
|
|
160
|
+
onExpandedChange: setExpanded,
|
|
161
|
+
onSelectCategory: setSelectedCategory,
|
|
162
|
+
onSelectSource: setActiveSource,
|
|
163
|
+
onToggleFilter: (id) =>
|
|
164
|
+
setActiveFilters((current) => {
|
|
165
|
+
const next = new Set(current);
|
|
166
|
+
if (next.has(id)) next.delete(id);
|
|
167
|
+
else next.add(id);
|
|
168
|
+
return next;
|
|
169
|
+
}),
|
|
170
|
+
onClear: () => {
|
|
171
|
+
setSelectedCategory("all");
|
|
172
|
+
setActiveFilters(new Set());
|
|
173
|
+
},
|
|
174
|
+
}}
|
|
175
|
+
recentSearches={recentSearches}
|
|
176
|
+
onPickRecent={setValue}
|
|
177
|
+
sections={sections}
|
|
178
|
+
loading={loading}
|
|
179
|
+
onSelectResult={setOpened}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const meta: Meta<typeof MobileSearchView> = {
|
|
185
|
+
title: "Kit/MobileSearchView",
|
|
186
|
+
component: MobileSearchView,
|
|
187
|
+
parameters: { layout: "centered" },
|
|
188
|
+
decorators: [phoneFrame],
|
|
189
|
+
};
|
|
190
|
+
export default meta;
|
|
191
|
+
|
|
192
|
+
type Story = StoryObj<typeof MobileSearchView>;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Global search — the daily-brief preset, so the FilterSheet carries the account
|
|
196
|
+
* source row (matthijs@ / work@acme) on top of the shared categories + filters.
|
|
197
|
+
* The header carries a single X that clears the query AND dismisses the takeover.
|
|
198
|
+
*/
|
|
199
|
+
export const GlobalSearch: Story = {
|
|
200
|
+
render: () => (
|
|
201
|
+
<Harness initialValue="invoice" sections={resultSections} preset="brief" />
|
|
202
|
+
),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Scoped search — a single inbox, so the inbox preset drops the account row
|
|
207
|
+
* entirely (the view is already scoped); same categories and filters otherwise.
|
|
208
|
+
*/
|
|
209
|
+
export const ScopedSearch: Story = {
|
|
210
|
+
render: () => (
|
|
211
|
+
<Harness initialValue="invoice" sections={resultSections} preset="inbox" />
|
|
212
|
+
),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Empty query: recent searches under the brief filter chrome. */
|
|
216
|
+
export const Idle: Story = {
|
|
217
|
+
render: () => <Harness preset="brief" />,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** A query that matches nothing. */
|
|
221
|
+
export const NoResults: Story = {
|
|
222
|
+
render: () => (
|
|
223
|
+
<Harness initialValue="asdfqwer" sections={emptySections} preset="brief" />
|
|
224
|
+
),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Results still loading. */
|
|
228
|
+
export const Loading: Story = {
|
|
229
|
+
render: () => <Harness initialValue="invoice" loading preset="brief" />,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Selecting a "Related" (semantic) hit. These rows carry their own thread +
|
|
234
|
+
* mailbox, so tapping one opens the conversation directly — even though the
|
|
235
|
+
* matching message lives outside the loaded list. Tap a row under "Related" to
|
|
236
|
+
* see the thread + mailbox the result hands the app to open. Regression cover for
|
|
237
|
+
* the brief bug where a tapped related result selected nothing.
|
|
238
|
+
*/
|
|
239
|
+
export const RelatedSelectable: Story = {
|
|
240
|
+
render: () => (
|
|
241
|
+
<Harness
|
|
242
|
+
initialValue="invoice"
|
|
243
|
+
sections={[{ id: "related", label: "Related", results: related }]}
|
|
244
|
+
preset="brief"
|
|
245
|
+
/>
|
|
246
|
+
),
|
|
247
|
+
};
|