@remit/ui 0.0.74 → 0.0.76
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 +35 -11
- package/src/components/app-shell.render.test.ts +25 -15
- package/src/components/app-shell.tsx +5 -0
- package/src/components/brief-sections.tsx +63 -7
- package/src/components/filter-sheet.render.test.ts +12 -1
- package/src/components/filter-sheet.tsx +35 -6
- package/src/components/message-list-pane.render.test.ts +22 -0
- package/src/components/message-list-pane.stories.tsx +101 -47
- package/src/components/message-list-pane.tsx +79 -115
- package/src/components/message-row.tsx +8 -3
- package/src/components/mobile-search-view.render.test.ts +21 -0
- package/src/components/mobile-search-view.tsx +8 -2
- package/src/components/touch-list.follows-sections.test.ts +140 -0
- package/src/components/touch-list.tsx +16 -8
- package/src/index.ts +17 -0
- package/src/lib/use-selection.hook.test.ts +172 -0
- package/src/lib/use-selection.test.ts +331 -0
- package/src/lib/use-selection.ts +334 -0
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useRef,
|
|
7
7
|
useState,
|
|
8
8
|
} from "react";
|
|
9
|
+
import type { SelectionModifiers } from "../lib/use-selection.js";
|
|
9
10
|
import type {
|
|
10
11
|
IntelligenceData,
|
|
11
12
|
SenderTrustLevel,
|
|
@@ -58,9 +59,30 @@ export function resolvePaneLayout(
|
|
|
58
59
|
|
|
59
60
|
export type NarrowView = "list" | "message";
|
|
60
61
|
|
|
61
|
-
/** Seeds the narrow touch list
|
|
62
|
-
* render
|
|
63
|
-
export type TouchSeed = "
|
|
62
|
+
/** Seeds the narrow touch list with a swipe-peeked row for stories / SSR, so it
|
|
63
|
+
* can render that state without a live gesture. */
|
|
64
|
+
export type TouchSeed = "peek-trailing" | "peek-leading";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The list's selection, owned by whoever renders the list. The kit holds none
|
|
68
|
+
* of it: a surface that offers multi-select already has the selected set, the
|
|
69
|
+
* range anchor and the verbs the bar runs, and one set of rows can only ever
|
|
70
|
+
* answer to one of them.
|
|
71
|
+
*
|
|
72
|
+
* `useSelection` is the model both the app and the kit's own stories drive this
|
|
73
|
+
* from.
|
|
74
|
+
*/
|
|
75
|
+
export interface MessageListSelection {
|
|
76
|
+
/** The ticked rows. */
|
|
77
|
+
selectedIds: ReadonlySet<string>;
|
|
78
|
+
/** Ticks or unticks one row — its checkbox, and a long press on touch. */
|
|
79
|
+
onToggle: (id: string) => void;
|
|
80
|
+
/**
|
|
81
|
+
* A row click and the modifiers it carried. True when selection took the
|
|
82
|
+
* click, in which case the row does not open.
|
|
83
|
+
*/
|
|
84
|
+
onRowSelect?: (id: string, modifiers: SelectionModifiers) => boolean;
|
|
85
|
+
}
|
|
64
86
|
|
|
65
87
|
/**
|
|
66
88
|
* Measures an element's OWN width via ResizeObserver — a container query, not a
|
|
@@ -317,19 +339,21 @@ export interface AppShellProps {
|
|
|
317
339
|
*/
|
|
318
340
|
initialNarrowView?: NarrowView;
|
|
319
341
|
/**
|
|
320
|
-
* Seed the narrow touch list
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
342
|
+
* Seed the narrow touch list with a swipe-peeked row, so a story / SSR can
|
|
343
|
+
* show that triage state statically. Only meaningful below the reading
|
|
344
|
+
* boundary; ignored once the reading pane fits or when the message view is
|
|
345
|
+
* showing.
|
|
324
346
|
*/
|
|
325
347
|
initialTouchState?: TouchSeed;
|
|
326
348
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
349
|
+
* The list header, forwarded to `MessageListPane`'s `selectionBar` slot. The
|
|
350
|
+
* caller mounts it for every state of the list: it names the view while
|
|
351
|
+
* nothing is ticked and carries the count and the verbs from the first
|
|
352
|
+
* ticked row.
|
|
331
353
|
*/
|
|
332
354
|
selectionBar?: ReactNode;
|
|
355
|
+
/** The list's selection, forwarded to `MessageListPane`. */
|
|
356
|
+
selection?: MessageListSelection;
|
|
333
357
|
intelligence?: IntelligenceData;
|
|
334
358
|
/** Pane 4 visible. Defaults to true when intelligence is present. */
|
|
335
359
|
intelligenceOpen?: boolean;
|
|
@@ -294,14 +294,19 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
|
|
|
294
294
|
);
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
-
it("
|
|
298
|
-
const html = render({
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
297
|
+
it("draws the consumer's selection on the touch rows", () => {
|
|
298
|
+
const html = render({
|
|
299
|
+
...touchBase,
|
|
300
|
+
selection: {
|
|
301
|
+
selectedIds: new Set(["r1", "r2"]),
|
|
302
|
+
onToggle: () => undefined,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
assert.equal(
|
|
306
|
+
count(html, /aria-checked="true"/g),
|
|
307
|
+
2,
|
|
308
|
+
"both ticked rows show a checked box",
|
|
303
309
|
);
|
|
304
|
-
assert.match(html, /2 messages selected/, "selection wording shown");
|
|
305
310
|
// Selection mode is not a swipe — no action zones revealed.
|
|
306
311
|
assert.equal(
|
|
307
312
|
count(html, trailingAction),
|
|
@@ -344,33 +349,38 @@ describe("AppShell touch-state seeds (story/SSR affordance)", () => {
|
|
|
344
349
|
);
|
|
345
350
|
});
|
|
346
351
|
|
|
347
|
-
it("
|
|
352
|
+
it("the selection bar is the list pane's header", () => {
|
|
348
353
|
const html = render({
|
|
349
354
|
...touchBase,
|
|
355
|
+
listTitle: "Inbox",
|
|
350
356
|
selectionBar: createElement(
|
|
351
357
|
"div",
|
|
352
358
|
{ "data-testid": "custom-bar" },
|
|
353
359
|
"Deleting 1,200 of 3,412…",
|
|
354
360
|
),
|
|
355
361
|
});
|
|
356
|
-
assert.match(html, /Deleting 1,200 of 3,412…/, "the
|
|
362
|
+
assert.match(html, /Deleting 1,200 of 3,412…/, "the bar renders");
|
|
357
363
|
assert.doesNotMatch(
|
|
358
364
|
html,
|
|
359
|
-
|
|
360
|
-
"the
|
|
365
|
+
/<h1[^>]*>Inbox<\/h1>/,
|
|
366
|
+
"the pane's own title header stands down",
|
|
361
367
|
);
|
|
362
368
|
});
|
|
363
369
|
|
|
364
|
-
it("
|
|
370
|
+
it("leaves the always-visible checkbox to the touch tier", () => {
|
|
365
371
|
const html = render({
|
|
366
372
|
...touchBase,
|
|
367
373
|
initialWidth: 1100,
|
|
368
|
-
|
|
374
|
+
selection: {
|
|
375
|
+
selectedIds: new Set(["r1"]),
|
|
376
|
+
onToggle: () => undefined,
|
|
377
|
+
},
|
|
369
378
|
});
|
|
379
|
+
assert.match(html, /aria-label="Deselect message"/, "the row is ticked");
|
|
370
380
|
assert.doesNotMatch(
|
|
371
381
|
html,
|
|
372
|
-
/
|
|
373
|
-
"
|
|
382
|
+
/translateX/,
|
|
383
|
+
"desktop rows carry no swipe-triage layer",
|
|
374
384
|
);
|
|
375
385
|
});
|
|
376
386
|
});
|
|
@@ -70,6 +70,7 @@ export function AppShell({
|
|
|
70
70
|
thread,
|
|
71
71
|
initialNarrowView = "list",
|
|
72
72
|
initialTouchState,
|
|
73
|
+
selection,
|
|
73
74
|
selectionBar,
|
|
74
75
|
intelligence,
|
|
75
76
|
intelligenceOpen = true,
|
|
@@ -127,6 +128,7 @@ export function AppShell({
|
|
|
127
128
|
}}
|
|
128
129
|
onSelectBriefCategory={selectCategory}
|
|
129
130
|
initialTouchState={initialTouchState}
|
|
131
|
+
selection={selection}
|
|
130
132
|
selectionBar={selectionBar}
|
|
131
133
|
/>
|
|
132
134
|
);
|
|
@@ -187,6 +189,7 @@ function AppShellList({
|
|
|
187
189
|
onSelectThread,
|
|
188
190
|
onSelectBriefCategory,
|
|
189
191
|
initialTouchState,
|
|
192
|
+
selection,
|
|
190
193
|
selectionBar,
|
|
191
194
|
}: Pick<
|
|
192
195
|
AppShellProps,
|
|
@@ -205,6 +208,7 @@ function AppShellList({
|
|
|
205
208
|
| "density"
|
|
206
209
|
| "onSelectThread"
|
|
207
210
|
| "initialTouchState"
|
|
211
|
+
| "selection"
|
|
208
212
|
| "selectionBar"
|
|
209
213
|
> & {
|
|
210
214
|
narrowView: NarrowView;
|
|
@@ -248,6 +252,7 @@ function AppShellList({
|
|
|
248
252
|
onOpenNav={showNavPane ? undefined : layout?.openNav}
|
|
249
253
|
isDesktop={showReadingPane}
|
|
250
254
|
initialTouchState={initialTouchState}
|
|
255
|
+
selection={selection}
|
|
251
256
|
selectionBar={selectionBar}
|
|
252
257
|
/>
|
|
253
258
|
);
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import type { BriefRowComponent } from "./message-row.js";
|
|
17
17
|
|
|
18
18
|
/* Composable brief filters — each is an additive predicate over a thread row. */
|
|
19
|
-
type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
|
|
19
|
+
export type BriefFilterId = "unread" | "attachment" | "contacts" | "today";
|
|
20
20
|
|
|
21
21
|
/* "Today" prefers the real `sentDate` timestamp; it falls back to the fixture
|
|
22
22
|
convention that same-day rows render a HH:MM timeLabel (fixtures carry no
|
|
@@ -56,7 +56,40 @@ export const briefFilterChips: FilterSheetFilter[] = briefFilterDefs.map(
|
|
|
56
56
|
({ id, label }) => ({ id, label }),
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Whether a thread survives a set of attribute chips, as the brief's own list
|
|
61
|
+
* applies them. Exported so a consumer narrowing the same rows on another
|
|
62
|
+
* surface — the phone search takeover — reads one definition of what "Unread" or
|
|
63
|
+
* "Today" means.
|
|
64
|
+
*/
|
|
65
|
+
export function matchesBriefFilters(
|
|
66
|
+
thread: ThreadRowData,
|
|
67
|
+
activeFilters: ReadonlySet<BriefFilterId>,
|
|
68
|
+
): boolean {
|
|
69
|
+
return briefFilterDefs.every(
|
|
70
|
+
(f) => !activeFilters.has(f.id) || f.match(thread),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The attribute chips are either this component's own or entirely the
|
|
76
|
+
* consumer's. A consumer narrowing the same rows on a second surface (the phone
|
|
77
|
+
* search takeover) holds the set so both surfaces answer to one selection, and
|
|
78
|
+
* takes every control over it with the set.
|
|
79
|
+
*/
|
|
80
|
+
type BriefFilterControl =
|
|
81
|
+
| {
|
|
82
|
+
activeFilters: ReadonlySet<BriefFilterId>;
|
|
83
|
+
onToggleFilter: (id: BriefFilterId) => void;
|
|
84
|
+
onClearFilters: () => void;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
activeFilters?: never;
|
|
88
|
+
onToggleFilter?: never;
|
|
89
|
+
onClearFilters?: never;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
interface BriefSectionsBaseProps {
|
|
60
93
|
sections: ThreadSection[];
|
|
61
94
|
briefCategory?: BriefCategoryFilter;
|
|
62
95
|
selectedThreadId?: string;
|
|
@@ -73,10 +106,17 @@ export interface BriefSectionsProps {
|
|
|
73
106
|
sourcesNote?: string;
|
|
74
107
|
/** Called when the user selects a source/account pill. */
|
|
75
108
|
onSelectSource?: (id: string) => void;
|
|
109
|
+
/**
|
|
110
|
+
* Drop the filter row and its panel, keeping the rows where they are. See
|
|
111
|
+
* `FilterSheetProps`.
|
|
112
|
+
*/
|
|
113
|
+
hideChrome?: boolean;
|
|
76
114
|
/** Seeds the filter panel open on first render (stories / deep links). */
|
|
77
115
|
defaultExpanded?: boolean;
|
|
78
116
|
}
|
|
79
117
|
|
|
118
|
+
export type BriefSectionsProps = BriefSectionsBaseProps & BriefFilterControl;
|
|
119
|
+
|
|
80
120
|
/**
|
|
81
121
|
* The daily-brief list body: category pills (single-select) + attribute chips
|
|
82
122
|
* (additive) + one capped section per category (see {@link BriefSection}). Owns
|
|
@@ -95,9 +135,15 @@ export function BriefSections({
|
|
|
95
135
|
sources,
|
|
96
136
|
sourcesNote,
|
|
97
137
|
onSelectSource,
|
|
138
|
+
activeFilters,
|
|
139
|
+
onToggleFilter,
|
|
140
|
+
onClearFilters,
|
|
141
|
+
hideChrome,
|
|
98
142
|
defaultExpanded = false,
|
|
99
143
|
}: BriefSectionsProps) {
|
|
100
|
-
const [
|
|
144
|
+
const [ownFilters, setOwnFilters] = useState<ReadonlySet<BriefFilterId>>(
|
|
145
|
+
new Set(),
|
|
146
|
+
);
|
|
101
147
|
const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
|
|
102
148
|
const listRef = useRef<HTMLDivElement>(null);
|
|
103
149
|
useRovingFocus({
|
|
@@ -105,8 +151,14 @@ export function BriefSections({
|
|
|
105
151
|
itemSelector: LIST_ROW_SELECTOR,
|
|
106
152
|
});
|
|
107
153
|
|
|
154
|
+
const active = activeFilters ?? ownFilters;
|
|
155
|
+
|
|
108
156
|
const toggleFilter = (id: BriefFilterId) => {
|
|
109
|
-
|
|
157
|
+
if (onToggleFilter) {
|
|
158
|
+
onToggleFilter(id);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
setOwnFilters((prev) => {
|
|
110
162
|
const next = new Set(prev);
|
|
111
163
|
if (next.has(id)) next.delete(id);
|
|
112
164
|
else next.add(id);
|
|
@@ -114,10 +166,9 @@ export function BriefSections({
|
|
|
114
166
|
});
|
|
115
167
|
};
|
|
116
168
|
|
|
117
|
-
const predicates = briefFilterDefs.filter((f) => active.has(f.id));
|
|
118
169
|
const matches = (t: ThreadRowData) =>
|
|
119
170
|
(briefCategory === "all" || t.category === briefCategory) &&
|
|
120
|
-
|
|
171
|
+
matchesBriefFilters(t, active);
|
|
121
172
|
|
|
122
173
|
// One section per category only earns its keep at the "all" scope. Narrow to
|
|
123
174
|
// a single category and the headers are redundant: render a plain flat list.
|
|
@@ -142,7 +193,11 @@ export function BriefSections({
|
|
|
142
193
|
|
|
143
194
|
const clearFilters = () => {
|
|
144
195
|
onSelectBriefCategory?.("all");
|
|
145
|
-
|
|
196
|
+
if (onClearFilters) {
|
|
197
|
+
onClearFilters();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
setOwnFilters(new Set());
|
|
146
201
|
};
|
|
147
202
|
|
|
148
203
|
const empty = showSections ? filtered.length === 0 : flatRows.length === 0;
|
|
@@ -200,6 +255,7 @@ export function BriefSections({
|
|
|
200
255
|
onSelectSource={onSelectSource}
|
|
201
256
|
onToggleFilter={(id) => toggleFilter(id as BriefFilterId)}
|
|
202
257
|
onClear={clearFilters}
|
|
258
|
+
hideChrome={hideChrome}
|
|
203
259
|
>
|
|
204
260
|
{listBody}
|
|
205
261
|
</FilterSheet>
|
|
@@ -85,7 +85,7 @@ describe("FilterSheet under a FilterPanelProvider", () => {
|
|
|
85
85
|
renderToString(
|
|
86
86
|
createElement(
|
|
87
87
|
FilterPanelProvider,
|
|
88
|
-
|
|
88
|
+
{ hasSheet: true },
|
|
89
89
|
createElement(FilterToggle),
|
|
90
90
|
createElement(FilterSheet, {
|
|
91
91
|
categories,
|
|
@@ -120,4 +120,15 @@ describe("FilterSheet under a FilterPanelProvider", () => {
|
|
|
120
120
|
it("renders nothing for a toggle with no panel above it", () => {
|
|
121
121
|
assert.equal(renderToString(createElement(FilterToggle)), "");
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
it("renders nothing while the provider reports no sheet", () => {
|
|
125
|
+
const html = renderToString(
|
|
126
|
+
createElement(
|
|
127
|
+
FilterPanelProvider,
|
|
128
|
+
{ hasSheet: false },
|
|
129
|
+
createElement(FilterToggle),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
assert.equal(html, "");
|
|
133
|
+
});
|
|
123
134
|
});
|
|
@@ -121,6 +121,8 @@ interface FilterPanelState {
|
|
|
121
121
|
/** Whether the sheet under this provider is narrowing the list right now. */
|
|
122
122
|
active: boolean;
|
|
123
123
|
setActive: (active: boolean) => void;
|
|
124
|
+
/** Whether a sheet is mounted for the caret to open. */
|
|
125
|
+
hasSheet: boolean;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
const FilterPanelCtx = createContext<FilterPanelState | null>(null);
|
|
@@ -130,29 +132,54 @@ const FilterPanelCtx = createContext<FilterPanelState | null>(null);
|
|
|
130
132
|
* under it, which are siblings in the tree. Wrap both: the header renders a
|
|
131
133
|
* `FilterToggle`, the sheet drops its own trigger row, and the sheet keeps its
|
|
132
134
|
* filter state exactly where it already lives.
|
|
135
|
+
*
|
|
136
|
+
* `hasSheet` states whether a sheet is mounted right now, and the caret stands
|
|
137
|
+
* down for as long as there is no panel behind it. It is required: a view whose
|
|
138
|
+
* body is sometimes something else — a skeleton, an empty state, an error —
|
|
139
|
+
* would otherwise inherit a caret over nothing by saying nothing. Compute it
|
|
140
|
+
* from what the body renders, in the same pass that renders it.
|
|
133
141
|
*/
|
|
134
|
-
export function FilterPanelProvider({
|
|
142
|
+
export function FilterPanelProvider({
|
|
143
|
+
children,
|
|
144
|
+
hasSheet,
|
|
145
|
+
}: {
|
|
146
|
+
children?: ReactNode;
|
|
147
|
+
hasSheet: boolean;
|
|
148
|
+
}) {
|
|
135
149
|
const [open, setOpen] = useState(false);
|
|
136
150
|
const [active, setActive] = useState(false);
|
|
137
151
|
const value = useMemo(
|
|
138
|
-
() => ({ open, setOpen, active, setActive }),
|
|
139
|
-
[open, active],
|
|
152
|
+
() => ({ open, setOpen, active, setActive, hasSheet }),
|
|
153
|
+
[open, active, hasSheet],
|
|
140
154
|
);
|
|
141
155
|
return (
|
|
142
156
|
<FilterPanelCtx.Provider value={value}>{children}</FilterPanelCtx.Provider>
|
|
143
157
|
);
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Detaches whatever it wraps from an enclosing `FilterPanelProvider`. A
|
|
162
|
+
* full-screen surface that covers the list — the phone search takeover — carries
|
|
163
|
+
* its own filter chrome, and the caret it would otherwise borrow belongs to a
|
|
164
|
+
* header that is not on screen.
|
|
165
|
+
*/
|
|
166
|
+
export function FilterPanelBoundary({ children }: { children: ReactNode }) {
|
|
167
|
+
return (
|
|
168
|
+
<FilterPanelCtx.Provider value={null}>{children}</FilterPanelCtx.Provider>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
/**
|
|
147
173
|
* The filter caret, for a list header to render beside its unread count. It is
|
|
148
174
|
* the view's whole filter affordance; the panel it opens still belongs to the
|
|
149
175
|
* sheet, inline above the rows.
|
|
150
176
|
*
|
|
151
|
-
* Renders nothing outside a `FilterPanelProvider
|
|
177
|
+
* Renders nothing outside a `FilterPanelProvider`, and nothing while that
|
|
178
|
+
* provider reports no sheet — there is no panel to open.
|
|
152
179
|
*/
|
|
153
180
|
export function FilterToggle() {
|
|
154
181
|
const panel = useContext(FilterPanelCtx);
|
|
155
|
-
if (!panel) return null;
|
|
182
|
+
if (!panel?.hasSheet) return null;
|
|
156
183
|
const { open, active, setOpen } = panel;
|
|
157
184
|
return (
|
|
158
185
|
<button
|
|
@@ -225,7 +252,9 @@ export function FilterSheet({
|
|
|
225
252
|
|
|
226
253
|
const setPanelActive = panel?.setActive;
|
|
227
254
|
useEffect(() => {
|
|
228
|
-
setPanelActive
|
|
255
|
+
if (!setPanelActive) return;
|
|
256
|
+
setPanelActive(hasActive);
|
|
257
|
+
return () => setPanelActive(false);
|
|
229
258
|
}, [hasActive, setPanelActive]);
|
|
230
259
|
|
|
231
260
|
const clearSource = useCallback(() => {
|
|
@@ -152,6 +152,28 @@ describe("MessageListPane", () => {
|
|
|
152
152
|
assert.doesNotMatch(html, /Every message in this folder was checked\./);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
it("ticks the rows the consumer says are selected, and nothing else", () => {
|
|
156
|
+
const html = renderToString(
|
|
157
|
+
createElement(MessageListPane, {
|
|
158
|
+
...baseProps,
|
|
159
|
+
isDesktop: true,
|
|
160
|
+
selection: {
|
|
161
|
+
selectedIds: new Set(["t1"]),
|
|
162
|
+
onToggle: () => undefined,
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
assert.match(html, /aria-label="Deselect message"/);
|
|
167
|
+
assert.match(html, /aria-label="Select message"/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("offers no checkbox at all when the consumer holds no selection", () => {
|
|
171
|
+
const html = renderToString(
|
|
172
|
+
createElement(MessageListPane, { ...baseProps, isDesktop: true }),
|
|
173
|
+
);
|
|
174
|
+
assert.doesNotMatch(html, /aria-label="Select message"/);
|
|
175
|
+
});
|
|
176
|
+
|
|
155
177
|
it("renders the selectionBar slot instead of the pane header when provided", () => {
|
|
156
178
|
const html = renderToString(
|
|
157
179
|
createElement(MessageListPane, {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { inboxFilterConfig } from "../filter-presets.js";
|
|
4
|
+
import {
|
|
5
|
+
rowSelectIntent,
|
|
6
|
+
type SelectionModifiers,
|
|
7
|
+
useSelection,
|
|
8
|
+
} from "../lib/use-selection.js";
|
|
4
9
|
import type { ThreadSection } from "./app-shell-types.js";
|
|
5
10
|
import { FilterSheet } from "./filter-sheet.js";
|
|
6
11
|
import { MailHeader } from "./mail-header.js";
|
|
@@ -212,60 +217,109 @@ export const CustomListBody: Story = {
|
|
|
212
217
|
decorators: [desktopFrame],
|
|
213
218
|
};
|
|
214
219
|
|
|
220
|
+
function SelectableList({ isDesktop }: { isDesktop: boolean }) {
|
|
221
|
+
const selection = useSelection();
|
|
222
|
+
const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
|
|
223
|
+
const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
|
|
224
|
+
|
|
225
|
+
const visible = sections.map((section) => ({
|
|
226
|
+
...section,
|
|
227
|
+
threads: section.threads
|
|
228
|
+
.filter((thread) => !trashedIds.has(thread.id))
|
|
229
|
+
.map((thread) =>
|
|
230
|
+
readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
|
|
231
|
+
),
|
|
232
|
+
}));
|
|
233
|
+
const orderedIds = visible.flatMap((s) => s.threads).map((t) => t.id);
|
|
234
|
+
const allSelected =
|
|
235
|
+
orderedIds.length > 0 &&
|
|
236
|
+
orderedIds.every((id) => selection.selectedIds.has(id));
|
|
237
|
+
|
|
238
|
+
const runVerb = (record: (ids: ReadonlySet<string>) => void) => {
|
|
239
|
+
record(selection.selectedIds);
|
|
240
|
+
selection.clearSelection();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Shift and cmd/ctrl come off a mouse, which the touch list has no path for.
|
|
244
|
+
const onRowSelect = isDesktop
|
|
245
|
+
? (id: string, modifiers: SelectionModifiers) => {
|
|
246
|
+
const intent = rowSelectIntent(modifiers);
|
|
247
|
+
if (intent === "range") {
|
|
248
|
+
selection.selectRange(orderedIds, id);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (intent === "toggle") {
|
|
252
|
+
selection.toggle(id);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
selection.clearSelection();
|
|
256
|
+
selection.setAnchor(id);
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
: undefined;
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<MessageListPane
|
|
263
|
+
listTitle="Inbox"
|
|
264
|
+
listMeta="3 conversations"
|
|
265
|
+
sections={visible}
|
|
266
|
+
flatList
|
|
267
|
+
isDesktop={isDesktop}
|
|
268
|
+
onSelectThread={() => undefined}
|
|
269
|
+
selection={{
|
|
270
|
+
selectedIds: selection.selectedIds,
|
|
271
|
+
onToggle: selection.toggle,
|
|
272
|
+
onRowSelect,
|
|
273
|
+
}}
|
|
274
|
+
selectionBar={
|
|
275
|
+
<SelectionTopBar
|
|
276
|
+
title="Inbox"
|
|
277
|
+
count={selection.selectedCount}
|
|
278
|
+
onCancel={selection.clearSelection}
|
|
279
|
+
onDelete={() =>
|
|
280
|
+
runVerb((ids) =>
|
|
281
|
+
setTrashedIds((prev) => new Set([...prev, ...ids])),
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
onMarkRead={() =>
|
|
285
|
+
runVerb((ids) => setReadIds((prev) => new Set([...prev, ...ids])))
|
|
286
|
+
}
|
|
287
|
+
selectAll={{
|
|
288
|
+
checked: allSelected,
|
|
289
|
+
indeterminate: selection.hasSelection && !allSelected,
|
|
290
|
+
onChange: () => selection.toggleAll(orderedIds),
|
|
291
|
+
}}
|
|
292
|
+
/>
|
|
293
|
+
}
|
|
294
|
+
/>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
215
298
|
/**
|
|
216
|
-
* The
|
|
217
|
-
*
|
|
218
|
-
*
|
|
299
|
+
* The pane under a selection its consumer owns — it draws the checkboxes and
|
|
300
|
+
* holds none of the state. Click a checkbox to tick one row, cmd/ctrl-click a
|
|
301
|
+
* row to tick it without opening it, shift-click to range from the last row
|
|
302
|
+
* touched, and select-all covers the rows on screen. From 768px up the labelled
|
|
303
|
+
* select-all sits inline in the bar.
|
|
304
|
+
*
|
|
305
|
+
* The bar is the header for every state of the list: it names the mailbox with
|
|
306
|
+
* nothing ticked and carries the count and the verbs from the first ticked row.
|
|
307
|
+
* Delete and Mark read act on the rows — trashed rows leave the list, marked
|
|
308
|
+
* ones lose their unread dot.
|
|
219
309
|
*/
|
|
220
|
-
export const
|
|
221
|
-
|
|
222
|
-
isDesktop: true,
|
|
223
|
-
flatList: true,
|
|
224
|
-
selectionBar: (
|
|
225
|
-
<SelectionTopBar
|
|
226
|
-
title="Inbox"
|
|
227
|
-
count={2}
|
|
228
|
-
onCancel={() => undefined}
|
|
229
|
-
onMarkRead={() => undefined}
|
|
230
|
-
onJunk={() => undefined}
|
|
231
|
-
onOrganize={() => undefined}
|
|
232
|
-
onDelete={() => undefined}
|
|
233
|
-
selectAll={{
|
|
234
|
-
checked: false,
|
|
235
|
-
indeterminate: true,
|
|
236
|
-
onChange: () => undefined,
|
|
237
|
-
}}
|
|
238
|
-
/>
|
|
239
|
-
),
|
|
240
|
-
},
|
|
310
|
+
export const ConsumerSelection: Story = {
|
|
311
|
+
render: () => <SelectableList isDesktop />,
|
|
241
312
|
decorators: [desktopFrame],
|
|
242
313
|
};
|
|
243
314
|
|
|
244
315
|
/**
|
|
245
|
-
* The same
|
|
246
|
-
*
|
|
316
|
+
* The same list at phone width, where selection is a long press and a tap on
|
|
317
|
+
* the checkbox: no modifier comes off a touch, so there is no range and no
|
|
318
|
+
* cmd-toggle here. Checkboxes stay put once a row is ticked, and select-all
|
|
319
|
+
* moves to a second row so row one stays a count and the verbs.
|
|
247
320
|
*/
|
|
248
|
-
export const
|
|
249
|
-
|
|
250
|
-
isDesktop: false,
|
|
251
|
-
flatList: true,
|
|
252
|
-
selectionBar: (
|
|
253
|
-
<SelectionTopBar
|
|
254
|
-
title="Inbox"
|
|
255
|
-
count={2}
|
|
256
|
-
onCancel={() => undefined}
|
|
257
|
-
onMarkRead={() => undefined}
|
|
258
|
-
onJunk={() => undefined}
|
|
259
|
-
onOrganize={() => undefined}
|
|
260
|
-
onDelete={() => undefined}
|
|
261
|
-
selectAll={{
|
|
262
|
-
checked: false,
|
|
263
|
-
indeterminate: true,
|
|
264
|
-
onChange: () => undefined,
|
|
265
|
-
}}
|
|
266
|
-
/>
|
|
267
|
-
),
|
|
268
|
-
},
|
|
321
|
+
export const NarrowConsumerSelection: Story = {
|
|
322
|
+
render: () => <SelectableList isDesktop={false} />,
|
|
269
323
|
decorators: [narrowFrame],
|
|
270
324
|
};
|
|
271
325
|
|