@remit/ui 0.0.50 → 0.0.52

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.
@@ -9,6 +9,7 @@ import { KeyboardHintBar } from "./keyboard-hint-bar.js";
9
9
  import {
10
10
  MessageListEmpty,
11
11
  MessageListError,
12
+ type MessageListFilter,
12
13
  MessageListLoading,
13
14
  } from "./message-list-state.js";
14
15
  import { ComfortableRow, CompactRow } from "./message-row.js";
@@ -28,6 +29,8 @@ export function MessageListPane({
28
29
  flatList,
29
30
  listState = "ready",
30
31
  searchQuery,
32
+ listFilter,
33
+ listScopeLabel,
31
34
  errorMessage,
32
35
  onRetry,
33
36
  onReportError,
@@ -61,6 +64,15 @@ export function MessageListPane({
61
64
  | "onSelectThread"
62
65
  | "onSelectBriefCategory"
63
66
  > & {
67
+ /**
68
+ * The active category filter, when the caller has one. Without it the empty
69
+ * state cannot know it is filtered, and a narrowed list renders the plain
70
+ * "No messages in this mailbox" — D19's stated failure case. Absent means
71
+ * unfiltered, so a surface that filters must pass this.
72
+ */
73
+ listFilter?: MessageListFilter;
74
+ /** Name of the collection, e.g. "Inbox". Passed to the empty state. */
75
+ listScopeLabel?: string;
64
76
  /** When set, the list header shows a folders/menu button that opens the nav
65
77
  * slide-over (list-only widths, where the nav is not a persistent pane). */
66
78
  onOpenNav?: () => void;
@@ -190,7 +202,11 @@ export function MessageListPane({
190
202
  <MessageListLoading />
191
203
  </div>
192
204
  ) : listState === "empty" ? (
193
- <MessageListEmpty searchQuery={searchQuery} />
205
+ <MessageListEmpty
206
+ filter={listFilter}
207
+ scopeLabel={listScopeLabel}
208
+ searchQuery={searchQuery}
209
+ />
194
210
  ) : listState === "error" ? (
195
211
  <MessageListError
196
212
  message={errorMessage}
@@ -4,9 +4,26 @@ import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
5
  import {
6
6
  MessageListEmpty,
7
+ type MessageListEmptyProps,
7
8
  MessageListError,
9
+ type MessageListFilter,
8
10
  MessageListLoading,
11
+ MessageListLoadingMore,
9
12
  } from "./message-list-state.js";
13
+ import { EmptyStateComparison } from "./message-list-state.stories.js";
14
+
15
+ const COMPLETENESS = "Every message in this folder was checked.";
16
+ const BOUNDED = "Only the messages loaded so far were checked.";
17
+
18
+ function empty(props: MessageListEmptyProps = {}): string {
19
+ return renderToString(createElement(MessageListEmpty, props));
20
+ }
21
+
22
+ const personal: MessageListFilter = {
23
+ label: "Personal",
24
+ reach: "whole-folder",
25
+ onClear: () => undefined,
26
+ };
10
27
 
11
28
  describe("MessageListLoading", () => {
12
29
  const html = renderToString(createElement(MessageListLoading));
@@ -23,22 +40,158 @@ describe("MessageListLoading", () => {
23
40
 
24
41
  describe("MessageListEmpty", () => {
25
42
  it("uses the plain mailbox copy with no query", () => {
26
- const html = renderToString(createElement(MessageListEmpty, {}));
27
- assert.match(html, /No messages in this mailbox/);
43
+ assert.match(empty(), /No messages in this mailbox/);
28
44
  });
29
45
 
30
46
  it("switches to the search copy when a query is active", () => {
31
- const html = renderToString(
32
- createElement(MessageListEmpty, { searchQuery: "invoice" }),
47
+ assert.match(
48
+ empty({ searchQuery: "invoice" }),
49
+ /No messages match your search/,
33
50
  );
34
- assert.match(html, /No messages match your search/);
35
51
  });
36
52
 
37
53
  it("treats a whitespace-only query as no search", () => {
38
- const html = renderToString(
39
- createElement(MessageListEmpty, { searchQuery: " " }),
54
+ assert.match(empty({ searchQuery: " " }), /No messages in this mailbox/);
55
+ });
56
+
57
+ it("names a collection that is not a mailbox instead of calling it one", () => {
58
+ const html = empty({ scopeLabel: "Starred" });
59
+ assert.match(html, /No messages in Starred/);
60
+ assert.doesNotMatch(html, /this mailbox/);
61
+ });
62
+
63
+ it("claims no completeness when nothing was filtered", () => {
64
+ assert.doesNotMatch(empty(), new RegExp(COMPLETENESS));
65
+ assert.doesNotMatch(
66
+ empty({ searchQuery: "invoice" }),
67
+ new RegExp(COMPLETENESS),
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("MessageListEmpty under a filter", () => {
73
+ it("names the filter and the folder, and says the folder was fully read", () => {
74
+ const html = empty({ filter: personal, scopeLabel: "Inbox" });
75
+ assert.match(html, /No Personal mail in Inbox/);
76
+ assert.match(html, new RegExp(COMPLETENESS));
77
+ });
78
+
79
+ it("keeps the completeness sentence without a scope label", () => {
80
+ const html = empty({ filter: personal });
81
+ assert.match(html, /No Personal mail/);
82
+ assert.match(html, new RegExp(COMPLETENESS));
83
+ });
84
+
85
+ it("carries a completeness sentence in every filtered variant", () => {
86
+ const variants: MessageListEmptyProps[] = [
87
+ { filter: personal },
88
+ { filter: personal, scopeLabel: "Inbox" },
89
+ { filter: personal, searchQuery: "invoice" },
90
+ { filter: personal, scopeLabel: "Inbox", searchQuery: "invoice" },
91
+ { filter: { ...personal, label: "Unclassified" } },
92
+ { filter: { ...personal, reach: "loaded-pages" } },
93
+ { filter: { ...personal, reach: "loaded-pages" }, scopeLabel: "Inbox" },
94
+ ];
95
+ for (const props of variants) {
96
+ assert.match(empty(props), new RegExp(`${COMPLETENESS}|${BOUNDED}`));
97
+ }
98
+ });
99
+
100
+ it("claims the whole folder only when the filter reached it", () => {
101
+ const bounded = empty({
102
+ filter: { ...personal, reach: "loaded-pages" },
103
+ scopeLabel: "Inbox",
104
+ });
105
+ assert.match(bounded, new RegExp(BOUNDED));
106
+ assert.doesNotMatch(
107
+ bounded,
108
+ new RegExp(COMPLETENESS),
109
+ "a bounded read must not claim the folder was fully checked",
40
110
  );
111
+ });
112
+
113
+ it("renders the two reaches distinguishably", () => {
114
+ const whole = empty({ filter: personal, scopeLabel: "Inbox" });
115
+ const bounded = empty({
116
+ filter: { ...personal, reach: "loaded-pages" },
117
+ scopeLabel: "Inbox",
118
+ });
119
+ assert.notEqual(whole, bounded);
120
+ assert.doesNotMatch(whole, new RegExp(BOUNDED));
121
+ });
122
+
123
+ it("leads with the query when searching inside a filter", () => {
124
+ const html = empty({
125
+ filter: personal,
126
+ scopeLabel: "Inbox",
127
+ searchQuery: "invoice",
128
+ });
129
+ assert.match(html, /No results for “invoice” in Personal/);
130
+ assert.match(html, new RegExp(COMPLETENESS));
131
+ });
132
+
133
+ it("offers the way out of the filter", () => {
134
+ assert.match(empty({ filter: personal }), /Clear filter/);
135
+ });
136
+
137
+ it("renders distinguishably from an unfiltered empty mailbox (#315)", () => {
138
+ const unfiltered = empty();
139
+ const filtered = empty({ filter: personal, scopeLabel: "Inbox" });
140
+ assert.notEqual(unfiltered, filtered);
141
+ assert.doesNotMatch(filtered, /No messages in this mailbox/);
142
+ assert.doesNotMatch(unfiltered, /Clear filter/);
143
+ });
144
+
145
+ it("renders distinguishably from the skeleton a restarted page shows", () => {
146
+ const filtered = empty({ filter: personal, scopeLabel: "Inbox" });
147
+ const loading = renderToString(createElement(MessageListLoading));
148
+ assert.notEqual(filtered, loading);
149
+ assert.doesNotMatch(filtered, /animate-pulse/);
150
+ assert.doesNotMatch(loading, new RegExp(COMPLETENESS));
151
+ });
152
+
153
+ it("renders unclassified as itself, never as personal (#45)", () => {
154
+ const unclassified = empty({
155
+ filter: { ...personal, label: "Unclassified" },
156
+ scopeLabel: "Inbox",
157
+ });
158
+ assert.match(unclassified, /No Unclassified mail in Inbox/);
159
+ assert.doesNotMatch(unclassified, /Personal/);
160
+ });
161
+ });
162
+
163
+ describe("the side-by-side comparison story", () => {
164
+ const html = renderToString(createElement(EmptyStateComparison));
165
+
166
+ it("renders both empty states, not one", () => {
41
167
  assert.match(html, /No messages in this mailbox/);
168
+ assert.match(html, /No Personal mail in Inbox/);
169
+ assert.match(html, new RegExp(COMPLETENESS));
170
+ });
171
+
172
+ it("gives neither panel a fixed width, so a frame cannot clip one away", () => {
173
+ assert.doesNotMatch(html, /\bw-9\d\b/);
174
+ assert.equal((html.match(/flex-1/g) ?? []).length >= 2, true);
175
+ });
176
+ });
177
+
178
+ describe("MessageListLoadingMore", () => {
179
+ const html = renderToString(createElement(MessageListLoadingMore));
180
+
181
+ it("says another page is coming in words, not only a spinner", () => {
182
+ assert.match(html, /Loading more/);
183
+ });
184
+
185
+ it("announces itself to assistive tech", () => {
186
+ assert.match(html, /role="status"/);
187
+ assert.match(html, /aria-live="polite"/);
188
+ });
189
+
190
+ it("renders distinguishably from a filtered empty list", () => {
191
+ const filtered = empty({ filter: personal, scopeLabel: "Inbox" });
192
+ assert.notEqual(html, filtered);
193
+ assert.doesNotMatch(html, new RegExp(COMPLETENESS));
194
+ assert.doesNotMatch(filtered, /Loading more/);
42
195
  });
43
196
  });
44
197
 
@@ -69,4 +222,12 @@ describe("MessageListError", () => {
69
222
  );
70
223
  assert.doesNotMatch(html, /\sdisabled[\s=>]/);
71
224
  });
225
+
226
+ it("reads as a failure, never as an empty result", () => {
227
+ const html = renderToString(
228
+ createElement(MessageListError, { message: "Network unreachable" }),
229
+ );
230
+ assert.doesNotMatch(html, new RegExp(COMPLETENESS));
231
+ assert.doesNotMatch(html, /No messages/);
232
+ });
72
233
  });
@@ -0,0 +1,328 @@
1
+ import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
+ import type { ReactNode } from "react";
3
+ import { inboxFilterConfig, UNCLASSIFIED_CATEGORY } from "../filter-presets.js";
4
+ import type { ThreadRowData } from "./app-shell-types.js";
5
+ import { FilterSheet } from "./filter-sheet.js";
6
+ import {
7
+ MessageListEmpty,
8
+ MessageListError,
9
+ type MessageListFilter,
10
+ MessageListLoading,
11
+ MessageListLoadingMore,
12
+ } from "./message-list-state.js";
13
+ import { ComfortableRow } from "./message-row.js";
14
+
15
+ const SENDERS = [
16
+ "Alex Rivera",
17
+ "Priya Raman",
18
+ "Tom Okafor",
19
+ "Lena Fischer",
20
+ ] as const;
21
+
22
+ function makeRow(i: number): ThreadRowData {
23
+ const fromName = SENDERS[i % SENDERS.length] ?? SENDERS[0];
24
+ return {
25
+ id: `t${i}`,
26
+ accountId: "a1",
27
+ fromName,
28
+ fromEmail: `${fromName.split(" ")[0]?.toLowerCase()}@example.com`,
29
+ subject: `Q3 planning notes ${i}`,
30
+ snippet: "Pushed the deck to the shared drive — have a look before Friday.",
31
+ timeLabel: `9:${String(i % 60).padStart(2, "0")}`,
32
+ isRead: i % 3 === 0,
33
+ category: "personal",
34
+ };
35
+ }
36
+
37
+ const longRow: ThreadRowData = {
38
+ ...makeRow(1),
39
+ fromName: "Netherlands Enterprise Agency, Subsidy and Permits Desk",
40
+ subject:
41
+ "Re: Re: Fwd: Consolidated quarterly reconciliation of the shared drive migration, including the appendices nobody asked for",
42
+ snippet:
43
+ "Following up on the earlier thread about the reconciliation, the appendices have been consolidated into a single document that now runs to sixty-two pages, and we would appreciate your review before the end of the week.",
44
+ };
45
+
46
+ const personalFilter: MessageListFilter = {
47
+ label: "Personal",
48
+ reach: "whole-folder",
49
+ onClear: () => undefined,
50
+ };
51
+
52
+ /**
53
+ * One list pane at its real width. Applied per story rather than on the meta:
54
+ * Storybook composes decorators and a story cannot shed a meta one, so a story
55
+ * that needs a different frame has to be the only thing framing itself.
56
+ */
57
+ const paneFrame: Decorator = (Story) => (
58
+ <div className="h-screen w-96 overflow-hidden border border-line">
59
+ <Story />
60
+ </div>
61
+ );
62
+
63
+ /** The list body a filtered mailbox scrolls: rows plus whatever sits below them. */
64
+ function Rows({ count, rows }: { count?: number; rows?: ThreadRowData[] }) {
65
+ const threads =
66
+ rows ?? Array.from({ length: count ?? 6 }, (_, i) => makeRow(i + 1));
67
+ return (
68
+ <div className="divide-y divide-line">
69
+ {threads.map((thread) => (
70
+ <ComfortableRow key={thread.id} thread={thread} />
71
+ ))}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ /**
77
+ * The inbox behind its filter, collapsed. The filter's identity lives in this
78
+ * summary bar (design D19 S5), which is why a filtered list needs no header of
79
+ * its own to be told apart from the mailbox.
80
+ */
81
+ function FilteredShell({
82
+ category = "personal",
83
+ children,
84
+ }: {
85
+ category?: string;
86
+ children: ReactNode;
87
+ }) {
88
+ const preset = inboxFilterConfig();
89
+ return (
90
+ <div className="flex h-full flex-col">
91
+ <FilterSheet
92
+ categories={[...preset.categories, UNCLASSIFIED_CATEGORY]}
93
+ filters={preset.filters}
94
+ selectedCategory={category}
95
+ activeFilters={new Set<string>()}
96
+ expanded={false}
97
+ onSelectCategory={() => undefined}
98
+ onToggleFilter={() => undefined}
99
+ onClear={() => undefined}
100
+ onExpandedChange={() => undefined}
101
+ >
102
+ <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
103
+ </FilterSheet>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * The review this issue is for: an unfiltered empty mailbox next to a filtered
110
+ * empty one. If these two ever look the same, the fix is invisible.
111
+ *
112
+ * Both panels share the width instead of taking a fixed one, so neither can be
113
+ * pushed off the edge of whatever frames the story. Rendered by the story below
114
+ * and asserted by `message-list-state.render.test.ts`.
115
+ */
116
+ export function EmptyStateComparison() {
117
+ return (
118
+ <div className="flex h-screen w-full gap-4 p-4">
119
+ <div className="min-w-0 flex-1 overflow-hidden border border-line">
120
+ <MessageListEmpty />
121
+ </div>
122
+ <div className="min-w-0 flex-1 overflow-hidden border border-line">
123
+ <FilteredShell>
124
+ <MessageListEmpty filter={personalFilter} scopeLabel="Inbox" />
125
+ </FilteredShell>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ const meta: Meta<typeof MessageListEmpty> = {
132
+ title: "Screens/Kit/MessageListState",
133
+ component: MessageListEmpty,
134
+ parameters: { layout: "fullscreen" },
135
+ excludeStories: ["EmptyStateComparison"],
136
+ };
137
+ export default meta;
138
+
139
+ type Story = StoryObj<typeof MessageListEmpty>;
140
+
141
+ /** A bare empty state, and a filtered one, each in one pane. */
142
+ const inPane = { decorators: [paneFrame] };
143
+
144
+ /** A filtered list: the collapsed chip summary above the state under test. */
145
+ const inFilteredPane = {
146
+ decorators: [paneFrame],
147
+ render: (args: Parameters<typeof MessageListEmpty>[0]) => (
148
+ <FilteredShell>
149
+ <MessageListEmpty {...args} />
150
+ </FilteredShell>
151
+ ),
152
+ };
153
+
154
+ /**
155
+ * S1 — an empty mailbox with no filter. One plain line, no completeness claim:
156
+ * nothing was narrowed, so there is nothing to reassure the reader about.
157
+ */
158
+ export const EmptyMailbox: Story = {
159
+ ...inPane,
160
+ args: {},
161
+ };
162
+
163
+ /** S1 with a search query and no category filter — unchanged search copy. */
164
+ export const EmptyMailboxSearching: Story = {
165
+ ...inPane,
166
+ args: { searchQuery: "invoice" },
167
+ };
168
+
169
+ /**
170
+ * A collection that is not a mailbox names itself. Flagged spans accounts, so
171
+ * "this mailbox" was never true for it; its own states are #310.
172
+ */
173
+ export const NamedCollectionEmpty: Story = {
174
+ ...inPane,
175
+ args: { scopeLabel: "Starred" },
176
+ };
177
+
178
+ /**
179
+ * S2 — the state this slice exists for. The filter is active, the mailbox holds
180
+ * no matching mail, and the list says the whole folder was checked so an empty
181
+ * screen can no longer mean "we only looked at the newest page".
182
+ */
183
+ export const FilteredEmpty: Story = {
184
+ ...inFilteredPane,
185
+ args: { filter: personalFilter, scopeLabel: "Inbox" },
186
+ };
187
+
188
+ /** S2 with a search query on top of the filter — same completeness sentence. */
189
+ export const FilteredEmptySearching: Story = {
190
+ ...inFilteredPane,
191
+ args: {
192
+ filter: personalFilter,
193
+ scopeLabel: "Inbox",
194
+ searchQuery: "invoice",
195
+ },
196
+ };
197
+
198
+ /**
199
+ * A filter that could only be applied to the pages fetched so far claims only
200
+ * that. D19-S3's case: the off-row criteria page with a continuation token, so
201
+ * "every message was checked" would be a lie there.
202
+ */
203
+ export const FilteredEmptyBoundedReach: Story = {
204
+ ...inFilteredPane,
205
+ args: {
206
+ filter: { ...personalFilter, reach: "loaded-pages" },
207
+ scopeLabel: "Inbox",
208
+ },
209
+ };
210
+
211
+ /**
212
+ * The `Unclassified` chip's empty state. Unclassified mail is its own
213
+ * filterable value and never reads as personal (issue #45), so this state is
214
+ * reachable and distinct from `FilteredEmpty`.
215
+ */
216
+ export const FilteredEmptyUnclassified: Story = {
217
+ decorators: [paneFrame],
218
+ args: {
219
+ filter: { ...personalFilter, label: "Unclassified" },
220
+ scopeLabel: "Inbox",
221
+ },
222
+ render: (args) => (
223
+ <FilteredShell category={UNCLASSIFIED_CATEGORY.id}>
224
+ <MessageListEmpty {...args} />
225
+ </FilteredShell>
226
+ ),
227
+ };
228
+
229
+ /** Long filter and folder names — the copy wraps rather than overflowing. */
230
+ export const FilteredEmptyLongLabels: Story = {
231
+ ...inFilteredPane,
232
+ args: {
233
+ filter: { ...personalFilter, label: "Transactional" },
234
+ scopeLabel: "Archive/2024/Suppliers/Netherlands Enterprise Agency",
235
+ },
236
+ };
237
+
238
+ /** Both empties side by side, full width — no pane frame to clip either one. */
239
+ export const EmptyVersusFilteredEmpty: Story = {
240
+ render: () => <EmptyStateComparison />,
241
+ };
242
+
243
+ /**
244
+ * S4 — the filter changed and pagination restarted. The skeleton, never the
245
+ * previous predicate's rows and never an empty state.
246
+ */
247
+ export const FilterChangedRestarting: Story = {
248
+ decorators: [paneFrame],
249
+ render: () => (
250
+ <FilteredShell>
251
+ <MessageListLoading />
252
+ </FilteredShell>
253
+ ),
254
+ };
255
+
256
+ /** S5 — the filter is active and rows came back. */
257
+ export const FilteredWithResults: Story = {
258
+ decorators: [paneFrame],
259
+ render: () => (
260
+ <FilteredShell>
261
+ <Rows count={6} />
262
+ </FilteredShell>
263
+ ),
264
+ };
265
+
266
+ /**
267
+ * S6 — rows already rendered, another page in flight. Words, not a bare
268
+ * spinner: "not fetched yet" must not read as "nothing there".
269
+ */
270
+ export const FilteredFetchingMore: Story = {
271
+ decorators: [paneFrame],
272
+ render: () => (
273
+ <FilteredShell>
274
+ <Rows count={12} />
275
+ <MessageListLoadingMore />
276
+ </FilteredShell>
277
+ ),
278
+ };
279
+
280
+ /** A filter that matches most of a large mailbox — scrolling, no truncation. */
281
+ export const FilteredManyResults: Story = {
282
+ decorators: [paneFrame],
283
+ render: () => (
284
+ <FilteredShell>
285
+ <Rows count={60} />
286
+ </FilteredShell>
287
+ ),
288
+ };
289
+
290
+ /** Long subjects, senders and snippets under an active filter. */
291
+ export const FilteredLongContent: Story = {
292
+ decorators: [paneFrame],
293
+ render: () => (
294
+ <FilteredShell>
295
+ <Rows
296
+ rows={[longRow, ...Array.from({ length: 4 }, (_, i) => makeRow(i + 2))]}
297
+ />
298
+ </FilteredShell>
299
+ ),
300
+ };
301
+
302
+ /** S7 — fail-hard error: the detail, a way back, and somewhere for it to go. */
303
+ export const ErrorState: Story = {
304
+ decorators: [paneFrame],
305
+ render: () => (
306
+ <FilteredShell>
307
+ <MessageListError
308
+ message="Request timed out while loading this mailbox."
309
+ onRetry={() => undefined}
310
+ onReport={() => undefined}
311
+ />
312
+ </FilteredShell>
313
+ ),
314
+ };
315
+
316
+ /** A long underlying failure message still wraps inside the frame. */
317
+ export const ErrorStateLongMessage: Story = {
318
+ decorators: [paneFrame],
319
+ render: () => (
320
+ <FilteredShell>
321
+ <MessageListError
322
+ message="The IMAP server closed the connection while fetching message headers for this mailbox, after 30 seconds without a response to the SELECT command."
323
+ onRetry={() => undefined}
324
+ onReport={() => undefined}
325
+ />
326
+ </FilteredShell>
327
+ ),
328
+ };