@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,415 @@
|
|
|
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 { AppShell } from "./app-shell.js";
|
|
6
|
+
import type { AppShellProps, ThreadSection } from "./app-shell-types.js";
|
|
7
|
+
import { commitPeek } from "./swipeable-row.js";
|
|
8
|
+
|
|
9
|
+
const baseAccounts: AppShellProps["accounts"] = [
|
|
10
|
+
{
|
|
11
|
+
id: "acc",
|
|
12
|
+
label: "Personal",
|
|
13
|
+
email: "me@example.com",
|
|
14
|
+
mailboxes: [{ id: "mbx_inbox", name: "Inbox", unseen: 2 }],
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const labelledSection: ThreadSection = {
|
|
19
|
+
id: "sec",
|
|
20
|
+
label: "Today",
|
|
21
|
+
threads: [
|
|
22
|
+
{
|
|
23
|
+
id: "t1",
|
|
24
|
+
accountId: "acc",
|
|
25
|
+
fromName: "Ada Lovelace",
|
|
26
|
+
fromEmail: "ada@example.com",
|
|
27
|
+
subject: "Hello",
|
|
28
|
+
snippet: "First message",
|
|
29
|
+
timeLabel: "09:00",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const render = (overrides: Partial<AppShellProps>) =>
|
|
35
|
+
renderToString(
|
|
36
|
+
createElement(AppShell, {
|
|
37
|
+
accounts: baseAccounts,
|
|
38
|
+
selectedNavId: "mbx_inbox",
|
|
39
|
+
listTitle: "Inbox",
|
|
40
|
+
sections: [labelledSection],
|
|
41
|
+
...overrides,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const intelligence: AppShellProps["intelligence"] = {
|
|
46
|
+
sender: {
|
|
47
|
+
name: "Ada Lovelace",
|
|
48
|
+
email: "ada@example.com",
|
|
49
|
+
trust: "wellknown",
|
|
50
|
+
firstSeenLabel: "Jan 2025",
|
|
51
|
+
},
|
|
52
|
+
authenticity: {
|
|
53
|
+
verdict: "aligned",
|
|
54
|
+
fromDomain: "example.com",
|
|
55
|
+
summary: "Signed by example.com, matches the sender.",
|
|
56
|
+
},
|
|
57
|
+
category: { value: "personal" },
|
|
58
|
+
similar: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const thread: AppShellProps["thread"] = {
|
|
62
|
+
subject: "Hello",
|
|
63
|
+
messages: [
|
|
64
|
+
{
|
|
65
|
+
id: "m1",
|
|
66
|
+
fromName: "Ada Lovelace",
|
|
67
|
+
fromEmail: "ada@example.com",
|
|
68
|
+
toLabel: "me",
|
|
69
|
+
dateLabel: "09:00",
|
|
70
|
+
snippet: "First message",
|
|
71
|
+
bodyHtml: "<p>First message</p>",
|
|
72
|
+
expanded: true,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Section labels are a desktop-list affordance; the narrow touch surface is a
|
|
78
|
+
// single flat list (see the narrow-view block below). The shell reflows by its
|
|
79
|
+
// OWN width now, seeded for SSR via `initialWidth`, so these seed the container
|
|
80
|
+
// to desktop to exercise the sectioned-vs-flat distinction itself.
|
|
81
|
+
describe("AppShell flat list (#5/#7)", () => {
|
|
82
|
+
it("renders section labels in the default sectioned list", () => {
|
|
83
|
+
const html = render({ initialWidth: 1100 });
|
|
84
|
+
assert.match(html, /Today/, "sectioned list shows its label");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("suppresses section labels in a flat mailbox", () => {
|
|
88
|
+
const html = render({ flatList: true, initialWidth: 1100 });
|
|
89
|
+
assert.doesNotMatch(
|
|
90
|
+
html,
|
|
91
|
+
/>Today</,
|
|
92
|
+
"flat list is one continuous list, no section headers",
|
|
93
|
+
);
|
|
94
|
+
assert.match(html, /Ada Lovelace/, "rows still render");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pane-count-by-width is owned by `resolvePaneLayout` — see
|
|
100
|
+
* pane-layout.render.test.ts for the boundary cases. This block only confirms
|
|
101
|
+
* the rendered shell *wires to* that helper: the reading pane and intelligence
|
|
102
|
+
* rail actually appear/disappear in the HTML as the seeded container width
|
|
103
|
+
* crosses the two thresholds (no duplicate enumeration of the boundaries).
|
|
104
|
+
*/
|
|
105
|
+
describe("AppShell render honors the pane layout (#784)", () => {
|
|
106
|
+
const readingPaneProps: Partial<AppShellProps> = {
|
|
107
|
+
flatList: true,
|
|
108
|
+
selectedThreadId: "m1",
|
|
109
|
+
thread,
|
|
110
|
+
intelligence,
|
|
111
|
+
};
|
|
112
|
+
// The reading pane's toolbar is the only place "Search mail" renders.
|
|
113
|
+
const readingPaneMarker = /Search mail/;
|
|
114
|
+
// "Known sender" is the wellknown trust label, rendered only by the rail.
|
|
115
|
+
const intelligenceMarker = /Known sender/;
|
|
116
|
+
|
|
117
|
+
// The shell reflows by its OWN width (container query / ResizeObserver),
|
|
118
|
+
// seeded for SSR via `initialWidth`.
|
|
119
|
+
it("list-only below 1024: neither reading pane nor rail in the HTML", () => {
|
|
120
|
+
const html = render({ ...readingPaneProps, initialWidth: 800 });
|
|
121
|
+
assert.match(html, /Ada Lovelace/, "the list still renders");
|
|
122
|
+
assert.doesNotMatch(html, readingPaneMarker, "reading pane is absent");
|
|
123
|
+
assert.doesNotMatch(html, intelligenceMarker, "rail is absent");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("reading pane appears at 1024, rail still held until 1280", () => {
|
|
127
|
+
const html = render({ ...readingPaneProps, initialWidth: 1100 });
|
|
128
|
+
assert.match(html, readingPaneMarker, "reading pane is present");
|
|
129
|
+
assert.doesNotMatch(html, intelligenceMarker, "rail holds until ≥1280px");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("rail joins the reading pane at 1280", () => {
|
|
133
|
+
const html = render({ ...readingPaneProps, initialWidth: 1400 });
|
|
134
|
+
assert.match(html, readingPaneMarker, "reading pane is present");
|
|
135
|
+
assert.match(html, intelligenceMarker, "rail joins at xl");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("AppShell nav: pane vs slide-over by width (#784)", () => {
|
|
140
|
+
// The nav sidebar's own items — present only when the nav renders.
|
|
141
|
+
const navMarker = /Daily brief/;
|
|
142
|
+
// The list-header folders button opens the slide-over at narrow widths.
|
|
143
|
+
const navTriggerMarker = /aria-label="Open folders"/;
|
|
144
|
+
|
|
145
|
+
it("below 1024px: nav is not a persistent pane, reached via the trigger", () => {
|
|
146
|
+
const html = render({ flatList: true, initialWidth: 800 });
|
|
147
|
+
assert.doesNotMatch(
|
|
148
|
+
html,
|
|
149
|
+
navMarker,
|
|
150
|
+
"nav is not in the persistent layout (drawer is closed)",
|
|
151
|
+
);
|
|
152
|
+
assert.match(html, navTriggerMarker, "the folders trigger is present");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("≥1024px: nav is a persistent pane, no trigger", () => {
|
|
156
|
+
const html = render({ flatList: true, initialWidth: 1100 });
|
|
157
|
+
assert.match(html, navMarker, "nav renders as a pane");
|
|
158
|
+
assert.doesNotMatch(
|
|
159
|
+
html,
|
|
160
|
+
navTriggerMarker,
|
|
161
|
+
"no folders trigger when the nav is a pane",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("AppShell narrow message view: width-gated in-place swap", () => {
|
|
167
|
+
const withThread: Partial<AppShellProps> = {
|
|
168
|
+
flatList: true,
|
|
169
|
+
selectedThreadId: "m1",
|
|
170
|
+
thread,
|
|
171
|
+
intelligence,
|
|
172
|
+
};
|
|
173
|
+
// The dedicated message view's back affordance — present only when the
|
|
174
|
+
// narrow single pane is showing the message view.
|
|
175
|
+
const messageViewMarker = /aria-label="Back to messages"/;
|
|
176
|
+
// The desktop reading-pane toolbar — present only at/above 1024.
|
|
177
|
+
const readingPaneMarker = /Search mail/;
|
|
178
|
+
|
|
179
|
+
it("below 1024 defaults to the list, not the message view", () => {
|
|
180
|
+
const html = render({ ...withThread, initialWidth: 800 });
|
|
181
|
+
assert.doesNotMatch(html, messageViewMarker, "starts on the list");
|
|
182
|
+
assert.match(html, /Ada Lovelace/, "the list renders");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("below 1024 the message view takes over the single pane when opened", () => {
|
|
186
|
+
const html = render({
|
|
187
|
+
...withThread,
|
|
188
|
+
initialWidth: 800,
|
|
189
|
+
initialNarrowView: "message",
|
|
190
|
+
});
|
|
191
|
+
assert.match(html, messageViewMarker, "the message view is shown");
|
|
192
|
+
assert.doesNotMatch(
|
|
193
|
+
html,
|
|
194
|
+
readingPaneMarker,
|
|
195
|
+
"no desktop reading pane at narrow width",
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("at/above 1024 the swap is ignored — thread fills the reading pane", () => {
|
|
200
|
+
const html = render({
|
|
201
|
+
...withThread,
|
|
202
|
+
initialWidth: 1100,
|
|
203
|
+
initialNarrowView: "message",
|
|
204
|
+
});
|
|
205
|
+
assert.match(
|
|
206
|
+
html,
|
|
207
|
+
readingPaneMarker,
|
|
208
|
+
"the reading pane renders the thread",
|
|
209
|
+
);
|
|
210
|
+
assert.doesNotMatch(
|
|
211
|
+
html,
|
|
212
|
+
messageViewMarker,
|
|
213
|
+
"the narrow message view never appears once the reading pane fits",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("AppShell narrow touch triage (#swipe/selection/pull)", () => {
|
|
219
|
+
// The pull-to-refresh affordance + swipe action labels live only in the
|
|
220
|
+
// narrow touch list body. The touch gate is container-derived (the shell's
|
|
221
|
+
// own width), seeded for SSR via `initialWidth`.
|
|
222
|
+
it("below 1024 the ready list is the touch surface (pull-to-refresh)", () => {
|
|
223
|
+
const html = render({ flatList: true, initialWidth: 800 });
|
|
224
|
+
assert.match(
|
|
225
|
+
html,
|
|
226
|
+
/Pull to refresh/,
|
|
227
|
+
"the pull-to-refresh hint is present",
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("at/above 1024 the desktop list has no touch chrome", () => {
|
|
232
|
+
const html = render({ flatList: true, initialWidth: 1100 });
|
|
233
|
+
assert.doesNotMatch(
|
|
234
|
+
html,
|
|
235
|
+
/Pull to refresh/,
|
|
236
|
+
"no pull-to-refresh on the desktop list",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("AppShell touch-state seeds (story/SSR affordance)", () => {
|
|
242
|
+
// A two-row flat list so the seeds (first-two-checked / peek the 2nd row)
|
|
243
|
+
// have rows to land on.
|
|
244
|
+
const twoRows: ThreadSection = {
|
|
245
|
+
id: "inbox",
|
|
246
|
+
threads: [
|
|
247
|
+
{
|
|
248
|
+
id: "r1",
|
|
249
|
+
accountId: "acc",
|
|
250
|
+
fromName: "Ada Lovelace",
|
|
251
|
+
fromEmail: "ada@example.com",
|
|
252
|
+
subject: "First",
|
|
253
|
+
snippet: "one",
|
|
254
|
+
timeLabel: "09:00",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "r2",
|
|
258
|
+
accountId: "acc",
|
|
259
|
+
fromName: "Grace Hopper",
|
|
260
|
+
fromEmail: "grace@example.com",
|
|
261
|
+
subject: "Second",
|
|
262
|
+
snippet: "two",
|
|
263
|
+
timeLabel: "09:05",
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
const touchBase: Partial<AppShellProps> = {
|
|
268
|
+
flatList: true,
|
|
269
|
+
initialWidth: 390,
|
|
270
|
+
sections: [twoRows],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const count = (html: string, re: RegExp) => (html.match(re) ?? []).length;
|
|
274
|
+
// The swipe-action backgrounds. `bg-accent-2` must not match `bg-accent-2-soft`
|
|
275
|
+
// (the row's active highlight), so require a non-`-`/letter boundary after it.
|
|
276
|
+
const trailingAction = /bg-danger/g;
|
|
277
|
+
const leadingAction = /bg-accent-2(?![-a-z])/g;
|
|
278
|
+
|
|
279
|
+
// Regression (the bug the user caught): at REST every row was leaking its
|
|
280
|
+
// trailing delete action because the transparent row didn't cover the layer
|
|
281
|
+
// behind it. No row is peeked here, so zero action zones may be visible.
|
|
282
|
+
it("at rest shows NO swipe-action zone on any row", () => {
|
|
283
|
+
const html = render(touchBase);
|
|
284
|
+
assert.equal(
|
|
285
|
+
count(html, trailingAction),
|
|
286
|
+
0,
|
|
287
|
+
"no trailing (delete) action visible at rest",
|
|
288
|
+
);
|
|
289
|
+
assert.equal(
|
|
290
|
+
count(html, leadingAction),
|
|
291
|
+
0,
|
|
292
|
+
"no leading (toggle-read) action visible at rest",
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("selection seed shows the selection bar replacing the header", () => {
|
|
297
|
+
const html = render({ ...touchBase, initialTouchState: "selection" });
|
|
298
|
+
assert.match(
|
|
299
|
+
html,
|
|
300
|
+
/aria-label="Cancel selection"/,
|
|
301
|
+
"the selection bar is shown",
|
|
302
|
+
);
|
|
303
|
+
// SSR splits interpolated text with comment markers, so match the parts.
|
|
304
|
+
assert.match(html, /messages<!-- --> selected/, "selection wording shown");
|
|
305
|
+
assert.match(html, />2<!-- -->/, "the seeded count of 2 is shown");
|
|
306
|
+
// Selection mode is not a swipe — no action zones revealed.
|
|
307
|
+
assert.equal(
|
|
308
|
+
count(html, trailingAction),
|
|
309
|
+
0,
|
|
310
|
+
"no swipe action in selection",
|
|
311
|
+
);
|
|
312
|
+
assert.equal(count(html, leadingAction), 0, "no swipe action in selection");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("peek-trailing reveals exactly one row's delete action, no leading", () => {
|
|
316
|
+
const html = render({ ...touchBase, initialTouchState: "peek-trailing" });
|
|
317
|
+
assert.match(html, /translateX\(-72px\)/, "the second row is peeked left");
|
|
318
|
+
assert.equal(
|
|
319
|
+
count(html, trailingAction),
|
|
320
|
+
1,
|
|
321
|
+
"exactly one trailing action is revealed",
|
|
322
|
+
);
|
|
323
|
+
assert.equal(count(html, leadingAction), 0, "no leading action leaks");
|
|
324
|
+
// the revealed action is a real button that performs the action
|
|
325
|
+
assert.match(
|
|
326
|
+
html,
|
|
327
|
+
/<button[^>]*aria-label="Delete message"/,
|
|
328
|
+
"delete action is a tappable button",
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("peek-leading reveals exactly one row's toggle-read action, no trailing", () => {
|
|
333
|
+
const html = render({ ...touchBase, initialTouchState: "peek-leading" });
|
|
334
|
+
assert.match(html, /translateX\(72px\)/, "the second row is peeked right");
|
|
335
|
+
assert.equal(
|
|
336
|
+
count(html, leadingAction),
|
|
337
|
+
1,
|
|
338
|
+
"exactly one leading action is revealed",
|
|
339
|
+
);
|
|
340
|
+
assert.equal(count(html, trailingAction), 0, "no trailing action leaks");
|
|
341
|
+
assert.match(
|
|
342
|
+
html,
|
|
343
|
+
/<button[^>]*aria-label="Mark as (read|unread)"/,
|
|
344
|
+
"toggle-read action is a tappable button",
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("ignores the touch seed at/above 1024 (desktop list)", () => {
|
|
349
|
+
const html = render({
|
|
350
|
+
...touchBase,
|
|
351
|
+
initialWidth: 1100,
|
|
352
|
+
initialTouchState: "selection",
|
|
353
|
+
});
|
|
354
|
+
assert.doesNotMatch(
|
|
355
|
+
html,
|
|
356
|
+
/aria-label="Cancel selection"/,
|
|
357
|
+
"no selection bar on the desktop list",
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("AppShell list states (#5/#7)", () => {
|
|
363
|
+
it("renders the skeleton when loading, not the rows", () => {
|
|
364
|
+
const html = render({ flatList: true, listState: "loading" });
|
|
365
|
+
assert.match(html, /animate-pulse/);
|
|
366
|
+
assert.doesNotMatch(html, /Ada Lovelace/, "rows replaced by skeleton");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("renders the empty mailbox copy", () => {
|
|
370
|
+
const html = render({ flatList: true, listState: "empty", sections: [] });
|
|
371
|
+
assert.match(html, /No messages in this mailbox/);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("renders the search-empty copy when a query is active", () => {
|
|
375
|
+
const html = render({
|
|
376
|
+
flatList: true,
|
|
377
|
+
listState: "empty",
|
|
378
|
+
sections: [],
|
|
379
|
+
searchQuery: "invoice",
|
|
380
|
+
});
|
|
381
|
+
assert.match(html, /No messages match your search/);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("fails hard on error with retry + report, never a disabled control", () => {
|
|
385
|
+
const html = render({
|
|
386
|
+
flatList: true,
|
|
387
|
+
listState: "error",
|
|
388
|
+
sections: [],
|
|
389
|
+
onRetry: () => {},
|
|
390
|
+
onReportError: () => {},
|
|
391
|
+
});
|
|
392
|
+
assert.match(html, /role="alert"/);
|
|
393
|
+
assert.match(html, /Couldn't load messages/);
|
|
394
|
+
assert.match(html, /Retry/);
|
|
395
|
+
assert.match(html, /Report a problem/);
|
|
396
|
+
assert.doesNotMatch(html, /\sdisabled[\s=>]/);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// The interactive swipe is a pointer drag; its release rule is `commitPeek`,
|
|
401
|
+
// kept pure so it's testable without a DOM (SSR can't fire pointer events).
|
|
402
|
+
describe("commitPeek — drag-release snap rule", () => {
|
|
403
|
+
it("snaps back to none when the drag is short of half the action width", () => {
|
|
404
|
+
assert.equal(commitPeek(0), "none");
|
|
405
|
+
assert.equal(commitPeek(35), "none", "just under half (36) stays closed");
|
|
406
|
+
assert.equal(commitPeek(-35), "none");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("commits to a side once the drag passes half the action width", () => {
|
|
410
|
+
assert.equal(commitPeek(36), "leading", "dragged right past half");
|
|
411
|
+
assert.equal(commitPeek(72), "leading", "fully dragged right");
|
|
412
|
+
assert.equal(commitPeek(-36), "trailing", "dragged left past half");
|
|
413
|
+
assert.equal(commitPeek(-72), "trailing", "fully dragged left");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { FolderInput } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { AppShellSlotted, useAppShellLayout } from "./app-shell-slotted.js";
|
|
4
|
+
import type {
|
|
5
|
+
AppShellProps,
|
|
6
|
+
BriefCategoryFilter,
|
|
7
|
+
NarrowView,
|
|
8
|
+
} from "./app-shell-types.js";
|
|
9
|
+
import { Button } from "./button.js";
|
|
10
|
+
import { IntelligencePanel } from "./intelligence-panel.js";
|
|
11
|
+
import { MessageListPane } from "./message-list-pane.js";
|
|
12
|
+
import {
|
|
13
|
+
type MobileReadingMessageActions,
|
|
14
|
+
MobileReadingPane,
|
|
15
|
+
} from "./mobile-reading-pane.js";
|
|
16
|
+
import { NavSidebar } from "./nav-sidebar.js";
|
|
17
|
+
import { ReadingPane } from "./reading-pane.js";
|
|
18
|
+
|
|
19
|
+
const noop = () => undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Preview wiring for the narrow-width reading view: the data-driven shell has no
|
|
23
|
+
* per-message handlers, so the mock supplies no-op verbs and a folder-picker
|
|
24
|
+
* slot. The live client passes real handlers in the #960 follow-up.
|
|
25
|
+
*/
|
|
26
|
+
const mobileReadingActions: MobileReadingMessageActions = {
|
|
27
|
+
onToggleExpand: noop,
|
|
28
|
+
onReply: noop,
|
|
29
|
+
onReplyAll: noop,
|
|
30
|
+
onForward: noop,
|
|
31
|
+
onToggleStar: noop,
|
|
32
|
+
onDelete: noop,
|
|
33
|
+
onToggleRead: noop,
|
|
34
|
+
moveSlot: () => (
|
|
35
|
+
<Button
|
|
36
|
+
variant="ghost"
|
|
37
|
+
size="sm"
|
|
38
|
+
icon={<FolderInput className="size-5" />}
|
|
39
|
+
aria-label="Move to folder"
|
|
40
|
+
title="Move to folder"
|
|
41
|
+
className="min-h-11 min-w-11 px-0"
|
|
42
|
+
/>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ */
|
|
47
|
+
/* Data-driven shell: maps thread/sections/intelligence props into the */
|
|
48
|
+
/* slots of AppShellSlotted, which owns the responsive panel layout */
|
|
49
|
+
/* and the 1024/1280 breakpoints. This component is the kit's preview / */
|
|
50
|
+
/* Storybook surface; the layout lives in exactly one place (slotted). */
|
|
51
|
+
/* ------------------------------------------------------------------ */
|
|
52
|
+
|
|
53
|
+
export function AppShell({
|
|
54
|
+
accounts,
|
|
55
|
+
initialWidth,
|
|
56
|
+
selectedNavId,
|
|
57
|
+
briefUnseen,
|
|
58
|
+
listTitle,
|
|
59
|
+
listMeta,
|
|
60
|
+
sections,
|
|
61
|
+
briefFilters,
|
|
62
|
+
flatList,
|
|
63
|
+
listState,
|
|
64
|
+
searchQuery,
|
|
65
|
+
onRetry,
|
|
66
|
+
onReportError,
|
|
67
|
+
briefCategory,
|
|
68
|
+
onSelectBriefCategory,
|
|
69
|
+
selectedThreadId,
|
|
70
|
+
thread,
|
|
71
|
+
initialNarrowView = "list",
|
|
72
|
+
initialTouchState,
|
|
73
|
+
intelligence,
|
|
74
|
+
intelligenceOpen = true,
|
|
75
|
+
density,
|
|
76
|
+
onSelectNav,
|
|
77
|
+
onSelectThread,
|
|
78
|
+
onToggleIntelligence,
|
|
79
|
+
}: AppShellProps) {
|
|
80
|
+
/* Below the reading boundary the single pane swaps in place between the list
|
|
81
|
+
and a dedicated message view; above it the thread fills the reading pane and
|
|
82
|
+
narrowView is unused. */
|
|
83
|
+
const [narrowView, setNarrowView] = useState<NarrowView>(initialNarrowView);
|
|
84
|
+
/* Category lives here when uncontrolled so the brief and the nav expando
|
|
85
|
+
share one axis; a controlled `briefCategory`/`onSelectBriefCategory` pair
|
|
86
|
+
overrides it. */
|
|
87
|
+
const [internalCategory, setInternalCategory] = useState<BriefCategoryFilter>(
|
|
88
|
+
briefCategory ?? "all",
|
|
89
|
+
);
|
|
90
|
+
const activeCategory = briefCategory ?? internalCategory;
|
|
91
|
+
const selectCategory = (category: BriefCategoryFilter) => {
|
|
92
|
+
setInternalCategory(category);
|
|
93
|
+
onSelectBriefCategory?.(category);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const nav = (
|
|
97
|
+
<NavSidebar
|
|
98
|
+
accounts={accounts}
|
|
99
|
+
selectedNavId={selectedNavId}
|
|
100
|
+
briefUnseen={briefUnseen}
|
|
101
|
+
onSelectNav={onSelectNav}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const list = (
|
|
106
|
+
<AppShellList
|
|
107
|
+
thread={thread}
|
|
108
|
+
intelligence={intelligence}
|
|
109
|
+
narrowView={narrowView}
|
|
110
|
+
onBackToList={() => setNarrowView("list")}
|
|
111
|
+
listTitle={listTitle}
|
|
112
|
+
listMeta={listMeta}
|
|
113
|
+
sections={sections}
|
|
114
|
+
briefFilters={briefFilters}
|
|
115
|
+
flatList={flatList}
|
|
116
|
+
listState={listState}
|
|
117
|
+
searchQuery={searchQuery}
|
|
118
|
+
onRetry={onRetry}
|
|
119
|
+
onReportError={onReportError}
|
|
120
|
+
briefCategory={activeCategory}
|
|
121
|
+
selectedThreadId={selectedThreadId}
|
|
122
|
+
density={density}
|
|
123
|
+
onSelectThread={(id) => {
|
|
124
|
+
setNarrowView("message");
|
|
125
|
+
onSelectThread?.(id);
|
|
126
|
+
}}
|
|
127
|
+
onSelectBriefCategory={selectCategory}
|
|
128
|
+
initialTouchState={initialTouchState}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const reading = (
|
|
133
|
+
<AppShellReading
|
|
134
|
+
thread={thread}
|
|
135
|
+
intelligence={intelligence}
|
|
136
|
+
intelligenceOpen={intelligenceOpen}
|
|
137
|
+
onToggleIntelligence={onToggleIntelligence}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<AppShellSlotted
|
|
143
|
+
initialWidth={initialWidth}
|
|
144
|
+
nav={nav}
|
|
145
|
+
list={list}
|
|
146
|
+
reading={reading}
|
|
147
|
+
intelligence={
|
|
148
|
+
intelligence ? (
|
|
149
|
+
<IntelligencePanel
|
|
150
|
+
data={intelligence}
|
|
151
|
+
onClose={onToggleIntelligence}
|
|
152
|
+
className="h-full w-full border-l-0"
|
|
153
|
+
/>
|
|
154
|
+
) : undefined
|
|
155
|
+
}
|
|
156
|
+
intelligenceOpen={intelligenceOpen}
|
|
157
|
+
hasThread={Boolean(thread)}
|
|
158
|
+
density={density}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------ */
|
|
164
|
+
/* Slot adapters: read the slotted shell's layout context so they */
|
|
165
|
+
/* render the right narrow/wide variant inside each pane. */
|
|
166
|
+
/* ------------------------------------------------------------------ */
|
|
167
|
+
|
|
168
|
+
function AppShellList({
|
|
169
|
+
thread,
|
|
170
|
+
intelligence,
|
|
171
|
+
narrowView,
|
|
172
|
+
onBackToList,
|
|
173
|
+
listTitle,
|
|
174
|
+
listMeta,
|
|
175
|
+
sections,
|
|
176
|
+
briefFilters,
|
|
177
|
+
flatList,
|
|
178
|
+
listState,
|
|
179
|
+
searchQuery,
|
|
180
|
+
onRetry,
|
|
181
|
+
onReportError,
|
|
182
|
+
briefCategory,
|
|
183
|
+
selectedThreadId,
|
|
184
|
+
density,
|
|
185
|
+
onSelectThread,
|
|
186
|
+
onSelectBriefCategory,
|
|
187
|
+
initialTouchState,
|
|
188
|
+
}: Pick<
|
|
189
|
+
AppShellProps,
|
|
190
|
+
| "thread"
|
|
191
|
+
| "intelligence"
|
|
192
|
+
| "listTitle"
|
|
193
|
+
| "listMeta"
|
|
194
|
+
| "sections"
|
|
195
|
+
| "briefFilters"
|
|
196
|
+
| "flatList"
|
|
197
|
+
| "listState"
|
|
198
|
+
| "searchQuery"
|
|
199
|
+
| "onRetry"
|
|
200
|
+
| "onReportError"
|
|
201
|
+
| "selectedThreadId"
|
|
202
|
+
| "density"
|
|
203
|
+
| "onSelectThread"
|
|
204
|
+
| "initialTouchState"
|
|
205
|
+
> & {
|
|
206
|
+
narrowView: NarrowView;
|
|
207
|
+
onBackToList: () => void;
|
|
208
|
+
briefCategory: BriefCategoryFilter;
|
|
209
|
+
onSelectBriefCategory: (category: BriefCategoryFilter) => void;
|
|
210
|
+
}) {
|
|
211
|
+
const layout = useAppShellLayout();
|
|
212
|
+
const showReadingPane = layout?.showReadingPane ?? false;
|
|
213
|
+
const showNavPane = layout?.showNavPane ?? true;
|
|
214
|
+
|
|
215
|
+
/* Below the reading boundary, opening a thread swaps the single pane for a
|
|
216
|
+
dedicated message view; at/above it the reading pane carries the thread. */
|
|
217
|
+
if (!showReadingPane && narrowView === "message" && thread) {
|
|
218
|
+
return (
|
|
219
|
+
<MobileReadingPane
|
|
220
|
+
thread={thread}
|
|
221
|
+
intelligence={intelligence}
|
|
222
|
+
onBack={onBackToList}
|
|
223
|
+
actions={mobileReadingActions}
|
|
224
|
+
/>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<MessageListPane
|
|
230
|
+
listTitle={listTitle}
|
|
231
|
+
listMeta={listMeta}
|
|
232
|
+
sections={sections ?? []}
|
|
233
|
+
briefFilters={briefFilters}
|
|
234
|
+
flatList={flatList}
|
|
235
|
+
listState={listState}
|
|
236
|
+
searchQuery={searchQuery}
|
|
237
|
+
onRetry={onRetry}
|
|
238
|
+
onReportError={onReportError}
|
|
239
|
+
briefCategory={briefCategory}
|
|
240
|
+
selectedThreadId={selectedThreadId}
|
|
241
|
+
density={density}
|
|
242
|
+
onSelectThread={onSelectThread}
|
|
243
|
+
onSelectBriefCategory={onSelectBriefCategory}
|
|
244
|
+
onOpenNav={showNavPane ? undefined : layout?.openNav}
|
|
245
|
+
isDesktop={showReadingPane}
|
|
246
|
+
initialTouchState={initialTouchState}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function AppShellReading({
|
|
252
|
+
thread,
|
|
253
|
+
intelligence,
|
|
254
|
+
intelligenceOpen,
|
|
255
|
+
onToggleIntelligence,
|
|
256
|
+
}: Pick<
|
|
257
|
+
AppShellProps,
|
|
258
|
+
"thread" | "intelligence" | "intelligenceOpen" | "onToggleIntelligence"
|
|
259
|
+
>) {
|
|
260
|
+
const layout = useAppShellLayout();
|
|
261
|
+
const isWide = layout?.showIntelligencePane ?? false;
|
|
262
|
+
const showIntelligence =
|
|
263
|
+
isWide &&
|
|
264
|
+
Boolean(intelligence) &&
|
|
265
|
+
Boolean(intelligenceOpen) &&
|
|
266
|
+
Boolean(thread);
|
|
267
|
+
return (
|
|
268
|
+
<ReadingPane
|
|
269
|
+
thread={thread}
|
|
270
|
+
intelligenceOpen={showIntelligence}
|
|
271
|
+
onToggleIntelligence={onToggleIntelligence}
|
|
272
|
+
showIntelligenceToggle={
|
|
273
|
+
isWide && Boolean(intelligence) && Boolean(thread)
|
|
274
|
+
}
|
|
275
|
+
/>
|
|
276
|
+
);
|
|
277
|
+
}
|