@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.
- package/package.json +1 -1
- package/src/components/app-shell-types.ts +8 -0
- package/src/components/filter-rule-editor.stories.tsx +87 -0
- package/src/components/filter-rule-editor.tsx +172 -9
- package/src/components/filter-rule.render.test.ts +45 -6
- package/src/components/filter-rule.ts +26 -9
- package/src/components/filter-sheet.stories.tsx +16 -0
- package/src/components/label-chip.render.test.ts +45 -0
- package/src/components/label-chip.tsx +49 -0
- package/src/components/message-list-pane.tsx +17 -1
- package/src/components/message-list-state.render.test.ts +168 -7
- package/src/components/message-list-state.stories.tsx +328 -0
- package/src/components/message-list-state.tsx +136 -12
- package/src/components/message-row.render.test.ts +67 -1
- package/src/components/message-row.tsx +4 -0
- package/src/filter-presets.test.ts +25 -0
- package/src/filter-presets.ts +21 -0
- package/src/index.ts +17 -0
- package/src/lib/label-color.test.ts +25 -0
- package/src/lib/label-color.ts +46 -0
- package/src/stories-viewport-globals.test.ts +24 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
+
};
|