@remit/ui 0.0.51 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.51",
3
+ "version": "0.0.52",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
+ import { UNCLASSIFIED_CATEGORY } from "../filter-presets.js";
3
4
  import {
4
5
  FilterSheet,
5
6
  type FilterSheetCategory,
@@ -10,6 +11,7 @@ import {
10
11
  const CATEGORIES: FilterSheetCategory[] = [
11
12
  { id: "all", label: "All", tone: "neutral" },
12
13
  { id: "personal", label: "Personal", tone: "positive" },
14
+ UNCLASSIFIED_CATEGORY,
13
15
  { id: "newsletters", label: "Newsletters", tone: "accent" },
14
16
  { id: "marketing", label: "Marketing", tone: "warning" },
15
17
  { id: "automated", label: "Automated", tone: "neutral" },
@@ -127,6 +129,20 @@ export const CollapsedWithActiveFilters: Story = {
127
129
  ),
128
130
  };
129
131
 
132
+ /**
133
+ * Unclassified selected. Mail the classifier has not reached is a filterable
134
+ * value of its own and never folds into Personal (issue #45), so the chip and
135
+ * its collapsed summary carry their own label and tone.
136
+ */
137
+ export const CollapsedWithUnclassified: Story = {
138
+ render: () => (
139
+ <ControlledShell
140
+ initialExpanded={false}
141
+ initialCategory={UNCLASSIFIED_CATEGORY.id}
142
+ />
143
+ ),
144
+ };
145
+
130
146
  export const CollapsedEmpty: Story = {
131
147
  render: () => <ControlledShell initialExpanded={false} />,
132
148
  };
@@ -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
+ };
@@ -1,4 +1,5 @@
1
- import { AlertCircle } from "lucide-react";
1
+ import { AlertCircle, Loader2 } from "lucide-react";
2
+ import type { ReactNode } from "react";
2
3
  import { Button } from "./button.js";
3
4
 
4
5
  /** Non-ready states the flat mailbox list can be in. "ready" renders rows. */
@@ -34,25 +35,148 @@ export function MessageListLoading() {
34
35
  }
35
36
 
36
37
  /**
37
- * Empty mailbox / empty-search state. Copy mirrors the live MessageList
38
- * EmptyState: a plain "No messages…" line, switching to the search variant
39
- * when a query is active.
38
+ * How much of the collection the filter was applied to.
39
+ *
40
+ * `whole-folder` is a SQL `where` over the mailbox — an empty result means the
41
+ * folder holds no matching mail. `loaded-pages` is a criterion evaluated over
42
+ * the rows fetched so far, which D19-S3 keeps alive for the off-row criteria
43
+ * (`senderTrust`, `dkimMismatch`) that page with a continuation token: an empty
44
+ * result there means nothing matched yet, not that nothing matches.
45
+ *
46
+ * A value rather than a `complete` flag, so the day a third reach exists the
47
+ * copy is a case to add and not a boolean to reinterpret.
40
48
  */
41
- export function MessageListEmpty({ searchQuery }: { searchQuery?: string }) {
42
- const isSearching = Boolean(searchQuery?.trim());
49
+ export type FilterReach = "whole-folder" | "loaded-pages";
50
+
51
+ /**
52
+ * The active category filter, the way out of it, and how far it reached. All
53
+ * three together: the label cannot arrive without its escape, and the reach
54
+ * cannot be left to a default, because it decides which completeness sentence
55
+ * is true.
56
+ */
57
+ export interface MessageListFilter {
58
+ /** Display label of the active category, e.g. "Personal". */
59
+ label: string;
60
+ reach: FilterReach;
61
+ onClear: () => void;
62
+ }
63
+
64
+ export interface MessageListEmptyProps {
65
+ /** Absent when no category filter is active. */
66
+ filter?: MessageListFilter;
67
+ /**
68
+ * Name of the collection being listed, e.g. "Inbox". Absent for a plain
69
+ * mailbox, which keeps the generic copy. Cross-account collections that are
70
+ * not mailboxes (Flagged) name themselves here rather than being called one.
71
+ */
72
+ scopeLabel?: string;
73
+ searchQuery?: string;
74
+ }
75
+
76
+ /**
77
+ * Empty list state. Unfiltered copy mirrors the live MessageList EmptyState: a
78
+ * plain "No messages…" line, switching to the search variant when a query is
79
+ * active.
80
+ *
81
+ * Under a filter it says how much was read (design D19). An empty list looks
82
+ * identical whether it is correct or broken, which is how #315's bug survived
83
+ * a mailbox holding thousands of matching messages. The sentence comes from the
84
+ * filter's own reach, so a filtered empty state always carries one and never
85
+ * claims more than the query did.
86
+ */
87
+ export function MessageListEmpty({
88
+ filter,
89
+ scopeLabel,
90
+ searchQuery,
91
+ }: MessageListEmptyProps) {
92
+ const query = searchQuery?.trim();
93
+
94
+ if (!filter) {
95
+ return (
96
+ <EmptyFrame>
97
+ <p className="text-fg-muted">{unfilteredCopy(query, scopeLabel)}</p>
98
+ </EmptyFrame>
99
+ );
100
+ }
101
+
102
+ return (
103
+ <EmptyFrame>
104
+ <p className="font-medium text-fg">
105
+ {filteredHeadline(filter.label, scopeLabel, query)}
106
+ </p>
107
+ <p className="mt-1 text-sm text-fg-muted">
108
+ {COMPLETENESS_COPY[filter.reach]}
109
+ </p>
110
+ <Button
111
+ variant="secondary"
112
+ size="sm"
113
+ className="mt-4"
114
+ onClick={filter.onClear}
115
+ >
116
+ Clear filter
117
+ </Button>
118
+ </EmptyFrame>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The one sentence a filtered empty list always carries. `loaded-pages` states
124
+ * what is true of a bounded read and nothing more; the wording it should settle
125
+ * on belongs with whoever moves the off-row criteria on-row (D7).
126
+ */
127
+ const COMPLETENESS_COPY: Record<FilterReach, string> = {
128
+ "whole-folder": "Every message in this folder was checked.",
129
+ "loaded-pages": "Only the messages loaded so far were checked.",
130
+ };
131
+
132
+ function unfilteredCopy(
133
+ query: string | undefined,
134
+ scopeLabel: string | undefined,
135
+ ): string {
136
+ if (query) return "No messages match your search";
137
+ if (scopeLabel) return `No messages in ${scopeLabel}`;
138
+ return "No messages in this mailbox";
139
+ }
140
+
141
+ /** One string, not JSX, so the copy stays a single text node and reads whole. */
142
+ function filteredHeadline(
143
+ filterLabel: string,
144
+ scopeLabel: string | undefined,
145
+ query: string | undefined,
146
+ ): string {
147
+ if (query) return `No results for “${query}” in ${filterLabel}`;
148
+ if (scopeLabel) return `No ${filterLabel} mail in ${scopeLabel}`;
149
+ return `No ${filterLabel} mail`;
150
+ }
151
+
152
+ function EmptyFrame({ children }: { children: ReactNode }) {
43
153
  return (
44
154
  <div className="flex h-full flex-1 items-center justify-center">
45
- <div className="flex flex-col items-center justify-center p-8 text-center">
46
- <p className="text-fg-muted">
47
- {isSearching
48
- ? "No messages match your search"
49
- : "No messages in this mailbox"}
50
- </p>
155
+ <div className="flex max-w-sm flex-col items-center justify-center p-8 text-center">
156
+ {children}
51
157
  </div>
52
158
  </div>
53
159
  );
54
160
  }
55
161
 
162
+ /**
163
+ * Another page in flight below rows already rendered. Says so in words: a bare
164
+ * spinner (`MessageList.tsx:1429-1433`) is one of the ways "not fetched yet"
165
+ * reads as "nothing there".
166
+ */
167
+ export function MessageListLoadingMore() {
168
+ return (
169
+ <div
170
+ className="flex items-center justify-center gap-2 py-4 text-sm text-fg-muted"
171
+ role="status"
172
+ aria-live="polite"
173
+ >
174
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
175
+ <span>Loading more&hellip;</span>
176
+ </div>
177
+ );
178
+ }
179
+
56
180
  /**
57
181
  * Fail-hard list error (ux.md): a centered, blocking message that stops the
58
182
  * list — never a toast, never a control left looking healthy. States plainly
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
- import type { ThreadRowData } from "./app-shell-types.js";
5
+ import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
6
6
  import { ComfortableRow, CompactRow } from "./message-row.js";
7
7
 
8
8
  const base: ThreadRowData = {
@@ -61,6 +61,48 @@ describe("ComfortableRow", () => {
61
61
  });
62
62
  });
63
63
 
64
+ describe("ComfortableRow category presentation", () => {
65
+ const render = (category: ThreadRowData["category"]): string =>
66
+ renderToString(
67
+ createElement(ComfortableRow, {
68
+ thread: { ...base, isRead: true, category },
69
+ }),
70
+ );
71
+
72
+ /** The category Badge's own class signature, so no other span matches. */
73
+ const badgeClass = (html: string): string | undefined =>
74
+ html.match(
75
+ /class="([^"]*rounded-full px-2 py-0\.5 text-2xs font-medium[^"]*)"/,
76
+ )?.[1];
77
+
78
+ it("renders no category badge for personal", () => {
79
+ assert.equal(badgeClass(render("personal")), undefined);
80
+ });
81
+
82
+ it("renders uncategorized as neither personal nor nothing (#45)", () => {
83
+ const uncategorized = render("uncategorized");
84
+ assert.notEqual(uncategorized, render("personal"));
85
+ assert.ok(
86
+ badgeClass(uncategorized),
87
+ "a message the classifier has not reached carries its own badge",
88
+ );
89
+ assert.match(uncategorized, /uncategorized|unclassified/i);
90
+ });
91
+
92
+ it("keeps the uncategorized badge off personal's tone (#45)", () => {
93
+ assert.notEqual(
94
+ categoryTone.uncategorized,
95
+ categoryTone.personal,
96
+ "the tone table must not give unclassified mail personal's colour",
97
+ );
98
+ assert.doesNotMatch(
99
+ badgeClass(render("uncategorized")) ?? "",
100
+ /accent/,
101
+ "the row badge must not render personal's accent tone",
102
+ );
103
+ });
104
+ });
105
+
64
106
  describe("ComfortableRow selection slot", () => {
65
107
  it("renders no checkbox without a selection (non-selectable mode)", () => {
66
108
  const html = renderToString(
@@ -5,6 +5,7 @@ import {
5
5
  type FilterAccount,
6
6
  flaggedFilterConfig,
7
7
  inboxFilterConfig,
8
+ UNCLASSIFIED_CATEGORY,
8
9
  } from "./filter-presets.js";
9
10
 
10
11
  const accounts: FilterAccount[] = [
@@ -98,3 +99,27 @@ describe("flaggedFilterConfig", () => {
98
99
  assert.equal(flaggedFilterConfig().sources, undefined);
99
100
  });
100
101
  });
102
+
103
+ describe("UNCLASSIFIED_CATEGORY", () => {
104
+ it("is held out of every shipped preset until the server filters it", () => {
105
+ for (const preset of [
106
+ briefFilterConfig(),
107
+ inboxFilterConfig(),
108
+ flaggedFilterConfig(),
109
+ ]) {
110
+ assert.equal(
111
+ preset.categories.some((c) => c.id === UNCLASSIFIED_CATEGORY.id),
112
+ false,
113
+ );
114
+ }
115
+ });
116
+
117
+ it("carries its own label and tone, never personal's (#45)", () => {
118
+ const personal = briefFilterConfig().categories.find(
119
+ (c) => c.id === "personal",
120
+ );
121
+ assert.equal(UNCLASSIFIED_CATEGORY.label, "Unclassified");
122
+ assert.notEqual(UNCLASSIFIED_CATEGORY.label, personal?.label);
123
+ assert.notEqual(UNCLASSIFIED_CATEGORY.tone, personal?.tone);
124
+ });
125
+ });
@@ -27,10 +27,31 @@ export interface FilterPreset {
27
27
  sources?: FilterSheetSource[];
28
28
  }
29
29
 
30
+ /**
31
+ * The Unclassified chip D6 requires, built and storied but not offered yet.
32
+ *
33
+ * This module is a runtime dependency of three live surfaces that filter over
34
+ * the loaded window, so a chip in the list below is a chip a user can click
35
+ * today. Offering it before #306 makes the predicate a server-side `where`
36
+ * would add a fresh instance of the bug this epic exists to fix: a category
37
+ * whose mail sits below the newest page reads as a category with no mail.
38
+ *
39
+ * #306 splices this entry into `MESSAGE_CATEGORIES` after `Personal` — matching
40
+ * `briefCategories`' order — and deletes this constant.
41
+ */
42
+ export const UNCLASSIFIED_CATEGORY: FilterSheetCategory = {
43
+ id: "uncategorized",
44
+ label: "Unclassified",
45
+ tone: "neutral",
46
+ };
47
+
30
48
  /**
31
49
  * Content-type categories, mirroring the `MessageCategory` enum
32
50
  * (@remit/remit-imap) by value. The leading "all" clears the category. Per
33
51
  * message, not per mailbox — so they apply in the brief and an inbox alike.
52
+ *
53
+ * `uncategorized` is absent on purpose and is not folded into `personal`
54
+ * (issue #45) — see `UNCLASSIFIED_CATEGORY`.
34
55
  */
35
56
  const MESSAGE_CATEGORIES: FilterSheetCategory[] = [
36
57
  { id: "all", label: "All", tone: "neutral" },
package/src/index.ts CHANGED
@@ -251,8 +251,11 @@ export { MessageListPane } from "./components/message-list-pane.js";
251
251
  export {
252
252
  type ListState,
253
253
  MessageListEmpty,
254
+ type MessageListEmptyProps,
254
255
  MessageListError,
256
+ type MessageListFilter,
255
257
  MessageListLoading,
258
+ MessageListLoadingMore,
256
259
  } from "./components/message-list-state.js";
257
260
  export {
258
261
  type BriefRowComponent,
@@ -542,6 +545,7 @@ export {
542
545
  type FilterPreset,
543
546
  flaggedFilterConfig,
544
547
  inboxFilterConfig,
548
+ UNCLASSIFIED_CATEGORY,
545
549
  } from "./filter-presets.js";
546
550
  export {
547
551
  buildCidResolver,
@@ -51,3 +51,27 @@ describe("story files use the viewport globals pattern (#68)", () => {
51
51
  assert.deepEqual(offenders, []);
52
52
  });
53
53
  });
54
+
55
+ /**
56
+ * Storybook composes decorators story → component → global, so a story-level
57
+ * `decorators: []` adds nothing. It reads as "this story opts out of the meta's
58
+ * frame" and silently does the opposite, which clipped a story down to half its
59
+ * content. A story that needs a different frame carries the frame itself.
60
+ */
61
+ describe("story files never claim to shed a meta decorator", () => {
62
+ const roots = [
63
+ here,
64
+ resolve(here, "../../workbench/src"),
65
+ resolve(here, "../../web-client/src"),
66
+ ];
67
+
68
+ it("never uses an empty story-level decorators array", () => {
69
+ const offenders = roots
70
+ .flatMap((root) => storyFiles(root))
71
+ .filter((file) =>
72
+ /decorators:\s*\[\s*\]/.test(readFileSync(file, "utf8")),
73
+ )
74
+ .map((file) => relative(here, file));
75
+ assert.deepEqual(offenders, []);
76
+ });
77
+ });