@remit/ui 0.0.51 → 0.0.53
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/filter-sheet.stories.tsx +16 -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 +43 -1
- package/src/components/selection-sheet.render.test.ts +30 -0
- package/src/components/selection-sheet.stories.tsx +21 -0
- package/src/components/selection-sheet.tsx +25 -12
- package/src/filter-presets.test.ts +25 -0
- package/src/filter-presets.ts +21 -0
- package/src/index.ts +4 -0
- package/src/stories-viewport-globals.test.ts +24 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
+
};
|
|
@@ -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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
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…</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
|
|
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(
|
|
@@ -120,6 +120,36 @@ describe("SelectionSheet", () => {
|
|
|
120
120
|
assert.doesNotMatch(html, /Select similar messages/);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* jsdom lays nothing out, so these assert the constraint itself rather than a
|
|
125
|
+
* measured height: the sheet must not carry a fixed pixel ceiling (which sat
|
|
126
|
+
* below its own content at 411×759 and clipped the last action), and its body
|
|
127
|
+
* must scroll rather than clip whatever the ceiling does cut off (#405).
|
|
128
|
+
*/
|
|
129
|
+
it("caps the expanded height against the viewport, not a fixed pixel ceiling", () => {
|
|
130
|
+
const html = renderToString(
|
|
131
|
+
createElement(SelectionSheet, { ...base, startExpanded: true }),
|
|
132
|
+
);
|
|
133
|
+
assert.match(html, /max-height:70dvh/);
|
|
134
|
+
assert.doesNotMatch(html, /max-height:min\(/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("scrolls the expanded actions instead of clipping them", () => {
|
|
138
|
+
const html = renderToString(
|
|
139
|
+
createElement(SelectionSheet, {
|
|
140
|
+
...base,
|
|
141
|
+
startExpanded: true,
|
|
142
|
+
onSelectSimilar: noop,
|
|
143
|
+
onSomethingElse: noop,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
assert.match(html, /class="flex min-h-0 flex-1 flex-col overflow-y-auto /);
|
|
147
|
+
assert.doesNotMatch(
|
|
148
|
+
html,
|
|
149
|
+
/class="flex min-h-0 flex-1 flex-col overflow-hidden /,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
123
153
|
it("renders a partial-failure notice with a Retry action", () => {
|
|
124
154
|
const html = renderToString(
|
|
125
155
|
createElement(SelectionSheet, {
|
|
@@ -66,6 +66,27 @@ export const Expanded: Story = {
|
|
|
66
66
|
args: { startExpanded: true, moveSlot: <MoveSlot /> },
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The same expanded sheet on the shortest phone viewport a real device reports
|
|
71
|
+
* (411×759 — Android Chrome with its address bar and system nav showing). This
|
|
72
|
+
* is the tallest the content ever gets: select-all, all three quick actions and
|
|
73
|
+
* both smart-flow rows. A fixed height ceiling cut "Something else" off here
|
|
74
|
+
* with no way to reach it (#405); the sheet now takes the height its content
|
|
75
|
+
* needs, and scrolls only when the viewport genuinely can't hold it.
|
|
76
|
+
*/
|
|
77
|
+
export const ExpandedShortViewport: Story = {
|
|
78
|
+
globals: { viewport: { value: "mobileShort" } },
|
|
79
|
+
args: {
|
|
80
|
+
startExpanded: true,
|
|
81
|
+
moveSlot: <MoveSlot />,
|
|
82
|
+
selectAll: {
|
|
83
|
+
checked: false,
|
|
84
|
+
indeterminate: true,
|
|
85
|
+
onChange: () => undefined,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
69
90
|
/** While a search result set pages to its total: the count isn't known, the
|
|
70
91
|
* quick actions are replaced by the running total and an explicit Stop. */
|
|
71
92
|
export const Counting: Story = {
|
|
@@ -24,9 +24,13 @@ const formatCount = (n: number): string => n.toLocaleString();
|
|
|
24
24
|
|
|
25
25
|
/** The peek height of the collapsed teaser row (px). */
|
|
26
26
|
export const SELECTION_SHEET_TEASER_HEIGHT = 56;
|
|
27
|
-
/** Ceiling on the expanded sheet height
|
|
28
|
-
*
|
|
29
|
-
|
|
27
|
+
/** Ceiling on the expanded sheet height, as a share of the dynamic viewport.
|
|
28
|
+
* The sheet is otherwise sized by its own content, so the ceiling binds only on
|
|
29
|
+
* a viewport too short to hold the actions — and there they scroll. */
|
|
30
|
+
const EXPANDED_MAX_DVH = 70;
|
|
31
|
+
/** Assumed expanded height until the sheet has been measured; only sets the
|
|
32
|
+
* collapsed translate for the first frame. */
|
|
33
|
+
const EXPANDED_HEIGHT_FALLBACK = 320;
|
|
30
34
|
|
|
31
35
|
const SNAP_MS = 320;
|
|
32
36
|
const SNAP_EASE = "cubic-bezier(0.32, 0.9, 0.3, 1)";
|
|
@@ -131,11 +135,16 @@ export interface SelectionSheetProps {
|
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
137
|
* The mobile multi-select surface: a peeking bottom sheet that teases at ~56px
|
|
134
|
-
* with the selection count, and expands by drag or tap to
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
138
|
+
* with the selection count, and expands by drag or tap to carry the bulk verbs
|
|
139
|
+
* (Delete / Move / Junk), the select-similar → organize entries, and every
|
|
140
|
+
* escalation state (counting, running progress, partial-failure) the selection
|
|
141
|
+
* can be in. Drag or tap the grabber to collapse back to the teaser; the
|
|
142
|
+
* selection is untouched.
|
|
143
|
+
*
|
|
144
|
+
* Expanded, it takes the height its own content needs, up to
|
|
145
|
+
* {@link EXPANDED_MAX_DVH}% of the dynamic viewport, past which the actions
|
|
146
|
+
* scroll. A fixed ceiling sat below the content on a short phone viewport and
|
|
147
|
+
* cut the last action off with no way to reach it (#405).
|
|
139
148
|
*
|
|
140
149
|
* Sits absolutely against the bottom of the nearest positioned ancestor, so the
|
|
141
150
|
* list it belongs to must be a `relative` container and pad its own bottom by
|
|
@@ -160,7 +169,9 @@ export function SelectionSheet({
|
|
|
160
169
|
}: SelectionSheetProps) {
|
|
161
170
|
const [expanded, setExpanded] = useState(startExpanded);
|
|
162
171
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
163
|
-
const [expandedHeight, setExpandedHeight] = useState(
|
|
172
|
+
const [expandedHeight, setExpandedHeight] = useState(
|
|
173
|
+
EXPANDED_HEIGHT_FALLBACK,
|
|
174
|
+
);
|
|
164
175
|
|
|
165
176
|
// A run or a live count owns the sheet: it stays open so the progress and
|
|
166
177
|
// status can't be dragged out of sight mid-operation.
|
|
@@ -265,7 +276,7 @@ export function SelectionSheet({
|
|
|
265
276
|
data-selection-sheet=""
|
|
266
277
|
className="absolute inset-x-0 bottom-0 z-30 flex select-none flex-col rounded-t-2xl border-t border-line bg-surface shadow-2xl shadow-black/40"
|
|
267
278
|
style={{
|
|
268
|
-
maxHeight:
|
|
279
|
+
maxHeight: `${EXPANDED_MAX_DVH}dvh`,
|
|
269
280
|
minHeight: `${SELECTION_SHEET_TEASER_HEIGHT}px`,
|
|
270
281
|
transform: `translateY(${translate}px)`,
|
|
271
282
|
transition,
|
|
@@ -355,10 +366,12 @@ export function SelectionSheet({
|
|
|
355
366
|
|
|
356
367
|
{/* Expanded content — clipped by the translate when collapsed. It stays
|
|
357
368
|
in the DOM at the teaser, so `inert` when collapsed keeps its offscreen
|
|
358
|
-
verbs out of the tab order and the a11y tree until the sheet opens.
|
|
369
|
+
verbs out of the tab order and the a11y tree until the sheet opens.
|
|
370
|
+
Scrolls rather than clips, so every verb stays reachable on a viewport
|
|
371
|
+
the actions don't fit in (#405). */}
|
|
359
372
|
<div
|
|
360
373
|
inert={!expanded ? true : undefined}
|
|
361
|
-
className="flex min-h-0 flex-1 flex-col overflow-
|
|
374
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4"
|
|
362
375
|
>
|
|
363
376
|
{progress && (
|
|
364
377
|
<div className="mb-3">
|
|
@@ -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
|
+
});
|
package/src/filter-presets.ts
CHANGED
|
@@ -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
|
+
});
|