@remit/ui 0.0.11 → 0.0.13
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 +7 -0
- package/src/components/app-shell.render.test.ts +18 -3
- package/src/components/app-shell.tsx +5 -0
- package/src/components/app-top-bar.stories.tsx +67 -6
- package/src/components/mail-header.stories.tsx +40 -0
- package/src/components/message-list-pane.stories.tsx +30 -2
- package/src/components/selection-top-bar.render.test.ts +89 -0
- package/src/components/selection-top-bar.stories.tsx +66 -0
- package/src/components/selection-top-bar.tsx +49 -1
- package/src/components/swipeable-row.stories.tsx +14 -0
- package/src/components/touch-list-body.stories.tsx +19 -0
package/package.json
CHANGED
|
@@ -308,6 +308,13 @@ export interface AppShellProps {
|
|
|
308
308
|
* fits or when the message view is showing.
|
|
309
309
|
*/
|
|
310
310
|
initialTouchState?: TouchSeed;
|
|
311
|
+
/**
|
|
312
|
+
* Replaces the narrow-width list pane header when a selection is active —
|
|
313
|
+
* forwarded to `MessageListPane`'s `selectionBar` slot. The caller owns
|
|
314
|
+
* selection state and toolbar actions; when omitted, `initialTouchState`
|
|
315
|
+
* still drives the pane's own built-in touch-triage bar.
|
|
316
|
+
*/
|
|
317
|
+
selectionBar?: ReactNode;
|
|
311
318
|
intelligence?: IntelligenceData;
|
|
312
319
|
/** Pane 4 visible. Defaults to true when intelligence is present. */
|
|
313
320
|
intelligenceOpen?: boolean;
|
|
@@ -300,9 +300,7 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
|
|
|
300
300
|
/aria-label="Cancel selection"/,
|
|
301
301
|
"the selection bar is shown",
|
|
302
302
|
);
|
|
303
|
-
|
|
304
|
-
assert.match(html, /messages<!-- --> selected/, "selection wording shown");
|
|
305
|
-
assert.match(html, />2<!-- -->/, "the seeded count of 2 is shown");
|
|
303
|
+
assert.match(html, /2 messages selected/, "selection wording shown");
|
|
306
304
|
// Selection mode is not a swipe — no action zones revealed.
|
|
307
305
|
assert.equal(
|
|
308
306
|
count(html, trailingAction),
|
|
@@ -345,6 +343,23 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
|
|
|
345
343
|
);
|
|
346
344
|
});
|
|
347
345
|
|
|
346
|
+
it("selectionBar overrides the built-in bar and forwards to the list pane", () => {
|
|
347
|
+
const html = render({
|
|
348
|
+
...touchBase,
|
|
349
|
+
selectionBar: createElement(
|
|
350
|
+
"div",
|
|
351
|
+
{ "data-testid": "custom-bar" },
|
|
352
|
+
"Deleting 1,200 of 3,412…",
|
|
353
|
+
),
|
|
354
|
+
});
|
|
355
|
+
assert.match(html, /Deleting 1,200 of 3,412…/, "the override renders");
|
|
356
|
+
assert.doesNotMatch(
|
|
357
|
+
html,
|
|
358
|
+
/aria-label="Cancel selection"/,
|
|
359
|
+
"the built-in bar is not also rendered",
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
348
363
|
it("ignores the touch seed at/above 1024 (desktop list)", () => {
|
|
349
364
|
const html = render({
|
|
350
365
|
...touchBase,
|
|
@@ -70,6 +70,7 @@ export function AppShell({
|
|
|
70
70
|
thread,
|
|
71
71
|
initialNarrowView = "list",
|
|
72
72
|
initialTouchState,
|
|
73
|
+
selectionBar,
|
|
73
74
|
intelligence,
|
|
74
75
|
intelligenceOpen = true,
|
|
75
76
|
density,
|
|
@@ -126,6 +127,7 @@ export function AppShell({
|
|
|
126
127
|
}}
|
|
127
128
|
onSelectBriefCategory={selectCategory}
|
|
128
129
|
initialTouchState={initialTouchState}
|
|
130
|
+
selectionBar={selectionBar}
|
|
129
131
|
/>
|
|
130
132
|
);
|
|
131
133
|
|
|
@@ -185,6 +187,7 @@ function AppShellList({
|
|
|
185
187
|
onSelectThread,
|
|
186
188
|
onSelectBriefCategory,
|
|
187
189
|
initialTouchState,
|
|
190
|
+
selectionBar,
|
|
188
191
|
}: Pick<
|
|
189
192
|
AppShellProps,
|
|
190
193
|
| "thread"
|
|
@@ -202,6 +205,7 @@ function AppShellList({
|
|
|
202
205
|
| "density"
|
|
203
206
|
| "onSelectThread"
|
|
204
207
|
| "initialTouchState"
|
|
208
|
+
| "selectionBar"
|
|
205
209
|
> & {
|
|
206
210
|
narrowView: NarrowView;
|
|
207
211
|
onBackToList: () => void;
|
|
@@ -244,6 +248,7 @@ function AppShellList({
|
|
|
244
248
|
onOpenNav={showNavPane ? undefined : layout?.openNav}
|
|
245
249
|
isDesktop={showReadingPane}
|
|
246
250
|
initialTouchState={initialTouchState}
|
|
251
|
+
selectionBar={selectionBar}
|
|
247
252
|
/>
|
|
248
253
|
);
|
|
249
254
|
}
|
|
@@ -51,7 +51,25 @@ const Actions = () => (
|
|
|
51
51
|
|
|
52
52
|
const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The placeholders the app pairs with each scope state. Only the unscoped brief
|
|
56
|
+
* may claim to search all mail; a scoped view says so, and a mailbox route
|
|
57
|
+
* whose name has not loaded yet gets neutral wording rather than a placeholder
|
|
58
|
+
* asserting the wrong scope.
|
|
59
|
+
*/
|
|
60
|
+
const PLACEHOLDER = {
|
|
61
|
+
global: "Search all mail",
|
|
62
|
+
pending: "Search mail",
|
|
63
|
+
scoped: "Search this folder",
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
const Bar = ({
|
|
67
|
+
initialChips = [],
|
|
68
|
+
placeholder = PLACEHOLDER.global,
|
|
69
|
+
}: {
|
|
70
|
+
initialChips?: SearchChip[];
|
|
71
|
+
placeholder?: string;
|
|
72
|
+
}) => {
|
|
55
73
|
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
56
74
|
const [value, setValue] = useState("");
|
|
57
75
|
return (
|
|
@@ -71,7 +89,7 @@ const Bar = ({ initialChips = [] }: { initialChips?: SearchChip[] }) => {
|
|
|
71
89
|
}}
|
|
72
90
|
onClearQuery={() => setValue("")}
|
|
73
91
|
globalFocusKey={false}
|
|
74
|
-
placeholder=
|
|
92
|
+
placeholder={placeholder}
|
|
75
93
|
/>
|
|
76
94
|
}
|
|
77
95
|
/>
|
|
@@ -96,24 +114,67 @@ const WithPanes = ({ children }: { children: React.ReactNode }) => (
|
|
|
96
114
|
</div>
|
|
97
115
|
);
|
|
98
116
|
|
|
99
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* The daily brief's state: search unscoped, nothing narrowing it. No chip, and
|
|
119
|
+
* the only placeholder allowed to claim it searches all mail — which it now
|
|
120
|
+
* genuinely does, across every folder of every account.
|
|
121
|
+
*/
|
|
100
122
|
export const Unscoped: Story = {
|
|
101
123
|
render: () => <Bar />,
|
|
102
124
|
};
|
|
103
125
|
|
|
104
126
|
/**
|
|
105
127
|
* A narrowing scope in the bar, tinted to mark it as the view the user is in
|
|
106
|
-
* rather than a filter they typed. Removing it widens the search again
|
|
128
|
+
* rather than a filter they typed. Removing it widens the search again — a
|
|
129
|
+
* navigation back to the brief, not an edit of the text, because the chip
|
|
130
|
+
* mirrors the route.
|
|
131
|
+
*
|
|
132
|
+
* The placeholder narrows with the chip. A scoped bar reading "Search all mail"
|
|
133
|
+
* is a state the app never produces.
|
|
107
134
|
*/
|
|
108
135
|
export const Scoped: Story = {
|
|
109
|
-
render: () => <Bar initialChips={[SCOPE]} />,
|
|
136
|
+
render: () => <Bar initialChips={[SCOPE]} placeholder={PLACEHOLDER.scoped} />,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The third scope state: a mailbox route whose name has not resolved yet. The
|
|
141
|
+
* list underneath is already narrowed, so the bar must not claim to search
|
|
142
|
+
* everything — but a chip reading a raw uuid is worse than no chip, so it shows
|
|
143
|
+
* none and falls back to neutral wording until the name arrives.
|
|
144
|
+
*/
|
|
145
|
+
export const ScopePending: Story = {
|
|
146
|
+
render: () => <Bar placeholder={PLACEHOLDER.pending} />,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The virtual collections scope the bar too, and their chips read as whatever
|
|
151
|
+
* describes the collection. Flagged is a marker on the mail rather than a
|
|
152
|
+
* place, so it chips `is:starred`; the outbox is a place mail sits in and keeps
|
|
153
|
+
* the `in:` form.
|
|
154
|
+
*/
|
|
155
|
+
export const ScopedToFlagged: Story = {
|
|
156
|
+
render: () => (
|
|
157
|
+
<Bar
|
|
158
|
+
initialChips={[{ id: "is:starred", label: "is:starred", tone: "scope" }]}
|
|
159
|
+
placeholder={PLACEHOLDER.scoped}
|
|
160
|
+
/>
|
|
161
|
+
),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const ScopedToOutbox: Story = {
|
|
165
|
+
render: () => (
|
|
166
|
+
<Bar
|
|
167
|
+
initialChips={[{ id: "in:outbox", label: "in:outbox", tone: "scope" }]}
|
|
168
|
+
placeholder={PLACEHOLDER.scoped}
|
|
169
|
+
/>
|
|
170
|
+
),
|
|
110
171
|
};
|
|
111
172
|
|
|
112
173
|
/** The arrangement: one bar over the nav, the list, and the message pane. */
|
|
113
174
|
export const OverTheLayout: Story = {
|
|
114
175
|
render: () => (
|
|
115
176
|
<WithPanes>
|
|
116
|
-
<Bar initialChips={[SCOPE]} />
|
|
177
|
+
<Bar initialChips={[SCOPE]} placeholder={PLACEHOLDER.scoped} />
|
|
117
178
|
</WithPanes>
|
|
118
179
|
),
|
|
119
180
|
};
|
|
@@ -59,6 +59,7 @@ function MailScreen({
|
|
|
59
59
|
initialExpanded = false,
|
|
60
60
|
initialSearchOpen = false,
|
|
61
61
|
initialSearchValue = "",
|
|
62
|
+
showSearch = true,
|
|
62
63
|
}: {
|
|
63
64
|
title: string;
|
|
64
65
|
unreadCount: number;
|
|
@@ -66,6 +67,7 @@ function MailScreen({
|
|
|
66
67
|
initialExpanded?: boolean;
|
|
67
68
|
initialSearchOpen?: boolean;
|
|
68
69
|
initialSearchValue?: string;
|
|
70
|
+
showSearch?: boolean;
|
|
69
71
|
}) {
|
|
70
72
|
const [searchValue, setSearchValue] = useState(initialSearchValue);
|
|
71
73
|
const [searchOpen, setSearchOpen] = useState(initialSearchOpen);
|
|
@@ -85,6 +87,7 @@ function MailScreen({
|
|
|
85
87
|
title={title}
|
|
86
88
|
unreadCount={unreadCount}
|
|
87
89
|
isDesktop={false}
|
|
90
|
+
showSearch={showSearch}
|
|
88
91
|
onMenuClick={() => undefined}
|
|
89
92
|
searchValue={searchValue}
|
|
90
93
|
onSearchChange={setSearchValue}
|
|
@@ -188,6 +191,43 @@ export const InboxFilterExpanded: Story = {
|
|
|
188
191
|
),
|
|
189
192
|
};
|
|
190
193
|
|
|
194
|
+
/**
|
|
195
|
+
* `showSearch={false}` — the header on desktop, where the app's top bar owns
|
|
196
|
+
* the search field for the whole shell. Title and unread count only: no
|
|
197
|
+
* magnifier, nothing to expand. Two search inputs on one page would compete
|
|
198
|
+
* for focus and for the "/" shortcut, so the header yields.
|
|
199
|
+
*
|
|
200
|
+
* This is what a desktop mail pane renders. Every story below it is the
|
|
201
|
+
* below-desktop case, where there is no top bar and the header keeps its own
|
|
202
|
+
* compact field.
|
|
203
|
+
*/
|
|
204
|
+
export const SearchOwnedByTopBar: Story = {
|
|
205
|
+
render: () => (
|
|
206
|
+
<MailScreen
|
|
207
|
+
title="Daily brief"
|
|
208
|
+
unreadCount={15338}
|
|
209
|
+
preset={briefFilterConfig(accounts)}
|
|
210
|
+
showSearch={false}
|
|
211
|
+
/>
|
|
212
|
+
),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The same header for a mailbox rather than the brief — still no search
|
|
217
|
+
* affordance, because whether the header owns one is a property of the layout,
|
|
218
|
+
* not of which list is under it.
|
|
219
|
+
*/
|
|
220
|
+
export const SearchOwnedByTopBarInbox: Story = {
|
|
221
|
+
render: () => (
|
|
222
|
+
<MailScreen
|
|
223
|
+
title="Inbox"
|
|
224
|
+
unreadCount={42}
|
|
225
|
+
preset={inboxFilterConfig()}
|
|
226
|
+
showSearch={false}
|
|
227
|
+
/>
|
|
228
|
+
),
|
|
229
|
+
};
|
|
230
|
+
|
|
191
231
|
/** Mobile search collapsed to a magnifier in the header top row. */
|
|
192
232
|
export const MobileSearchCollapsed: Story = {
|
|
193
233
|
render: () => (
|
|
@@ -212,8 +212,15 @@ export const CustomListBody: Story = {
|
|
|
212
212
|
decorators: [desktopFrame],
|
|
213
213
|
};
|
|
214
214
|
|
|
215
|
-
/**
|
|
216
|
-
*
|
|
215
|
+
/**
|
|
216
|
+
* External `selectionBar` slot mechanism, exercised at desktop width with
|
|
217
|
+
* `SelectionTopBar` as a convenient stand-in node — any slot content works
|
|
218
|
+
* here, the point is that the pane header is replaced. This is NOT a
|
|
219
|
+
* production composition: the live desktop toolbar is `SelectionToolbar`
|
|
220
|
+
* (web-client only, not in this kit); `MessageList.tsx` only ever puts
|
|
221
|
+
* `SelectionTopBar` in this slot when `!isDesktop` — see `NarrowExternalSelectionBar`
|
|
222
|
+
* below for that production-accurate case.
|
|
223
|
+
*/
|
|
217
224
|
export const ExternalSelectionBar: Story = {
|
|
218
225
|
args: {
|
|
219
226
|
isDesktop: true,
|
|
@@ -230,6 +237,27 @@ export const ExternalSelectionBar: Story = {
|
|
|
230
237
|
decorators: [desktopFrame],
|
|
231
238
|
};
|
|
232
239
|
|
|
240
|
+
/**
|
|
241
|
+
* The production composition (`MessageList.tsx:798` gates on `!isDesktop`):
|
|
242
|
+
* `SelectionTopBar` in the `selectionBar` slot at narrow width. Desktop never
|
|
243
|
+
* renders this component in the slot — only `NarrowTouchList`'s width does.
|
|
244
|
+
*/
|
|
245
|
+
export const NarrowExternalSelectionBar: Story = {
|
|
246
|
+
args: {
|
|
247
|
+
isDesktop: false,
|
|
248
|
+
flatList: true,
|
|
249
|
+
selectionBar: (
|
|
250
|
+
<SelectionTopBar
|
|
251
|
+
count={2}
|
|
252
|
+
onCancel={() => undefined}
|
|
253
|
+
onMarkRead={() => undefined}
|
|
254
|
+
onDelete={() => undefined}
|
|
255
|
+
/>
|
|
256
|
+
),
|
|
257
|
+
},
|
|
258
|
+
decorators: [narrowFrame],
|
|
259
|
+
};
|
|
260
|
+
|
|
233
261
|
/** Fail-loud error state — the specific failure detail is surfaced under the
|
|
234
262
|
* headline (not a bare "something went wrong"), with a way back (Retry) and a
|
|
235
263
|
* place for the failure to go (Report a problem). */
|
|
@@ -64,4 +64,93 @@ describe("SelectionTopBar", () => {
|
|
|
64
64
|
);
|
|
65
65
|
assert.match(html, /Cross-account moves are not supported/);
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
it("renders statusLabel in place of the count copy when provided", () => {
|
|
69
|
+
const html = renderToString(
|
|
70
|
+
createElement(SelectionTopBar, {
|
|
71
|
+
...handlers,
|
|
72
|
+
count: 3412,
|
|
73
|
+
statusLabel: "Deleting 1,200 of 3,412…",
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
assert.match(text(html), /Deleting 1,200 of 3,412…/);
|
|
77
|
+
assert.doesNotMatch(text(html), /3412 messages selected/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back to the count copy when statusLabel is absent", () => {
|
|
81
|
+
const html = renderToString(
|
|
82
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
83
|
+
);
|
|
84
|
+
assert.match(text(html), /2 messages selected/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("renders failureHint when provided", () => {
|
|
88
|
+
const html = renderToString(
|
|
89
|
+
createElement(SelectionTopBar, {
|
|
90
|
+
...handlers,
|
|
91
|
+
count: 2,
|
|
92
|
+
failureHint: "340 failed to delete — retry?",
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
assert.match(html, /340 failed to delete — retry\?/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("omits the select-all control when selectAll is absent", () => {
|
|
99
|
+
const html = renderToString(
|
|
100
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
101
|
+
);
|
|
102
|
+
assert.doesNotMatch(html, /aria-label="Select all"/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("renders the select-all control, unchecked, in the some-selected state", () => {
|
|
106
|
+
const html = renderToString(
|
|
107
|
+
createElement(SelectionTopBar, {
|
|
108
|
+
...handlers,
|
|
109
|
+
count: 2,
|
|
110
|
+
selectAll: {
|
|
111
|
+
checked: false,
|
|
112
|
+
indeterminate: true,
|
|
113
|
+
onChange: () => undefined,
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
assert.match(html, /aria-label="Select all"/);
|
|
118
|
+
assert.doesNotMatch(
|
|
119
|
+
html,
|
|
120
|
+
/aria-label="Select all"[^>]*checked=""/,
|
|
121
|
+
"some-selected is not the checked state",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders the select-all control checked in the all-selected state", () => {
|
|
126
|
+
const html = renderToString(
|
|
127
|
+
createElement(SelectionTopBar, {
|
|
128
|
+
...handlers,
|
|
129
|
+
count: 12,
|
|
130
|
+
selectAll: {
|
|
131
|
+
checked: true,
|
|
132
|
+
indeterminate: false,
|
|
133
|
+
onChange: () => undefined,
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
assert.match(
|
|
138
|
+
html,
|
|
139
|
+
/aria-label="Select all"[^>]*checked=""/,
|
|
140
|
+
"all-selected renders the checkbox checked",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("omitting every new prop renders exactly the pre-existing bar", () => {
|
|
145
|
+
const html = renderToString(
|
|
146
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
147
|
+
);
|
|
148
|
+
assert.doesNotMatch(
|
|
149
|
+
html,
|
|
150
|
+
/aria-label="Select all"/,
|
|
151
|
+
"no select-all control",
|
|
152
|
+
);
|
|
153
|
+
assert.match(text(html), /2 messages selected/, "default count copy");
|
|
154
|
+
assert.doesNotMatch(html, /role="status"/, "no status line rendered");
|
|
155
|
+
});
|
|
67
156
|
});
|
|
@@ -37,3 +37,69 @@ export const CrossAccountHint: Story = {
|
|
|
37
37
|
"Move only works within one account — clear selection or pick messages from a single account",
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
|
+
|
|
41
|
+
/** Some but not all rows checked: the select-all control renders the
|
|
42
|
+
* `Checkbox` tri-state dash, not the box or the tick. */
|
|
43
|
+
export const SelectAll: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
count: 3,
|
|
46
|
+
selectAll: {
|
|
47
|
+
checked: false,
|
|
48
|
+
indeterminate: true,
|
|
49
|
+
onChange: () => undefined,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Every row checked: the select-all control renders as a plain checked box. */
|
|
55
|
+
export const AllSelected: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
count: 12,
|
|
58
|
+
selectAll: {
|
|
59
|
+
checked: true,
|
|
60
|
+
indeterminate: false,
|
|
61
|
+
onChange: () => undefined,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** While a search result set is still paging, the exact count isn't known yet —
|
|
67
|
+
* `statusLabel` replaces the "{count} selected" text with a counting message. */
|
|
68
|
+
export const Counting: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
count: 0,
|
|
71
|
+
statusLabel: "Counting matching messages…",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** A bulk delete in progress reports a running total via `statusLabel`, and
|
|
76
|
+
* the delete button shows its busy spinner (never disables). */
|
|
77
|
+
export const DeletingWithProgress: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
count: 3412,
|
|
80
|
+
statusLabel: "Deleting 1,200 of 3,412…",
|
|
81
|
+
isBusy: true,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** After a bulk delete finishes with some batches failed, `failureHint`
|
|
86
|
+
* surfaces the shortfall in danger tone — independent of `moveDisabledHint`,
|
|
87
|
+
* which is muted and cross-account-specific. */
|
|
88
|
+
export const PartialFailure: Story = {
|
|
89
|
+
args: {
|
|
90
|
+
count: 3412,
|
|
91
|
+
failureHint: "3,072 deleted, 340 failed to delete — retry?",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* count === 0 is unreachable in production: both the kit
|
|
97
|
+
* (`message-list-pane.tsx`'s `toggleCheck`) and the web-client
|
|
98
|
+
* (`MessageList.tsx`'s multi-select effect) auto-exit selection mode the
|
|
99
|
+
* instant the last checked row is unchecked. `SelectionTopBar` itself has no
|
|
100
|
+
* floor on `count` — this story pins the contract that no caller should ever
|
|
101
|
+
* leave it mounted here.
|
|
102
|
+
*/
|
|
103
|
+
export const ZeroSelected: Story = {
|
|
104
|
+
args: { count: 0 },
|
|
105
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Loader2, MailOpen, Trash2, X } from "lucide-react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
|
+
import { Checkbox } from "./checkbox.js";
|
|
4
5
|
|
|
5
6
|
export interface SelectionTopBarProps {
|
|
6
7
|
count: number;
|
|
@@ -23,6 +24,30 @@ export interface SelectionTopBarProps {
|
|
|
23
24
|
* shows a spinner; other actions no-op. Never disables controls.
|
|
24
25
|
*/
|
|
25
26
|
isBusy?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Select-all control rendered between cancel and the count label. Presence
|
|
29
|
+
* of this prop is what renders the checkbox — omit it for a bar with no
|
|
30
|
+
* select-all affordance. `indeterminate` renders the some-selected tri-state
|
|
31
|
+
* (`Checkbox`'s dash), `checked` is the all-selected state.
|
|
32
|
+
*/
|
|
33
|
+
selectAll?: {
|
|
34
|
+
checked: boolean;
|
|
35
|
+
indeterminate?: boolean;
|
|
36
|
+
onChange: () => void;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Overrides the "{count} messages selected" text. For states where the
|
|
40
|
+
* count itself isn't the useful thing to show yet — "Counting…" while a
|
|
41
|
+
* search result set is still paging, or "Deleting 1,200 of 3,412…" progress
|
|
42
|
+
* during a bulk delete.
|
|
43
|
+
*/
|
|
44
|
+
statusLabel?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Danger-toned status line below the action row, for a partial failure
|
|
47
|
+
* after a bulk operation (e.g. some batches failed to delete). Independent
|
|
48
|
+
* of `moveDisabledHint` — a caller shows one or the other, never both.
|
|
49
|
+
*/
|
|
50
|
+
failureHint?: string;
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
/**
|
|
@@ -37,6 +62,9 @@ export function SelectionTopBar({
|
|
|
37
62
|
moveSlot,
|
|
38
63
|
moveDisabledHint,
|
|
39
64
|
isBusy = false,
|
|
65
|
+
selectAll,
|
|
66
|
+
statusLabel,
|
|
67
|
+
failureHint,
|
|
40
68
|
}: SelectionTopBarProps) {
|
|
41
69
|
return (
|
|
42
70
|
<header className="flex shrink-0 flex-col border-b border-line bg-surface-sunken">
|
|
@@ -49,8 +77,18 @@ export function SelectionTopBar({
|
|
|
49
77
|
aria-label="Cancel selection"
|
|
50
78
|
className="-ml-1 shrink-0"
|
|
51
79
|
/>
|
|
80
|
+
{selectAll && (
|
|
81
|
+
<Checkbox
|
|
82
|
+
aria-label="Select all"
|
|
83
|
+
checked={selectAll.checked}
|
|
84
|
+
indeterminate={selectAll.indeterminate}
|
|
85
|
+
onChange={selectAll.onChange}
|
|
86
|
+
className="shrink-0"
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
52
89
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
|
53
|
-
{
|
|
90
|
+
{statusLabel ??
|
|
91
|
+
`${count} ${count === 1 ? "message" : "messages"} selected`}
|
|
54
92
|
</span>
|
|
55
93
|
{onMarkRead && (
|
|
56
94
|
<Button
|
|
@@ -90,6 +128,16 @@ export function SelectionTopBar({
|
|
|
90
128
|
{moveDisabledHint}
|
|
91
129
|
</p>
|
|
92
130
|
)}
|
|
131
|
+
{failureHint && (
|
|
132
|
+
// biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
|
|
133
|
+
<p
|
|
134
|
+
className="px-row-inset pb-2 text-xs text-danger"
|
|
135
|
+
role="status"
|
|
136
|
+
aria-live="polite"
|
|
137
|
+
>
|
|
138
|
+
{failureHint}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
93
141
|
</header>
|
|
94
142
|
);
|
|
95
143
|
}
|
|
@@ -55,6 +55,20 @@ export const PeekedLeading: Story = { args: { peek: "leading" } };
|
|
|
55
55
|
|
|
56
56
|
export const PeekedTrailing: Story = { args: { peek: "trailing" } };
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* In selection mode the leading avatar is REPLACED by a checkbox affordance
|
|
60
|
+
* — unchecked below, checked in the next story. `baseArgs` never flips
|
|
61
|
+
* `selectionMode`/`checked`, so this row-level toggle had zero coverage.
|
|
62
|
+
*/
|
|
63
|
+
export const SelectionUnchecked: Story = {
|
|
64
|
+
args: { peek: "none", selectionMode: true, checked: false },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Selection mode, row checked: the circle fills accent and shows a tick. */
|
|
68
|
+
export const SelectionChecked: Story = {
|
|
69
|
+
args: { peek: "none", selectionMode: true, checked: true },
|
|
70
|
+
};
|
|
71
|
+
|
|
58
72
|
/**
|
|
59
73
|
* The open affordance is rendered as a real `<a href>` via `linkComponent`,
|
|
60
74
|
* so deep-link, middle-click and open-in-new-tab work. Consumers pass their
|
|
@@ -74,3 +74,22 @@ export const Refreshing: Story = { args: { refreshing: true } };
|
|
|
74
74
|
export const SelectionMode: Story = {
|
|
75
75
|
args: { selectionMode: true, checkedIds: new Set(["t1", "t3"]) },
|
|
76
76
|
};
|
|
77
|
+
|
|
78
|
+
/** Every row checked — the ceiling a select-all control drives toward. */
|
|
79
|
+
export const SelectionModeAllChecked: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
selectionMode: true,
|
|
82
|
+
checkedIds: new Set(
|
|
83
|
+
sections.flatMap((section) => section.threads.map((t) => t.id)),
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Selection mode with nothing checked. `TouchListBody` itself has no floor —
|
|
90
|
+
* the auto-exit-at-zero contract belongs to the caller (`MessageListPane`,
|
|
91
|
+
* production `MessageList.tsx`), which this component doesn't own.
|
|
92
|
+
*/
|
|
93
|
+
export const SelectionModeNoneChecked: Story = {
|
|
94
|
+
args: { selectionMode: true, checkedIds: new Set<string>() },
|
|
95
|
+
};
|