@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,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FolderInput,
|
|
3
|
+
Forward,
|
|
4
|
+
Reply,
|
|
5
|
+
ReplyAll,
|
|
6
|
+
Star,
|
|
7
|
+
Trash2,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { cn } from "../lib/cn.js";
|
|
11
|
+
import { Button } from "./button.js";
|
|
12
|
+
|
|
13
|
+
/** The verbs the reading-pane toolbar exposes. No archive: Remit is IMAP-backed
|
|
14
|
+
* and has no archive verb — the IMAP-native equivalent is move-to-folder. */
|
|
15
|
+
export type MailAction =
|
|
16
|
+
| "reply"
|
|
17
|
+
| "replyAll"
|
|
18
|
+
| "forward"
|
|
19
|
+
| "delete"
|
|
20
|
+
| "move"
|
|
21
|
+
| "flag";
|
|
22
|
+
|
|
23
|
+
export interface MailActionToolbarProps {
|
|
24
|
+
/** Whether a thread is open. Drives the no-op-explain behaviour, never `disabled`. */
|
|
25
|
+
hasThread: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Fired when an action button is pressed with no thread open. The host
|
|
28
|
+
* surfaces a one-line inline explanation — never a toast, never a disabled
|
|
29
|
+
* button (`doc/rules/ux.md`: never disable a control).
|
|
30
|
+
*/
|
|
31
|
+
onUnavailable?: (action: MailAction) => void;
|
|
32
|
+
/** A one-line inline notice rendered under the toolbar (e.g. "Open a message first"). */
|
|
33
|
+
unavailableHint?: ReactNode;
|
|
34
|
+
isStarred?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Whether to render the triage cluster (move-to-trash / move / flag).
|
|
37
|
+
* Defaults to `true` for the desktop reading-pane toolbar. The mobile pane
|
|
38
|
+
* sets it `false` because its management bar already owns triage — rendering
|
|
39
|
+
* both would create duplicate accessible names.
|
|
40
|
+
*/
|
|
41
|
+
showTriage?: boolean;
|
|
42
|
+
onReply?: () => void;
|
|
43
|
+
onReplyAll?: () => void;
|
|
44
|
+
onForward?: () => void;
|
|
45
|
+
onDelete?: () => void;
|
|
46
|
+
onToggleStar?: () => void;
|
|
47
|
+
/** Replaces the move button with a host-supplied trigger (e.g. the picker popover). */
|
|
48
|
+
moveSlot?: ReactNode;
|
|
49
|
+
onMove?: () => void;
|
|
50
|
+
replyTitle?: string;
|
|
51
|
+
replyAllTitle?: string;
|
|
52
|
+
forwardTitle?: string;
|
|
53
|
+
deleteTitle?: string;
|
|
54
|
+
flagTitle?: string;
|
|
55
|
+
/** Trailing content (search, compose, intelligence toggle, account menu). */
|
|
56
|
+
children?: ReactNode;
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The reading-pane action toolbar, shared between the remit-ui AppShell
|
|
62
|
+
* reference and the live client. Buttons are always pressable: with no thread
|
|
63
|
+
* open they no-op and call `onUnavailable(action)` so the host can explain why,
|
|
64
|
+
* rather than greying out (the never-disable tenet — a `disabled` button gives
|
|
65
|
+
* the user no path to learn what it does or what they must do first).
|
|
66
|
+
*/
|
|
67
|
+
export function MailActionToolbar({
|
|
68
|
+
hasThread,
|
|
69
|
+
onUnavailable,
|
|
70
|
+
unavailableHint,
|
|
71
|
+
isStarred,
|
|
72
|
+
showTriage = true,
|
|
73
|
+
onReply,
|
|
74
|
+
onReplyAll,
|
|
75
|
+
onForward,
|
|
76
|
+
onDelete,
|
|
77
|
+
onToggleStar,
|
|
78
|
+
moveSlot,
|
|
79
|
+
onMove,
|
|
80
|
+
replyTitle = "Reply (r)",
|
|
81
|
+
replyAllTitle = "Reply all (a)",
|
|
82
|
+
forwardTitle = "Forward (f)",
|
|
83
|
+
deleteTitle = "Move to Trash (#)",
|
|
84
|
+
flagTitle = "Flag (s)",
|
|
85
|
+
children,
|
|
86
|
+
className,
|
|
87
|
+
}: MailActionToolbarProps) {
|
|
88
|
+
const act = (action: MailAction, handler?: () => void) => () => {
|
|
89
|
+
if (!hasThread) {
|
|
90
|
+
onUnavailable?.(action);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
handler?.();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="relative">
|
|
98
|
+
<header
|
|
99
|
+
className={cn(
|
|
100
|
+
"flex h-pane-header shrink-0 items-center gap-1 border-b border-line bg-surface px-3",
|
|
101
|
+
className,
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<Button
|
|
105
|
+
variant="ghost"
|
|
106
|
+
size="sm"
|
|
107
|
+
icon={<Reply className="size-4" />}
|
|
108
|
+
title={replyTitle}
|
|
109
|
+
aria-label="Reply"
|
|
110
|
+
onClick={act("reply", onReply)}
|
|
111
|
+
/>
|
|
112
|
+
<Button
|
|
113
|
+
variant="ghost"
|
|
114
|
+
size="sm"
|
|
115
|
+
icon={<ReplyAll className="size-4" />}
|
|
116
|
+
title={replyAllTitle}
|
|
117
|
+
aria-label="Reply all"
|
|
118
|
+
onClick={act("replyAll", onReplyAll)}
|
|
119
|
+
/>
|
|
120
|
+
<Button
|
|
121
|
+
variant="ghost"
|
|
122
|
+
size="sm"
|
|
123
|
+
icon={<Forward className="size-4" />}
|
|
124
|
+
title={forwardTitle}
|
|
125
|
+
aria-label="Forward"
|
|
126
|
+
onClick={act("forward", onForward)}
|
|
127
|
+
/>
|
|
128
|
+
{showTriage && (
|
|
129
|
+
<>
|
|
130
|
+
<span className="mx-1 h-4 w-px bg-line" aria-hidden />
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="sm"
|
|
134
|
+
icon={<Trash2 className="size-4" />}
|
|
135
|
+
title={deleteTitle}
|
|
136
|
+
aria-label="Move to Trash"
|
|
137
|
+
onClick={act("delete", onDelete)}
|
|
138
|
+
/>
|
|
139
|
+
{moveSlot ?? (
|
|
140
|
+
<Button
|
|
141
|
+
variant="ghost"
|
|
142
|
+
size="sm"
|
|
143
|
+
icon={<FolderInput className="size-4" />}
|
|
144
|
+
title="Move to mailbox"
|
|
145
|
+
aria-label="Move to mailbox"
|
|
146
|
+
onClick={act("move", onMove)}
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
149
|
+
<Button
|
|
150
|
+
variant="ghost"
|
|
151
|
+
size="sm"
|
|
152
|
+
icon={
|
|
153
|
+
<Star
|
|
154
|
+
className={cn(
|
|
155
|
+
"size-4",
|
|
156
|
+
isStarred && "fill-warning text-warning",
|
|
157
|
+
)}
|
|
158
|
+
/>
|
|
159
|
+
}
|
|
160
|
+
title={flagTitle}
|
|
161
|
+
aria-label="Flag"
|
|
162
|
+
onClick={act("flag", onToggleStar)}
|
|
163
|
+
/>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
<div className="flex-1" />
|
|
167
|
+
{children}
|
|
168
|
+
</header>
|
|
169
|
+
{!hasThread && unavailableHint && (
|
|
170
|
+
// biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
|
|
171
|
+
<p
|
|
172
|
+
role="status"
|
|
173
|
+
className="border-b border-line bg-surface-sunken px-3 py-1 text-2xs text-fg-subtle"
|
|
174
|
+
>
|
|
175
|
+
{unavailableHint}
|
|
176
|
+
</p>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { MailHeader, type MailHeaderProps } from "./mail-header.js";
|
|
6
|
+
|
|
7
|
+
function render(overrides: Partial<MailHeaderProps> = {}): string {
|
|
8
|
+
return renderToString(
|
|
9
|
+
createElement(MailHeader, {
|
|
10
|
+
title: "Daily brief",
|
|
11
|
+
unreadCount: 15338,
|
|
12
|
+
isDesktop: false,
|
|
13
|
+
searchValue: "",
|
|
14
|
+
onSearchChange: () => undefined,
|
|
15
|
+
searchOpen: false,
|
|
16
|
+
onSearchOpenChange: () => undefined,
|
|
17
|
+
...overrides,
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("MailHeader", () => {
|
|
23
|
+
it("renders the title and a formatted unread count", () => {
|
|
24
|
+
const html = render();
|
|
25
|
+
assert.match(html, /Daily brief/);
|
|
26
|
+
assert.match(html, /15,338 unread/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders the menu (hamburger) control", () => {
|
|
30
|
+
assert.match(render(), /aria-label="Menu"/);
|
|
31
|
+
assert.match(render({ isDesktop: true }), /aria-label="Menu"/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("does not render an account chip row (accounts live in the filter / nav)", () => {
|
|
35
|
+
assert.doesNotMatch(render(), /aria-label="Filters"/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("shows the search magnifier and hides the input when collapsed", () => {
|
|
39
|
+
const html = render({ searchOpen: false });
|
|
40
|
+
assert.match(html, /aria-label="Search"/);
|
|
41
|
+
assert.doesNotMatch(html, /aria-label="Search mail"/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("expands the search input and offers a close control when open", () => {
|
|
45
|
+
const html = render({ searchOpen: true });
|
|
46
|
+
assert.match(html, /aria-label="Search mail"/);
|
|
47
|
+
assert.match(html, /aria-label="Close search"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("offers a single X when open with a query — close, not a separate inline clear", () => {
|
|
51
|
+
const html = render({ searchOpen: true, searchValue: "invoice" });
|
|
52
|
+
assert.match(html, /aria-label="Close search"/);
|
|
53
|
+
assert.doesNotMatch(html, /aria-label="Clear search"/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("expands the search bar when a query is active even if not explicitly opened", () => {
|
|
57
|
+
const html = render({ searchOpen: false, searchValue: "invoice" });
|
|
58
|
+
assert.match(html, /aria-label="Search mail"/);
|
|
59
|
+
assert.match(html, /aria-label="Close search"/);
|
|
60
|
+
assert.doesNotMatch(html, /aria-label="Search"/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders the search bar inline on desktop, keeping its own clear", () => {
|
|
64
|
+
const html = render({ isDesktop: true, searchValue: "invoice" });
|
|
65
|
+
assert.match(html, /aria-label="Search mail"/);
|
|
66
|
+
assert.match(html, /aria-label="Clear search"/);
|
|
67
|
+
assert.match(html, /Daily brief/);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
briefFilterConfig,
|
|
5
|
+
type FilterAccount,
|
|
6
|
+
type FilterPreset,
|
|
7
|
+
inboxFilterConfig,
|
|
8
|
+
} from "../filter-presets.js";
|
|
9
|
+
import { FilterSheet } from "./filter-sheet.js";
|
|
10
|
+
import { MailHeader } from "./mail-header.js";
|
|
11
|
+
|
|
12
|
+
const accounts: FilterAccount[] = [
|
|
13
|
+
{ id: "all", label: "All", active: true },
|
|
14
|
+
{ id: "personal", label: "Personal", count: 9 },
|
|
15
|
+
{ id: "work", label: "Work", count: 14 },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const phoneFrame: Decorator = (Story) => (
|
|
19
|
+
<div
|
|
20
|
+
className="overflow-hidden border border-line bg-canvas"
|
|
21
|
+
style={{ width: 390, height: 720 }}
|
|
22
|
+
>
|
|
23
|
+
<Story />
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
function MockList() {
|
|
28
|
+
const rows = [
|
|
29
|
+
["Priya Natarajan", "Q3 roadmap review — agenda + pre-read"],
|
|
30
|
+
["Mei Tan", "Sunday lunch?"],
|
|
31
|
+
["Linear", "Your receipt from Linear"],
|
|
32
|
+
["The Pragmatic Engineer", "Platform teams that scale"],
|
|
33
|
+
["Coolblue", "Tot 30% korting op monitoren"],
|
|
34
|
+
["GitHub", "[remit] PR #418: fix thread reconstruction"],
|
|
35
|
+
["Strava", "Jord gave you kudos on your morning ride"],
|
|
36
|
+
["Airbnb", "Your reservation in Lisbon is confirmed"],
|
|
37
|
+
];
|
|
38
|
+
return (
|
|
39
|
+
<ul className="divide-y divide-line">
|
|
40
|
+
{rows.map(([from, subject]) => (
|
|
41
|
+
<li key={subject} className="px-row-inset py-3">
|
|
42
|
+
<div className="text-sm font-medium text-fg">{from}</div>
|
|
43
|
+
<div className="truncate text-xs text-fg-muted">{subject}</div>
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The shared mail view: the MailHeader top row, then the FilterSheet bar (its
|
|
52
|
+
* caret opens the fuller filter over the list). The preset decides which groups
|
|
53
|
+
* the filter offers — brief vs inbox.
|
|
54
|
+
*/
|
|
55
|
+
function MailScreen({
|
|
56
|
+
title,
|
|
57
|
+
unreadCount,
|
|
58
|
+
preset,
|
|
59
|
+
initialExpanded = false,
|
|
60
|
+
initialSearchOpen = false,
|
|
61
|
+
initialSearchValue = "",
|
|
62
|
+
}: {
|
|
63
|
+
title: string;
|
|
64
|
+
unreadCount: number;
|
|
65
|
+
preset: FilterPreset;
|
|
66
|
+
initialExpanded?: boolean;
|
|
67
|
+
initialSearchOpen?: boolean;
|
|
68
|
+
initialSearchValue?: string;
|
|
69
|
+
}) {
|
|
70
|
+
const [searchValue, setSearchValue] = useState(initialSearchValue);
|
|
71
|
+
const [searchOpen, setSearchOpen] = useState(initialSearchOpen);
|
|
72
|
+
const [expanded, setExpanded] = useState(initialExpanded);
|
|
73
|
+
const [category, setCategory] = useState("all");
|
|
74
|
+
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
|
|
75
|
+
const [source, setSource] = useState("all");
|
|
76
|
+
|
|
77
|
+
const sources = preset.sources?.map((s) => ({
|
|
78
|
+
...s,
|
|
79
|
+
active: s.id === source,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex h-full flex-col">
|
|
84
|
+
<MailHeader
|
|
85
|
+
title={title}
|
|
86
|
+
unreadCount={unreadCount}
|
|
87
|
+
isDesktop={false}
|
|
88
|
+
onMenuClick={() => undefined}
|
|
89
|
+
searchValue={searchValue}
|
|
90
|
+
onSearchChange={setSearchValue}
|
|
91
|
+
searchOpen={searchOpen}
|
|
92
|
+
onSearchOpenChange={setSearchOpen}
|
|
93
|
+
/>
|
|
94
|
+
<div className="min-h-0 flex-1">
|
|
95
|
+
<FilterSheet
|
|
96
|
+
categories={preset.categories}
|
|
97
|
+
filters={preset.filters}
|
|
98
|
+
sources={sources}
|
|
99
|
+
selectedCategory={category}
|
|
100
|
+
activeFilters={activeFilters}
|
|
101
|
+
expanded={expanded}
|
|
102
|
+
onExpandedChange={setExpanded}
|
|
103
|
+
onSelectCategory={setCategory}
|
|
104
|
+
onSelectSource={setSource}
|
|
105
|
+
onToggleFilter={(id) =>
|
|
106
|
+
setActiveFilters((prev) => {
|
|
107
|
+
const next = new Set(prev);
|
|
108
|
+
if (next.has(id)) next.delete(id);
|
|
109
|
+
else next.add(id);
|
|
110
|
+
return next;
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
onClear={() => {
|
|
114
|
+
setCategory("all");
|
|
115
|
+
setActiveFilters(new Set());
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<MockList />
|
|
119
|
+
</FilterSheet>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const meta: Meta<typeof MailHeader> = {
|
|
126
|
+
title: "Screens/Kit/MailHeader",
|
|
127
|
+
component: MailHeader,
|
|
128
|
+
parameters: { layout: "centered" },
|
|
129
|
+
decorators: [phoneFrame],
|
|
130
|
+
};
|
|
131
|
+
export default meta;
|
|
132
|
+
|
|
133
|
+
type Story = StoryObj<typeof MailHeader>;
|
|
134
|
+
|
|
135
|
+
/** Brief, filter collapsed: header + the FilterSheet bar with its caret. */
|
|
136
|
+
export const BriefFilterCollapsed: Story = {
|
|
137
|
+
render: () => (
|
|
138
|
+
<MailScreen
|
|
139
|
+
title="Daily brief"
|
|
140
|
+
unreadCount={15338}
|
|
141
|
+
preset={briefFilterConfig(accounts)}
|
|
142
|
+
/>
|
|
143
|
+
),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Brief, filter expanded — categories + Unread/Has attachment/From contacts/Today,
|
|
148
|
+
* plus the accounts group because more than one account feeds the aggregate brief.
|
|
149
|
+
*/
|
|
150
|
+
export const BriefFilterExpanded: Story = {
|
|
151
|
+
render: () => (
|
|
152
|
+
<MailScreen
|
|
153
|
+
title="Daily brief"
|
|
154
|
+
unreadCount={15338}
|
|
155
|
+
preset={briefFilterConfig(accounts)}
|
|
156
|
+
initialExpanded
|
|
157
|
+
/>
|
|
158
|
+
),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Single account: the brief filter drops the accounts group entirely (one
|
|
163
|
+
* account is meaningless to segment) — categories + the brief chip set only.
|
|
164
|
+
*/
|
|
165
|
+
export const BriefFilterSingleAccount: Story = {
|
|
166
|
+
render: () => (
|
|
167
|
+
<MailScreen
|
|
168
|
+
title="Daily brief"
|
|
169
|
+
unreadCount={42}
|
|
170
|
+
preset={briefFilterConfig(accounts.slice(0, 1))}
|
|
171
|
+
initialExpanded
|
|
172
|
+
/>
|
|
173
|
+
),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Inbox, filter expanded — categories + Unread/Flagged/Has attachment. No
|
|
178
|
+
* accounts group: an inbox is already scoped to one account.
|
|
179
|
+
*/
|
|
180
|
+
export const InboxFilterExpanded: Story = {
|
|
181
|
+
render: () => (
|
|
182
|
+
<MailScreen
|
|
183
|
+
title="Inbox"
|
|
184
|
+
unreadCount={42}
|
|
185
|
+
preset={inboxFilterConfig()}
|
|
186
|
+
initialExpanded
|
|
187
|
+
/>
|
|
188
|
+
),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Mobile search collapsed to a magnifier in the header top row. */
|
|
192
|
+
export const MobileSearchCollapsed: Story = {
|
|
193
|
+
render: () => (
|
|
194
|
+
<MailScreen
|
|
195
|
+
title="Daily brief"
|
|
196
|
+
unreadCount={15338}
|
|
197
|
+
preset={briefFilterConfig(accounts)}
|
|
198
|
+
/>
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Mobile search expanded over the title. A single X owns the dismiss: it clears
|
|
204
|
+
* the query AND closes the search (the SearchBar's own inline clear is suppressed
|
|
205
|
+
* here, so there is exactly one X).
|
|
206
|
+
*/
|
|
207
|
+
export const MobileSearchExpanded: Story = {
|
|
208
|
+
render: () => (
|
|
209
|
+
<MailScreen
|
|
210
|
+
title="Daily brief"
|
|
211
|
+
unreadCount={15338}
|
|
212
|
+
preset={briefFilterConfig(accounts)}
|
|
213
|
+
initialSearchOpen
|
|
214
|
+
initialSearchValue="invoice"
|
|
215
|
+
/>
|
|
216
|
+
),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* An active query expands the bar on its own — even when search was never
|
|
221
|
+
* explicitly opened (e.g. returning from a result via Back). The query is
|
|
222
|
+
* visible, not hidden behind the collapsed magnifier.
|
|
223
|
+
*/
|
|
224
|
+
export const MobileSearchActiveQueryNotOpened: Story = {
|
|
225
|
+
render: () => (
|
|
226
|
+
<MailScreen
|
|
227
|
+
title="Daily brief"
|
|
228
|
+
unreadCount={15338}
|
|
229
|
+
preset={briefFilterConfig(accounts)}
|
|
230
|
+
initialSearchValue="invoice"
|
|
231
|
+
/>
|
|
232
|
+
),
|
|
233
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Menu, Search, X } from "lucide-react";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
import { SearchBar } from "./search-bar.js";
|
|
4
|
+
|
|
5
|
+
export interface MailHeaderProps {
|
|
6
|
+
/** Top-row title — the view name, e.g. "Daily brief" or "Inbox". */
|
|
7
|
+
title: string;
|
|
8
|
+
/** Unread count shown beside the title, e.g. 15338 → "15,338 unread". */
|
|
9
|
+
unreadCount: number;
|
|
10
|
+
/**
|
|
11
|
+
* Container-derived ≥1024 desktop tier. Desktop renders the search bar
|
|
12
|
+
* inline; below it search collapses to a magnifier that expands over the
|
|
13
|
+
* title (mirrors the live mobile header).
|
|
14
|
+
*/
|
|
15
|
+
isDesktop: boolean;
|
|
16
|
+
/** Opens the app navigation drawer (hamburger). */
|
|
17
|
+
onMenuClick?: () => void;
|
|
18
|
+
searchValue: string;
|
|
19
|
+
onSearchChange: (value: string) => void;
|
|
20
|
+
/** Full clear (X). Falls back to clearing the value. */
|
|
21
|
+
onSearchClear?: () => void;
|
|
22
|
+
/** Mobile only: whether the inline search is expanded over the title. */
|
|
23
|
+
searchOpen: boolean;
|
|
24
|
+
onSearchOpenChange: (open: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The shared mail header used across views — the daily brief and each inbox.
|
|
29
|
+
* Just the top row: hamburger + title + unread count + search. The view's
|
|
30
|
+
* filter (categories, attributes, and — for the aggregate brief — accounts)
|
|
31
|
+
* lives in the FilterSheet bar the consumer renders directly below this header;
|
|
32
|
+
* fast account switching is the nav sidebar, so there is no account chip row
|
|
33
|
+
* here. Search adapts to the tier — inline on desktop, a magnifier that expands
|
|
34
|
+
* the bar over the title on mobile. Open state is controlled so stories and the
|
|
35
|
+
* route can drive it.
|
|
36
|
+
*/
|
|
37
|
+
export function MailHeader({
|
|
38
|
+
title,
|
|
39
|
+
unreadCount,
|
|
40
|
+
isDesktop,
|
|
41
|
+
onMenuClick,
|
|
42
|
+
searchValue,
|
|
43
|
+
onSearchChange,
|
|
44
|
+
onSearchClear,
|
|
45
|
+
searchOpen,
|
|
46
|
+
onSearchOpenChange,
|
|
47
|
+
}: MailHeaderProps) {
|
|
48
|
+
const unreadLabel = `${unreadCount.toLocaleString()} unread`;
|
|
49
|
+
const clearSearch = onSearchClear ?? (() => onSearchChange(""));
|
|
50
|
+
const clearAndClose = () => {
|
|
51
|
+
clearSearch();
|
|
52
|
+
onSearchOpenChange(false);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const renderSearchBar = (showClearButton: boolean) => (
|
|
56
|
+
<SearchBar
|
|
57
|
+
value={searchValue}
|
|
58
|
+
onChange={onSearchChange}
|
|
59
|
+
onClear={clearSearch}
|
|
60
|
+
globalFocusKey={false}
|
|
61
|
+
showClearButton={showClearButton}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const menuButton = (
|
|
66
|
+
<Button
|
|
67
|
+
variant="ghost"
|
|
68
|
+
icon={<Menu className="size-5" />}
|
|
69
|
+
onClick={onMenuClick}
|
|
70
|
+
aria-label="Menu"
|
|
71
|
+
className="min-h-11 min-w-11 shrink-0 px-0"
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<header className="flex shrink-0 flex-col bg-canvas">
|
|
77
|
+
<div className="flex h-12 items-center gap-2 px-row-inset">
|
|
78
|
+
{isDesktop ? (
|
|
79
|
+
<>
|
|
80
|
+
{menuButton}
|
|
81
|
+
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
82
|
+
{title}
|
|
83
|
+
</h1>
|
|
84
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
85
|
+
{unreadLabel}
|
|
86
|
+
</span>
|
|
87
|
+
<div className="w-64 max-w-[40%] shrink-0">
|
|
88
|
+
{renderSearchBar(true)}
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
) : searchOpen || searchValue.trim().length > 0 ? (
|
|
92
|
+
<div className="flex flex-1 items-center gap-1">
|
|
93
|
+
<div className="flex-1">{renderSearchBar(false)}</div>
|
|
94
|
+
<Button
|
|
95
|
+
variant="ghost"
|
|
96
|
+
icon={<X className="size-5" />}
|
|
97
|
+
onClick={clearAndClose}
|
|
98
|
+
aria-label="Close search"
|
|
99
|
+
className="min-h-11 min-w-11 shrink-0 px-0"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
{menuButton}
|
|
105
|
+
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
106
|
+
{title}
|
|
107
|
+
</h1>
|
|
108
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
109
|
+
{unreadLabel}
|
|
110
|
+
</span>
|
|
111
|
+
<Button
|
|
112
|
+
variant="ghost"
|
|
113
|
+
icon={<Search className="size-5" />}
|
|
114
|
+
onClick={() => onSearchOpenChange(true)}
|
|
115
|
+
aria-label="Search"
|
|
116
|
+
className="min-h-11 min-w-11 shrink-0 px-0"
|
|
117
|
+
/>
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</header>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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 { MessageBodyView } from "./message-body-view.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `MessageBodyView` sanitizes via DOMPurify, which needs a DOM. Under SSR /
|
|
9
|
+
* plain-node (no DOM) the component cannot sanitize, so it never emits the email
|
|
10
|
+
* HTML — that guard is the security contract: unsanitizable mail is not
|
|
11
|
+
* rendered. These tests cover the DOM-free paths (empty + plain-text fallback +
|
|
12
|
+
* the no-DOM HTML guard); the sanitize/classify behavior is pinned by the
|
|
13
|
+
* sanitizer + render-treatment unit tests, and the live rendering by the
|
|
14
|
+
* Storybook stories.
|
|
15
|
+
*/
|
|
16
|
+
describe("MessageBodyView", () => {
|
|
17
|
+
it("renders the empty state when there is no html or text", () => {
|
|
18
|
+
const html = renderToString(createElement(MessageBodyView, {}));
|
|
19
|
+
assert.match(html, /This message has no body content\./);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders plain text in a pre block (no sanitizer needed)", () => {
|
|
23
|
+
const html = renderToString(
|
|
24
|
+
createElement(MessageBodyView, { text: "Just a plain note." }),
|
|
25
|
+
);
|
|
26
|
+
assert.match(html, /Just a plain note\./);
|
|
27
|
+
assert.match(html, /email-text/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not emit raw email HTML without a DOM to sanitize it", () => {
|
|
31
|
+
// No DOM ⇒ cannot DOMPurify ⇒ the component must NOT paint the HTML.
|
|
32
|
+
const html = renderToString(
|
|
33
|
+
createElement(MessageBodyView, {
|
|
34
|
+
html: "<p>secret newsletter body</p>",
|
|
35
|
+
category: "newsletter",
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
assert.doesNotMatch(html, /secret newsletter body/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to text when html cannot be sanitized (no DOM)", () => {
|
|
42
|
+
const html = renderToString(
|
|
43
|
+
createElement(MessageBodyView, {
|
|
44
|
+
html: "<p>html body</p>",
|
|
45
|
+
text: "plain fallback",
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
assert.doesNotMatch(html, /html body/);
|
|
49
|
+
assert.match(html, /plain fallback/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("applies the message-body wrapper class", () => {
|
|
53
|
+
const html = renderToString(
|
|
54
|
+
createElement(MessageBodyView, { text: "x", className: "px-4" }),
|
|
55
|
+
);
|
|
56
|
+
assert.match(html, /message-body/);
|
|
57
|
+
assert.match(html, /px-4/);
|
|
58
|
+
});
|
|
59
|
+
});
|