@remit/ui 0.0.80 → 0.0.82
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 +22 -0
- package/src/components/app-shell.tsx +5 -0
- package/src/components/keyboard-hint-bar.render.test.ts +29 -0
- package/src/components/keyboard-hint-bar.tsx +2 -14
- package/src/components/message-list-pane.stories.tsx +48 -35
- package/src/components/message-list-pane.tsx +82 -4
- package/src/components/message-row.tsx +22 -2
- package/src/components/self-update.ts +3 -4
- package/src/index.ts +30 -2
- package/src/lib/keymap-dispatch.test.ts +255 -0
- package/src/lib/keymap-dispatch.ts +244 -0
- package/src/lib/keymap.test.ts +68 -0
- package/src/lib/keymap.ts +241 -0
- package/src/lib/use-list-cursor.test.ts +273 -0
- package/src/lib/use-list-cursor.ts +263 -0
- package/src/lib/use-list-keyboard.test.ts +172 -0
- package/src/lib/use-list-keyboard.ts +101 -0
- package/src/lib/use-triage-keyboard.test.ts +153 -0
- package/src/lib/use-triage-keyboard.ts +119 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the global keyboard triage layer (#429).
|
|
3
|
+
*
|
|
4
|
+
* Every shortcut the app responds to is declared here once. The help overlay
|
|
5
|
+
* (`KeyboardShortcutsModal`), the list footer hints and the toolbar tooltips
|
|
6
|
+
* all read from this module so the displayed bindings can never drift from the
|
|
7
|
+
* keys the dispatcher actually routes. The spec lives in
|
|
8
|
+
* `doc/design/flows/04-triage.md`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A logical action a key (or key sequence) maps to. */
|
|
12
|
+
export type TriageAction =
|
|
13
|
+
// list navigation / focus model
|
|
14
|
+
| "focusNext"
|
|
15
|
+
| "focusPrevious"
|
|
16
|
+
| "focusFirst"
|
|
17
|
+
| "focusLast"
|
|
18
|
+
| "openFocused"
|
|
19
|
+
| "back"
|
|
20
|
+
// selection
|
|
21
|
+
| "toggleSelect"
|
|
22
|
+
| "extendSelectDown"
|
|
23
|
+
| "extendSelectUp"
|
|
24
|
+
| "selectAll"
|
|
25
|
+
// message verbs (focused row / selection)
|
|
26
|
+
| "reply"
|
|
27
|
+
| "replyAll"
|
|
28
|
+
| "forward"
|
|
29
|
+
| "delete"
|
|
30
|
+
| "toggleStar"
|
|
31
|
+
| "toggleRead"
|
|
32
|
+
// sender verbs
|
|
33
|
+
| "muteSender"
|
|
34
|
+
| "blockSender"
|
|
35
|
+
| "vipSender"
|
|
36
|
+
| "markJunk"
|
|
37
|
+
// view
|
|
38
|
+
| "toggleIntelligence"
|
|
39
|
+
| "toggleDensity"
|
|
40
|
+
// global
|
|
41
|
+
| "focusSearch"
|
|
42
|
+
| "compose"
|
|
43
|
+
| "help"
|
|
44
|
+
// go-to sequences (g then …)
|
|
45
|
+
| "goBrief"
|
|
46
|
+
| "goInbox"
|
|
47
|
+
| "goSent"
|
|
48
|
+
| "goFlagged"
|
|
49
|
+
| "goSettings";
|
|
50
|
+
|
|
51
|
+
/** Map of action → handler. Omitted actions are inert (no-op). */
|
|
52
|
+
export type TriageHandlers = Partial<Record<TriageAction, () => void>>;
|
|
53
|
+
|
|
54
|
+
/** One hint in the list's footer bar. */
|
|
55
|
+
export interface KeyboardHint {
|
|
56
|
+
/** The action the keys run, so a hint can be traced to what serves it. */
|
|
57
|
+
action: TriageAction;
|
|
58
|
+
/** One or more keys shown as `Kbd` chips. */
|
|
59
|
+
keys: string[];
|
|
60
|
+
/** What the keys do. */
|
|
61
|
+
label: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The persistent message-list footer hint set (desktop). */
|
|
65
|
+
export const defaultKeyboardHints: KeyboardHint[] = [
|
|
66
|
+
{ action: "focusNext", keys: ["j", "k"], label: "navigate" },
|
|
67
|
+
{ action: "muteSender", keys: ["m"], label: "mute" },
|
|
68
|
+
{ action: "help", keys: ["?"], label: "all shortcuts" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The hints a host may advertise: the footer set narrowed to the actions its
|
|
73
|
+
* handler table answers. A surface offers the keys it serves and no others, so
|
|
74
|
+
* the bar under a list describes that list.
|
|
75
|
+
*/
|
|
76
|
+
export function keyboardHintsFor(handlers: TriageHandlers): KeyboardHint[] {
|
|
77
|
+
return defaultKeyboardHints.filter(
|
|
78
|
+
(hint) => handlers[hint.action] !== undefined,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One displayed binding row in the help overlay. `keys` is the human-readable
|
|
84
|
+
* sequence (e.g. ["g", "b"] or ["⌘", "N"]); `display` overrides the rendered
|
|
85
|
+
* label when a single token reads better (e.g. "Esc", "↵").
|
|
86
|
+
*/
|
|
87
|
+
export interface KeyHint {
|
|
88
|
+
action: TriageAction;
|
|
89
|
+
/** Tokens rendered as individual <Kbd> chips, in order. */
|
|
90
|
+
keys: string[];
|
|
91
|
+
description: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface KeyHintGroup {
|
|
95
|
+
title: string;
|
|
96
|
+
hints: KeyHint[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The full key map, grouped by area, exactly as it appears in the `?` overlay.
|
|
101
|
+
* Order is intentional (matches 04-triage.md reading order).
|
|
102
|
+
*/
|
|
103
|
+
export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
104
|
+
{
|
|
105
|
+
title: "Navigation",
|
|
106
|
+
hints: [
|
|
107
|
+
{ action: "focusNext", keys: ["j"], description: "Focus next message" },
|
|
108
|
+
{
|
|
109
|
+
action: "focusPrevious",
|
|
110
|
+
keys: ["k"],
|
|
111
|
+
description: "Focus previous message",
|
|
112
|
+
},
|
|
113
|
+
{ action: "focusNext", keys: ["↓"], description: "Focus next message" },
|
|
114
|
+
{
|
|
115
|
+
action: "focusPrevious",
|
|
116
|
+
keys: ["↑"],
|
|
117
|
+
description: "Focus previous message",
|
|
118
|
+
},
|
|
119
|
+
{ action: "focusFirst", keys: ["Home"], description: "Focus first" },
|
|
120
|
+
{ action: "focusLast", keys: ["End"], description: "Focus last" },
|
|
121
|
+
{
|
|
122
|
+
action: "openFocused",
|
|
123
|
+
keys: ["Enter"],
|
|
124
|
+
description: "Open focused thread",
|
|
125
|
+
},
|
|
126
|
+
{ action: "back", keys: ["Esc"], description: "Back / close overlay" },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
title: "Selection",
|
|
131
|
+
hints: [
|
|
132
|
+
{ action: "toggleSelect", keys: ["x"], description: "Toggle select" },
|
|
133
|
+
{ action: "toggleSelect", keys: ["Space"], description: "Toggle select" },
|
|
134
|
+
{
|
|
135
|
+
action: "extendSelectDown",
|
|
136
|
+
keys: ["Shift", "j"],
|
|
137
|
+
description: "Extend selection down",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
action: "extendSelectUp",
|
|
141
|
+
keys: ["Shift", "k"],
|
|
142
|
+
description: "Extend selection up",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
action: "extendSelectDown",
|
|
146
|
+
keys: ["Shift", "↓"],
|
|
147
|
+
description: "Extend selection down",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
action: "extendSelectUp",
|
|
151
|
+
keys: ["Shift", "↑"],
|
|
152
|
+
description: "Extend selection up",
|
|
153
|
+
},
|
|
154
|
+
{ action: "selectAll", keys: ["⌘", "A"], description: "Select all" },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
title: "Actions",
|
|
159
|
+
hints: [
|
|
160
|
+
{ action: "reply", keys: ["r"], description: "Reply" },
|
|
161
|
+
{ action: "replyAll", keys: ["a"], description: "Reply all" },
|
|
162
|
+
{ action: "forward", keys: ["f"], description: "Forward" },
|
|
163
|
+
{ action: "delete", keys: ["#"], description: "Delete" },
|
|
164
|
+
{ action: "toggleStar", keys: ["s"], description: "Star / unstar" },
|
|
165
|
+
{
|
|
166
|
+
action: "toggleRead",
|
|
167
|
+
keys: ["u"],
|
|
168
|
+
description: "Toggle read / unread",
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
title: "Sender",
|
|
174
|
+
hints: [
|
|
175
|
+
{ action: "muteSender", keys: ["m"], description: "Mute sender" },
|
|
176
|
+
{ action: "blockSender", keys: ["b"], description: "Block sender" },
|
|
177
|
+
{ action: "vipSender", keys: ["v"], description: "VIP sender" },
|
|
178
|
+
{ action: "markJunk", keys: ["!"], description: "Mark junk" },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
title: "Go to",
|
|
183
|
+
hints: [
|
|
184
|
+
{ action: "goBrief", keys: ["g", "b"], description: "Daily brief" },
|
|
185
|
+
{ action: "goInbox", keys: ["g", "i"], description: "Inbox" },
|
|
186
|
+
{ action: "goSent", keys: ["g", "s"], description: "Sent" },
|
|
187
|
+
{ action: "goFlagged", keys: ["g", "f"], description: "Starred" },
|
|
188
|
+
{ action: "goSettings", keys: ["g", ","], description: "Settings" },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
title: "View & global",
|
|
193
|
+
hints: [
|
|
194
|
+
{
|
|
195
|
+
action: "toggleIntelligence",
|
|
196
|
+
keys: ["i"],
|
|
197
|
+
description: "Toggle intelligence",
|
|
198
|
+
},
|
|
199
|
+
{ action: "toggleDensity", keys: ["d"], description: "Toggle density" },
|
|
200
|
+
{ action: "focusSearch", keys: ["/"], description: "Focus search" },
|
|
201
|
+
{ action: "compose", keys: ["c"], description: "Compose" },
|
|
202
|
+
{ action: "compose", keys: ["⌘", "N"], description: "Compose" },
|
|
203
|
+
{ action: "help", keys: ["?"], description: "Shortcut help" },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Look up the displayed key tokens for an action (first matching hint). Used by
|
|
210
|
+
* toolbar tooltips so a single declaration drives both the overlay and the
|
|
211
|
+
* button titles. Returns `undefined` for actions with no hint.
|
|
212
|
+
*/
|
|
213
|
+
export function keysForAction(action: TriageAction): string[] | undefined {
|
|
214
|
+
for (const group of KEY_HINT_GROUPS) {
|
|
215
|
+
const hint = group.hints.find((h) => h.action === action);
|
|
216
|
+
if (hint) return hint.keys;
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render an action's binding as it reads to a user, e.g. "r" or "g then b".
|
|
223
|
+
* Returns "" when the action has no hint.
|
|
224
|
+
*/
|
|
225
|
+
export function shortcutHintForAction(action: TriageAction): string {
|
|
226
|
+
const keys = keysForAction(action);
|
|
227
|
+
if (!keys || keys.length === 0) return "";
|
|
228
|
+
if (keys.length === 1) return keys[0] ?? "";
|
|
229
|
+
// Sequence (go-to) keys read as "g then b"; modifier combos as "⌘N".
|
|
230
|
+
if (keys[0] === "g") return keys.join(" then ");
|
|
231
|
+
return keys.join("");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Render an action's binding as a compact tooltip suffix, e.g. "(r)" or
|
|
236
|
+
* "(g then b)". Returns "" when the action has no hint.
|
|
237
|
+
*/
|
|
238
|
+
export function tooltipForAction(action: TriageAction): string {
|
|
239
|
+
const hint = shortcutHintForAction(action);
|
|
240
|
+
return hint === "" ? "" : `(${hint})`;
|
|
241
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared list cursor (#149) — the roving keyboard cursor and multi-selection
|
|
3
|
+
* every thread list drives. Mounted against jsdom rather than `renderToString`,
|
|
4
|
+
* since the state only moves in response to real calls across renders.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
8
|
+
import type { JSDOM } from "jsdom";
|
|
9
|
+
import { act, createElement } from "react";
|
|
10
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
11
|
+
import { type ListCursor, useListCursor } from "./use-list-cursor.js";
|
|
12
|
+
|
|
13
|
+
const IDS = ["m1", "m2", "m3", "m4"];
|
|
14
|
+
|
|
15
|
+
let dom: JSDOM;
|
|
16
|
+
let container: HTMLElement;
|
|
17
|
+
let root: Root;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
21
|
+
dom = new JSDOMCtor(
|
|
22
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
23
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
24
|
+
);
|
|
25
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
26
|
+
globalThis.document = dom.window.document;
|
|
27
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
28
|
+
globalThis.Element = dom.window.Element;
|
|
29
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
30
|
+
value: dom.window.navigator,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
(
|
|
34
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
35
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
after(() => dom.window.close());
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
container = dom.window.document.getElementById(
|
|
42
|
+
"root",
|
|
43
|
+
) as unknown as HTMLElement;
|
|
44
|
+
container.innerHTML = "";
|
|
45
|
+
root = createRoot(container);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
act(() => root.unmount());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mount the hook and hand back a live handle. Every read goes through
|
|
54
|
+
* `current()` so a test never asserts against a stale render.
|
|
55
|
+
*/
|
|
56
|
+
function mountCursor(options: {
|
|
57
|
+
orderedIds?: string[];
|
|
58
|
+
isDesktop?: boolean;
|
|
59
|
+
initialFocusedId?: string;
|
|
60
|
+
}): () => ListCursor {
|
|
61
|
+
let latest: ListCursor | undefined;
|
|
62
|
+
const Probe = () => {
|
|
63
|
+
latest = useListCursor({
|
|
64
|
+
orderedIds: options.orderedIds ?? IDS,
|
|
65
|
+
isDesktop: options.isDesktop ?? true,
|
|
66
|
+
initialFocusedId: options.initialFocusedId,
|
|
67
|
+
});
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
act(() => root.render(createElement(Probe)));
|
|
71
|
+
return () => {
|
|
72
|
+
if (!latest) throw new Error("cursor not mounted");
|
|
73
|
+
return latest;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("useListCursor — the roving cursor", () => {
|
|
78
|
+
it("starts on the open thread and walks the list with next/previous", () => {
|
|
79
|
+
const cursor = mountCursor({ initialFocusedId: "m2" });
|
|
80
|
+
assert.equal(cursor().focusedMessageId, "m2");
|
|
81
|
+
|
|
82
|
+
act(() => cursor().focusNext());
|
|
83
|
+
assert.equal(cursor().focusedMessageId, "m3");
|
|
84
|
+
|
|
85
|
+
act(() => cursor().focusPrevious());
|
|
86
|
+
assert.equal(cursor().focusedMessageId, "m2");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("starts at the top when nothing is focused yet", () => {
|
|
90
|
+
const cursor = mountCursor({});
|
|
91
|
+
assert.equal(cursor().focusedMessageId, undefined);
|
|
92
|
+
|
|
93
|
+
act(() => cursor().focusNext());
|
|
94
|
+
assert.equal(cursor().focusedMessageId, "m1");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("clamps at both ends rather than wrapping", () => {
|
|
98
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
99
|
+
act(() => cursor().focusPrevious());
|
|
100
|
+
assert.equal(cursor().focusedMessageId, "m1");
|
|
101
|
+
|
|
102
|
+
act(() => cursor().focusLast());
|
|
103
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
104
|
+
act(() => cursor().focusNext());
|
|
105
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
106
|
+
|
|
107
|
+
act(() => cursor().focusFirst());
|
|
108
|
+
assert.equal(cursor().focusedMessageId, "m1");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("moves nothing when the list is empty", () => {
|
|
112
|
+
const cursor = mountCursor({ orderedIds: [] });
|
|
113
|
+
act(() => cursor().focusNext());
|
|
114
|
+
act(() => cursor().focusLast());
|
|
115
|
+
assert.equal(cursor().focusedMessageId, undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("asks for DOM focus on its own moves, not on a pointer move", () => {
|
|
119
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
120
|
+
act(() => cursor().focusNext());
|
|
121
|
+
assert.equal(cursor().pendingDomFocusRef.current, "m2");
|
|
122
|
+
assert.equal(cursor().cursorMovedByPointerRef.current, false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("useListCursor — selection", () => {
|
|
127
|
+
it("x toggles the row under the cursor", () => {
|
|
128
|
+
const cursor = mountCursor({ initialFocusedId: "m2" });
|
|
129
|
+
act(() => cursor().toggleFocusedSelection());
|
|
130
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m2"]);
|
|
131
|
+
|
|
132
|
+
act(() => cursor().toggleFocusedSelection());
|
|
133
|
+
assert.equal(cursor().selection.selectedCount, 0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("shift-extend moves the cursor and grows the range from the anchor", () => {
|
|
137
|
+
const cursor = mountCursor({ initialFocusedId: "m2" });
|
|
138
|
+
|
|
139
|
+
// The first press has no anchor yet, so the row it lands on becomes both
|
|
140
|
+
// the anchor and the whole range.
|
|
141
|
+
act(() => cursor().extendRangeDown());
|
|
142
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m3"]);
|
|
143
|
+
assert.equal(cursor().focusedMessageId, "m3");
|
|
144
|
+
assert.equal(cursor().selection.anchorId, "m3");
|
|
145
|
+
|
|
146
|
+
// Consecutive presses extend from that anchor.
|
|
147
|
+
act(() => cursor().extendRangeDown());
|
|
148
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), ["m3", "m4"]);
|
|
149
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
150
|
+
assert.equal(cursor().selection.anchorId, "m3");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("shift-extend upward ranges back through the anchor", () => {
|
|
154
|
+
const cursor = mountCursor({ initialFocusedId: "m4" });
|
|
155
|
+
|
|
156
|
+
act(() => cursor().extendRangeUp());
|
|
157
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m3"]);
|
|
158
|
+
|
|
159
|
+
act(() => cursor().extendRangeUp());
|
|
160
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), ["m2", "m3"]);
|
|
161
|
+
assert.equal(cursor().focusedMessageId, "m2");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("at the last row, extending takes that row and the cursor stays", () => {
|
|
165
|
+
const cursor = mountCursor({ initialFocusedId: "m4" });
|
|
166
|
+
act(() => cursor().extendRangeDown());
|
|
167
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m4"]);
|
|
168
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("select-all takes every row, and exiting clears it", () => {
|
|
172
|
+
const cursor = mountCursor({});
|
|
173
|
+
act(() => cursor().selectAllLoaded());
|
|
174
|
+
assert.equal(cursor().selection.selectedCount, IDS.length);
|
|
175
|
+
|
|
176
|
+
act(() => cursor().exitSelection());
|
|
177
|
+
assert.equal(cursor().selection.selectedCount, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("runs the caller's teardown before clearing", () => {
|
|
181
|
+
const order: string[] = [];
|
|
182
|
+
let latest: ListCursor | undefined;
|
|
183
|
+
const Probe = () => {
|
|
184
|
+
latest = useListCursor({
|
|
185
|
+
orderedIds: IDS,
|
|
186
|
+
isDesktop: true,
|
|
187
|
+
onExitSelection: () => order.push("teardown"),
|
|
188
|
+
});
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
act(() => root.render(createElement(Probe)));
|
|
192
|
+
act(() => latest?.selectAllLoaded());
|
|
193
|
+
act(() => latest?.exitSelection());
|
|
194
|
+
assert.deepEqual(order, ["teardown"]);
|
|
195
|
+
assert.equal(latest?.selection.selectedCount, 0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("useListCursor — mouse selection semantics", () => {
|
|
200
|
+
it("shift-click ranges, cmd-click toggles, and both consume the click", () => {
|
|
201
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
202
|
+
|
|
203
|
+
let handled = false;
|
|
204
|
+
act(() => {
|
|
205
|
+
handled = cursor().handleRowSelect("m3", {
|
|
206
|
+
shiftKey: true,
|
|
207
|
+
metaKey: false,
|
|
208
|
+
ctrlKey: false,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
assert.equal(handled, true);
|
|
212
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), [
|
|
213
|
+
"m1",
|
|
214
|
+
"m2",
|
|
215
|
+
"m3",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
act(() => {
|
|
219
|
+
handled = cursor().handleRowSelect("m4", {
|
|
220
|
+
shiftKey: false,
|
|
221
|
+
metaKey: true,
|
|
222
|
+
ctrlKey: false,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
assert.equal(handled, true);
|
|
226
|
+
assert.ok(cursor().selection.selectedIds.has("m4"));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("a plain click collapses the selection and lets the row open", () => {
|
|
230
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
231
|
+
act(() => cursor().selectAllLoaded());
|
|
232
|
+
|
|
233
|
+
let handled = true;
|
|
234
|
+
act(() => {
|
|
235
|
+
handled = cursor().handleRowSelect("m2", {
|
|
236
|
+
shiftKey: false,
|
|
237
|
+
metaKey: false,
|
|
238
|
+
ctrlKey: false,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
assert.equal(handled, false, "navigation must proceed on a plain click");
|
|
242
|
+
assert.equal(cursor().selection.selectedCount, 0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("useListCursor — the device branch", () => {
|
|
247
|
+
it("is not in multi-select mode on desktop, whatever is selected", () => {
|
|
248
|
+
const cursor = mountCursor({ isDesktop: true });
|
|
249
|
+
act(() => cursor().selectAllLoaded());
|
|
250
|
+
assert.equal(cursor().isMultiSelectMode, false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("enters multi-select mode on touch as soon as something is selected", () => {
|
|
254
|
+
const cursor = mountCursor({ isDesktop: false });
|
|
255
|
+
assert.equal(cursor().isMultiSelectMode, false);
|
|
256
|
+
|
|
257
|
+
act(() => cursor().selection.select("m2"));
|
|
258
|
+
assert.equal(cursor().isMultiSelectMode, true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("on touch, in multi-select mode, next/previous toggle instead of moving", () => {
|
|
262
|
+
const cursor = mountCursor({ isDesktop: false, initialFocusedId: "m1" });
|
|
263
|
+
act(() => cursor().selection.select("m1"));
|
|
264
|
+
|
|
265
|
+
act(() => cursor().focusNext());
|
|
266
|
+
assert.equal(
|
|
267
|
+
cursor().focusedMessageId,
|
|
268
|
+
"m1",
|
|
269
|
+
"the cursor stays put in multi-select mode",
|
|
270
|
+
);
|
|
271
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), ["m1", "m2"]);
|
|
272
|
+
});
|
|
273
|
+
});
|