@remit/ui 0.0.75 → 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/filter-sheet.render.test.ts +12 -1
- package/src/components/filter-sheet.tsx +8 -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 +1 -1
- package/src/components/touch-list.follows-sections.test.ts +140 -0
- package/src/components/touch-list.tsx +16 -8
- package/src/index.ts +15 -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
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { Menu } from "lucide-react";
|
|
2
|
-
import type { ReactNode } from "react";
|
|
3
|
-
import { useCallback,
|
|
2
|
+
import type { MouseEvent, ReactNode } from "react";
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
4
|
import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
|
|
5
|
-
import
|
|
5
|
+
import { deriveIsMultiSelectMode, modifiersOf } from "../lib/use-selection.js";
|
|
6
|
+
import type {
|
|
7
|
+
AppShellProps,
|
|
8
|
+
MessageListSelection,
|
|
9
|
+
TouchSeed,
|
|
10
|
+
} from "./app-shell-types.js";
|
|
6
11
|
import { BriefSections } from "./brief-sections.js";
|
|
7
12
|
import { Button } from "./button.js";
|
|
8
13
|
import { KeyboardHintBar } from "./keyboard-hint-bar.js";
|
|
@@ -16,12 +21,14 @@ import {
|
|
|
16
21
|
type BriefRowComponent,
|
|
17
22
|
ComfortableRow,
|
|
18
23
|
CompactRow,
|
|
24
|
+
type RowSelection,
|
|
19
25
|
type RowToggleEvent,
|
|
20
26
|
} from "./message-row.js";
|
|
21
|
-
import { SelectionTopBar } from "./selection-top-bar.js";
|
|
22
27
|
import type { SwipePeek } from "./swipeable-row.js";
|
|
23
28
|
import { TouchListBody } from "./touch-list.js";
|
|
24
29
|
|
|
30
|
+
const NO_SELECTION: ReadonlySet<string> = new Set();
|
|
31
|
+
|
|
25
32
|
/* ------------------------------------------------------------------ */
|
|
26
33
|
/* Pane 2: message list (sectioned, dense, density toggle) */
|
|
27
34
|
/* ------------------------------------------------------------------ */
|
|
@@ -47,6 +54,7 @@ export function MessageListPane({
|
|
|
47
54
|
onOpenNav,
|
|
48
55
|
isDesktop,
|
|
49
56
|
initialTouchState,
|
|
57
|
+
selection,
|
|
50
58
|
selectionBar,
|
|
51
59
|
paneOverlay,
|
|
52
60
|
listBody,
|
|
@@ -85,9 +93,16 @@ export function MessageListPane({
|
|
|
85
93
|
isDesktop: boolean;
|
|
86
94
|
initialTouchState?: TouchSeed;
|
|
87
95
|
/**
|
|
88
|
-
*
|
|
89
|
-
* the
|
|
90
|
-
*
|
|
96
|
+
* The list's selection. The pane draws it — the row checkboxes, and the
|
|
97
|
+
* always-visible ones the touch list shows once a row is ticked — and holds
|
|
98
|
+
* none of it. Absent means the list does not offer multi-select.
|
|
99
|
+
*/
|
|
100
|
+
selection?: MessageListSelection;
|
|
101
|
+
/**
|
|
102
|
+
* The pane header. The caller mounts it for every state of the list: a
|
|
103
|
+
* `SelectionTopBar` names the view while nothing is ticked and carries the
|
|
104
|
+
* count and the verbs (mark-read, move, delete, cancel) from the first
|
|
105
|
+
* ticked row. Replaces the pane's own plain title header.
|
|
91
106
|
*/
|
|
92
107
|
selectionBar?: ReactNode;
|
|
93
108
|
/**
|
|
@@ -107,9 +122,8 @@ export function MessageListPane({
|
|
|
107
122
|
*/
|
|
108
123
|
listBody?: ReactNode;
|
|
109
124
|
/**
|
|
110
|
-
* Suppress the built-in title header
|
|
111
|
-
*
|
|
112
|
-
* replaces the (now absent) header while a selection is active.
|
|
125
|
+
* Suppress the built-in title header, for a consumer that renders its own
|
|
126
|
+
* above the pane.
|
|
113
127
|
*/
|
|
114
128
|
hideHeader?: boolean;
|
|
115
129
|
}) {
|
|
@@ -121,38 +135,7 @@ export function MessageListPane({
|
|
|
121
135
|
});
|
|
122
136
|
|
|
123
137
|
const touchTriage = !isDesktop && !briefFilters && listState === "ready";
|
|
124
|
-
const seededRows = sections.flatMap((section) => section.threads);
|
|
125
|
-
const [selectionMode, setSelectionMode] = useState(
|
|
126
|
-
initialTouchState === "selection",
|
|
127
|
-
);
|
|
128
|
-
// The seed is a touch-triage state; desktop starts unselected and gets there
|
|
129
|
-
// through the row checkboxes.
|
|
130
|
-
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(() =>
|
|
131
|
-
initialTouchState === "selection" && !isDesktop
|
|
132
|
-
? new Set(seededRows.slice(0, 2).map((t) => t.id))
|
|
133
|
-
: new Set(),
|
|
134
|
-
);
|
|
135
|
-
// What the fallback bar's verbs have done to the mock rows. A demo bar whose
|
|
136
|
-
// Trash only closes the bar is a Trash that deletes nothing, which is the one
|
|
137
|
-
// thing a selection bar must never be — so these verbs act on the rows the
|
|
138
|
-
// mock owns, the same way `refresh` below fakes a refresh visibly.
|
|
139
|
-
const [trashedIds, setTrashedIds] = useState<ReadonlySet<string>>(new Set());
|
|
140
|
-
const [readIds, setReadIds] = useState<ReadonlySet<string>>(new Set());
|
|
141
138
|
const [refreshing, setRefreshing] = useState(false);
|
|
142
|
-
const touchSections = useMemo(
|
|
143
|
-
() =>
|
|
144
|
-
trashedIds.size === 0 && readIds.size === 0
|
|
145
|
-
? sections
|
|
146
|
-
: sections.map((section) => ({
|
|
147
|
-
...section,
|
|
148
|
-
threads: section.threads
|
|
149
|
-
.filter((thread) => !trashedIds.has(thread.id))
|
|
150
|
-
.map((thread) =>
|
|
151
|
-
readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
|
|
152
|
-
),
|
|
153
|
-
})),
|
|
154
|
-
[sections, trashedIds, readIds],
|
|
155
|
-
);
|
|
156
139
|
const initialPeek: SwipePeek | undefined =
|
|
157
140
|
initialTouchState === "peek-trailing"
|
|
158
141
|
? "trailing"
|
|
@@ -160,92 +143,70 @@ export function MessageListPane({
|
|
|
160
143
|
? "leading"
|
|
161
144
|
: undefined;
|
|
162
145
|
|
|
163
|
-
const toggleCheck = useCallback((id: string) => {
|
|
164
|
-
setCheckedIds((prev) => {
|
|
165
|
-
const next = new Set(prev);
|
|
166
|
-
if (next.has(id)) next.delete(id);
|
|
167
|
-
else next.add(id);
|
|
168
|
-
if (next.size === 0) setSelectionMode(false);
|
|
169
|
-
return next;
|
|
170
|
-
});
|
|
171
|
-
}, []);
|
|
172
|
-
const enterSelection = (id: string) => {
|
|
173
|
-
setSelectionMode(true);
|
|
174
|
-
setCheckedIds(new Set([id]));
|
|
175
|
-
};
|
|
176
|
-
const cancelSelection = () => {
|
|
177
|
-
setSelectionMode(false);
|
|
178
|
-
setCheckedIds(new Set());
|
|
179
|
-
};
|
|
180
|
-
const trashChecked = () => {
|
|
181
|
-
setTrashedIds((prev) => new Set([...prev, ...checkedIds]));
|
|
182
|
-
cancelSelection();
|
|
183
|
-
};
|
|
184
|
-
const markCheckedRead = () => {
|
|
185
|
-
setReadIds((prev) => new Set([...prev, ...checkedIds]));
|
|
186
|
-
cancelSelection();
|
|
187
|
-
};
|
|
188
146
|
const refresh = () => {
|
|
189
147
|
setRefreshing(true);
|
|
190
148
|
setTimeout(() => setRefreshing(false), 1400);
|
|
191
149
|
};
|
|
192
150
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
151
|
+
const selectedIds = selection?.selectedIds ?? NO_SELECTION;
|
|
152
|
+
const toggleSelected = selection?.onToggle;
|
|
153
|
+
const onRowSelect = selection?.onRowSelect;
|
|
154
|
+
// Multi-select is the touch affordance, and it is a function of the count
|
|
155
|
+
// rather than a flag, so ticking the last row off leaves it on its own.
|
|
156
|
+
const selectionMode = deriveIsMultiSelectMode(selectedIds.size, isDesktop);
|
|
157
|
+
|
|
158
|
+
// The checkbox takes the avatar's place on hover, and stays put once the row
|
|
159
|
+
// is ticked.
|
|
196
160
|
const rowSelection = useCallback(
|
|
197
|
-
(id: string) =>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
[
|
|
161
|
+
(id: string): RowSelection | undefined => {
|
|
162
|
+
if (!toggleSelected) return undefined;
|
|
163
|
+
return {
|
|
164
|
+
checked: selectedIds.has(id),
|
|
165
|
+
onToggle: (event: RowToggleEvent) => {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
event.stopPropagation();
|
|
168
|
+
toggleSelected(id);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
[selectedIds, toggleSelected],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Shift ranges and cmd/ctrl ticks, so a modified click never opens the row it
|
|
176
|
+
// was aimed at. Shift-click otherwise drags a native text selection across
|
|
177
|
+
// the rows it spans — the row highlight is the selection the user asked for.
|
|
178
|
+
const takeRowSelect = useCallback(
|
|
179
|
+
(id: string, event: MouseEvent): boolean => {
|
|
180
|
+
if (!onRowSelect?.(id, modifiersOf(event))) return false;
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
if (event.shiftKey) window.getSelection()?.removeAllRanges();
|
|
183
|
+
return true;
|
|
184
|
+
},
|
|
185
|
+
[onRowSelect],
|
|
209
186
|
);
|
|
210
187
|
|
|
211
188
|
// The brief drives rows through a `BriefRowComponent`, whose props carry no
|
|
212
189
|
// selection — a consumer's own row (the web client's) wires its checkbox
|
|
213
190
|
// itself. Binding it here keeps the kit's rows selectable there too.
|
|
214
191
|
const BriefRow: BriefRowComponent = useCallback(
|
|
215
|
-
(
|
|
216
|
-
|
|
192
|
+
({ thread, active, onClick }) => (
|
|
193
|
+
<Row
|
|
194
|
+
thread={thread}
|
|
195
|
+
active={active}
|
|
196
|
+
selection={rowSelection(thread.id)}
|
|
197
|
+
onClick={(event) => {
|
|
198
|
+
if (takeRowSelect(thread.id, event)) return;
|
|
199
|
+
onClick?.();
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
),
|
|
203
|
+
[Row, rowSelection, takeRowSelect],
|
|
217
204
|
);
|
|
218
205
|
|
|
219
|
-
const selectableIds = seededRows.map((thread) => thread.id);
|
|
220
|
-
const allChecked =
|
|
221
|
-
selectableIds.length > 0 && selectableIds.every((id) => checkedIds.has(id));
|
|
222
|
-
const selectAll = {
|
|
223
|
-
checked: allChecked,
|
|
224
|
-
indeterminate: checkedIds.size > 0 && !allChecked,
|
|
225
|
-
onChange: () =>
|
|
226
|
-
setCheckedIds(allChecked ? new Set() : new Set(selectableIds)),
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
// When the caller supplies a selectionBar slot, it owns selection state.
|
|
230
|
-
// Fall back to the built-in bar only when no external bar is given.
|
|
231
|
-
const inBuiltinSelection =
|
|
232
|
-
!selectionBar &&
|
|
233
|
-
checkedIds.size > 0 &&
|
|
234
|
-
(desktopSelectable || (touchTriage && selectionMode));
|
|
235
|
-
|
|
236
206
|
return (
|
|
237
207
|
<section className="relative flex h-full w-full flex-col bg-surface">
|
|
238
208
|
{selectionBar ??
|
|
239
|
-
(
|
|
240
|
-
<SelectionTopBar
|
|
241
|
-
title={listTitle}
|
|
242
|
-
count={checkedIds.size}
|
|
243
|
-
selectAll={desktopSelectable ? selectAll : undefined}
|
|
244
|
-
onCancel={cancelSelection}
|
|
245
|
-
onMarkRead={markCheckedRead}
|
|
246
|
-
onDelete={trashChecked}
|
|
247
|
-
/>
|
|
248
|
-
) : hideHeader ? null : (
|
|
209
|
+
(hideHeader ? null : (
|
|
249
210
|
<header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
|
|
250
211
|
{onOpenNav && (
|
|
251
212
|
<Button
|
|
@@ -304,13 +265,13 @@ export function MessageListPane({
|
|
|
304
265
|
listBody
|
|
305
266
|
) : touchTriage ? (
|
|
306
267
|
<TouchListBody
|
|
307
|
-
sections={
|
|
268
|
+
sections={sections}
|
|
308
269
|
selectedThreadId={selectedThreadId}
|
|
309
270
|
selectionMode={selectionMode}
|
|
310
|
-
checkedIds={
|
|
271
|
+
checkedIds={selectedIds}
|
|
311
272
|
initialPeek={initialPeek}
|
|
312
|
-
onToggleCheck={
|
|
313
|
-
onEnterSelection={
|
|
273
|
+
onToggleCheck={(id) => toggleSelected?.(id)}
|
|
274
|
+
onEnterSelection={(id) => toggleSelected?.(id)}
|
|
314
275
|
onOpenThread={(id) => onSelectThread?.(id)}
|
|
315
276
|
onRefresh={refresh}
|
|
316
277
|
refreshing={refreshing}
|
|
@@ -338,7 +299,10 @@ export function MessageListPane({
|
|
|
338
299
|
thread={thread}
|
|
339
300
|
active={thread.id === selectedThreadId}
|
|
340
301
|
selection={rowSelection(thread.id)}
|
|
341
|
-
onClick={() =>
|
|
302
|
+
onClick={(event) => {
|
|
303
|
+
if (takeRowSelect(thread.id, event)) return;
|
|
304
|
+
onSelectThread?.(thread.id);
|
|
305
|
+
}}
|
|
342
306
|
/>
|
|
343
307
|
))}
|
|
344
308
|
</div>
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Check, Paperclip, ShieldAlert, Star } from "lucide-react";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ComponentType,
|
|
4
|
+
MouseEvent,
|
|
5
|
+
ReactNode,
|
|
6
|
+
SyntheticEvent,
|
|
7
|
+
} from "react";
|
|
3
8
|
import { cn } from "../lib/cn.js";
|
|
4
9
|
import { LIST_ROW_ATTRIBUTE } from "../lib/roving-focus.js";
|
|
5
10
|
import { categoryTone, type ThreadRowData } from "./app-shell-types.js";
|
|
@@ -328,7 +333,7 @@ export function CompactRow({
|
|
|
328
333
|
thread: ThreadRowData;
|
|
329
334
|
active?: boolean;
|
|
330
335
|
focused?: boolean;
|
|
331
|
-
onClick?: () => void;
|
|
336
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
332
337
|
}) {
|
|
333
338
|
return (
|
|
334
339
|
<button
|
|
@@ -353,7 +358,7 @@ export function ComfortableRow({
|
|
|
353
358
|
active?: boolean;
|
|
354
359
|
focused?: boolean;
|
|
355
360
|
selection?: RowSelection;
|
|
356
|
-
onClick?: () => void;
|
|
361
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
357
362
|
}) {
|
|
358
363
|
return (
|
|
359
364
|
<button
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The touch list renders the `sections` it is handed, on every render.
|
|
3
|
+
*
|
|
4
|
+
* It used to copy them into state on mount, so a consumer whose verbs act on
|
|
5
|
+
* its own rows — the selection bar's Trash — cleared the bar and left every
|
|
6
|
+
* row on screen. The swipe mock still has to work, so what it does is held as
|
|
7
|
+
* ids over whatever the consumer passes rather than as a copy of the rows.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
12
|
+
import type { JSDOM } from "jsdom";
|
|
13
|
+
import { act, createElement } from "react";
|
|
14
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
15
|
+
import type { ThreadSection } from "./app-shell-types.js";
|
|
16
|
+
import type { SwipePeek } from "./swipeable-row.js";
|
|
17
|
+
import { TouchListBody } from "./touch-list.js";
|
|
18
|
+
|
|
19
|
+
let dom: JSDOM;
|
|
20
|
+
let container: HTMLElement;
|
|
21
|
+
let root: Root;
|
|
22
|
+
|
|
23
|
+
const row = (id: string, fromName: string, isRead = false) => ({
|
|
24
|
+
id,
|
|
25
|
+
accountId: "account-1",
|
|
26
|
+
fromName,
|
|
27
|
+
fromEmail: `${id}@example.com`,
|
|
28
|
+
subject: `Subject ${id}`,
|
|
29
|
+
snippet: "…",
|
|
30
|
+
timeLabel: "9:42",
|
|
31
|
+
isRead,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const sectionsOf = (...ids: string[]): ThreadSection[] => [
|
|
35
|
+
{ id: "inbox", threads: ids.map((id) => row(id, `Sender ${id}`)) },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const readOf = (...ids: string[]): ThreadSection[] => [
|
|
39
|
+
{ id: "inbox", threads: ids.map((id) => row(id, `Sender ${id}`, true)) },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const render = (sections: ThreadSection[], initialPeek?: SwipePeek) => {
|
|
43
|
+
act(() => {
|
|
44
|
+
root.render(
|
|
45
|
+
createElement(TouchListBody, {
|
|
46
|
+
sections,
|
|
47
|
+
initialPeek,
|
|
48
|
+
selectionMode: false,
|
|
49
|
+
checkedIds: new Set<string>(),
|
|
50
|
+
onToggleCheck: () => undefined,
|
|
51
|
+
onEnterSelection: () => undefined,
|
|
52
|
+
onOpenThread: () => undefined,
|
|
53
|
+
onRefresh: () => undefined,
|
|
54
|
+
refreshing: false,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The unread dot each row draws while it is unread, and only then. */
|
|
61
|
+
const unreadRows = () =>
|
|
62
|
+
container.querySelectorAll("span.rounded-full.bg-accent").length;
|
|
63
|
+
|
|
64
|
+
const click = (label: string) => {
|
|
65
|
+
const button = container.querySelector(`button[aria-label="${label}"]`);
|
|
66
|
+
assert.ok(button, `no "${label}" control on the page`);
|
|
67
|
+
act(() => {
|
|
68
|
+
button.dispatchEvent(
|
|
69
|
+
new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
before(async () => {
|
|
75
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
76
|
+
dom = new JSDOMCtor(
|
|
77
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
78
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
79
|
+
);
|
|
80
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
81
|
+
globalThis.document = dom.window.document;
|
|
82
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
83
|
+
globalThis.Element = dom.window.Element;
|
|
84
|
+
globalThis.SVGElement = dom.window.SVGElement;
|
|
85
|
+
(
|
|
86
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
87
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
after(() => {
|
|
91
|
+
dom.window.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
container = dom.window.document.getElementById(
|
|
96
|
+
"root",
|
|
97
|
+
) as unknown as HTMLElement;
|
|
98
|
+
container.innerHTML = "";
|
|
99
|
+
root = createRoot(container);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
act(() => {
|
|
104
|
+
root.unmount();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("TouchListBody", () => {
|
|
109
|
+
it("drops the rows a consumer's verb removed", () => {
|
|
110
|
+
render(sectionsOf("a", "b", "c"));
|
|
111
|
+
assert.match(container.textContent ?? "", /Sender a/);
|
|
112
|
+
|
|
113
|
+
render(sectionsOf("c"));
|
|
114
|
+
assert.doesNotMatch(container.textContent ?? "", /Sender a/);
|
|
115
|
+
assert.doesNotMatch(container.textContent ?? "", /Sender b/);
|
|
116
|
+
assert.match(container.textContent ?? "", /Sender c/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("shows the rows a consumer added after mount", () => {
|
|
120
|
+
render(sectionsOf("a"));
|
|
121
|
+
render(sectionsOf("a", "b"));
|
|
122
|
+
assert.match(container.textContent ?? "", /Sender b/);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("TouchListBody swipe-to-toggle-read", () => {
|
|
127
|
+
it("leaves the row at the state the swipe landed on", () => {
|
|
128
|
+
render(sectionsOf("a", "b"), "leading");
|
|
129
|
+
assert.equal(unreadRows(), 2, "both rows start unread");
|
|
130
|
+
click("Mark as read");
|
|
131
|
+
assert.equal(unreadRows(), 1, "the swiped row is read");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("does not invert a row the consumer has since marked read", () => {
|
|
135
|
+
render(sectionsOf("a", "b"), "leading");
|
|
136
|
+
click("Mark as read");
|
|
137
|
+
render(readOf("a", "b"));
|
|
138
|
+
assert.equal(unreadRows(), 0, "a row the consumer marked read stays read");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -33,11 +33,20 @@ export function TouchListBody({
|
|
|
33
33
|
*/
|
|
34
34
|
busy?: boolean;
|
|
35
35
|
}) {
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
// What a swipe has left a row at — the state it landed on, not a flip of
|
|
37
|
+
// whatever the prop says — so the list still follows the `sections` its
|
|
38
|
+
// consumer passes. The live client owns real mutation.
|
|
39
|
+
const [swipedAway, setSwipedAway] = useState<ReadonlySet<string>>(new Set());
|
|
40
|
+
const [swipedRead, setSwipedRead] = useState<ReadonlyMap<string, boolean>>(
|
|
41
|
+
new Map(),
|
|
40
42
|
);
|
|
43
|
+
const items = sections
|
|
44
|
+
.flatMap((section) => section.threads)
|
|
45
|
+
.filter((thread) => !swipedAway.has(thread.id))
|
|
46
|
+
.map((thread) => {
|
|
47
|
+
const read = swipedRead.get(thread.id);
|
|
48
|
+
return read === undefined ? thread : { ...thread, isRead: read };
|
|
49
|
+
});
|
|
41
50
|
const [peek, setPeek] = useState<{ id: string; side: SwipePeek } | null>(
|
|
42
51
|
initialPeek && initialPeek !== "none" && items[1]
|
|
43
52
|
? { id: items[1].id, side: initialPeek }
|
|
@@ -45,11 +54,10 @@ export function TouchListBody({
|
|
|
45
54
|
);
|
|
46
55
|
const act = (id: string, side: "leading" | "trailing") => {
|
|
47
56
|
if (side === "trailing") {
|
|
48
|
-
|
|
57
|
+
setSwipedAway((prev) => new Set(prev).add(id));
|
|
49
58
|
} else {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
);
|
|
59
|
+
const shown = items.find((thread) => thread.id === id);
|
|
60
|
+
setSwipedRead((prev) => new Map(prev).set(id, !shown?.isRead));
|
|
53
61
|
}
|
|
54
62
|
setPeek(null);
|
|
55
63
|
};
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export {
|
|
|
29
29
|
categoryTone,
|
|
30
30
|
type Density,
|
|
31
31
|
INTELLIGENCE_MIN_WIDTH,
|
|
32
|
+
type MessageListSelection,
|
|
32
33
|
type NarrowView,
|
|
33
34
|
type NavAccount,
|
|
34
35
|
type NavAccountStatus,
|
|
@@ -701,6 +702,20 @@ export {
|
|
|
701
702
|
type UseLongPressResult,
|
|
702
703
|
useLongPress,
|
|
703
704
|
} from "./lib/use-long-press.js";
|
|
705
|
+
export {
|
|
706
|
+
computeRange,
|
|
707
|
+
deriveIsMultiSelectMode,
|
|
708
|
+
intersectSelectedIds,
|
|
709
|
+
isModified,
|
|
710
|
+
modifiersOf,
|
|
711
|
+
nextFocusId,
|
|
712
|
+
type RowSelectIntent,
|
|
713
|
+
resolveRangeAnchor,
|
|
714
|
+
rowSelectIntent,
|
|
715
|
+
type SelectionModifiers,
|
|
716
|
+
type UseSelectionOptions,
|
|
717
|
+
useSelection,
|
|
718
|
+
} from "./lib/use-selection.js";
|
|
704
719
|
export {
|
|
705
720
|
type ComboboxProps,
|
|
706
721
|
type SuggestListState,
|