@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,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListCursor — the roving keyboard cursor and multi-selection of a thread
|
|
3
|
+
* list, independent of how the list renders.
|
|
4
|
+
*
|
|
5
|
+
* The mailbox list, the daily brief and Flagged all need it. It used to exist
|
|
6
|
+
* only inside `MessageList`, so the brief and Flagged had no cursor and no
|
|
7
|
+
* selection at all (#149). Everything here is list-shape agnostic: it works off
|
|
8
|
+
* the ordered message ids, so a virtualized flat list and a sectioned brief
|
|
9
|
+
* drive the same state.
|
|
10
|
+
*
|
|
11
|
+
* DOM concerns stay with the caller. `pendingDomFocusRef` names the row that
|
|
12
|
+
* should take real browser focus once it is rendered, and
|
|
13
|
+
* `cursorMovedByPointerRef` records whether the last move came from a click, so
|
|
14
|
+
* a list that scrolls its cursor into view can skip doing so for pointer moves.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
18
|
+
import {
|
|
19
|
+
deriveIsMultiSelectMode,
|
|
20
|
+
nextFocusId,
|
|
21
|
+
rowSelectIntent,
|
|
22
|
+
type SelectionModifiers,
|
|
23
|
+
useSelection,
|
|
24
|
+
} from "./use-selection.js";
|
|
25
|
+
|
|
26
|
+
interface UseListCursorOptions {
|
|
27
|
+
/** Message ids in display order. */
|
|
28
|
+
orderedIds: string[];
|
|
29
|
+
isDesktop: boolean;
|
|
30
|
+
/** Seeds the cursor — normally the open thread. */
|
|
31
|
+
initialFocusedId?: string;
|
|
32
|
+
/** Rows ticked on first render, for a surface that opens with a selection. */
|
|
33
|
+
initialSelectedIds?: readonly string[];
|
|
34
|
+
/** Extra teardown run before the selection is cleared. */
|
|
35
|
+
onExitSelection?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ListCursor {
|
|
39
|
+
focusedMessageId: string | undefined;
|
|
40
|
+
setFocusedMessageId: (id: string | undefined) => void;
|
|
41
|
+
/**
|
|
42
|
+
* The row a keyboard command last moved the cursor onto, and `undefined`
|
|
43
|
+
* whenever the cursor last moved some other way. Drives the reading pane
|
|
44
|
+
* following the cursor (`useFollowFocusOpen`) — a click and Enter open on
|
|
45
|
+
* their own, so only a bare cursor move is left to follow.
|
|
46
|
+
*/
|
|
47
|
+
keyboardFocusedMessageId: string | undefined;
|
|
48
|
+
focusIndex: number;
|
|
49
|
+
pendingDomFocusRef: React.RefObject<string | null>;
|
|
50
|
+
cursorMovedByPointerRef: React.RefObject<boolean>;
|
|
51
|
+
selection: ReturnType<typeof useSelection>;
|
|
52
|
+
isMultiSelectMode: boolean;
|
|
53
|
+
exitSelection: () => void;
|
|
54
|
+
moveFocusToIndex: (index: number) => void;
|
|
55
|
+
focusNext: () => void;
|
|
56
|
+
focusPrevious: () => void;
|
|
57
|
+
focusFirst: () => void;
|
|
58
|
+
focusLast: () => void;
|
|
59
|
+
toggleFocusedSelection: () => void;
|
|
60
|
+
extendRangeUp: () => void;
|
|
61
|
+
extendRangeDown: () => void;
|
|
62
|
+
selectAllLoaded: () => void;
|
|
63
|
+
/**
|
|
64
|
+
* Desktop mouse selection semantics (Apple Mail / Gmail model). Returns true
|
|
65
|
+
* when selection handled the click — the caller must then suppress the
|
|
66
|
+
* row's navigation; false for a plain click.
|
|
67
|
+
*/
|
|
68
|
+
handleRowSelect: (
|
|
69
|
+
messageId: string,
|
|
70
|
+
modifiers: SelectionModifiers,
|
|
71
|
+
) => boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const useListCursor = ({
|
|
75
|
+
orderedIds,
|
|
76
|
+
isDesktop,
|
|
77
|
+
initialFocusedId,
|
|
78
|
+
initialSelectedIds,
|
|
79
|
+
onExitSelection,
|
|
80
|
+
}: UseListCursorOptions): ListCursor => {
|
|
81
|
+
// The keyboard "where am I" pointer, distinct from the open thread
|
|
82
|
+
// (`selectedMessageId` in the URL). j/k move this cursor; Enter opens the
|
|
83
|
+
// focused row, and on desktop the reading pane follows the cursor of its own
|
|
84
|
+
// accord (`useFollowFocusOpen`). It seeds from the open thread so opening a
|
|
85
|
+
// message also focuses its row.
|
|
86
|
+
const [focusedMessageId, setFocusedId] = useState<string | undefined>(
|
|
87
|
+
initialFocusedId,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Which of those moves came from a keyboard command, so the reading pane can
|
|
91
|
+
// follow the cursor without following a click that already opened its own row.
|
|
92
|
+
const [keyboardFocusedMessageId, setKeyboardFocusedMessageId] = useState<
|
|
93
|
+
string | undefined
|
|
94
|
+
>();
|
|
95
|
+
|
|
96
|
+
// Every non-keyboard move — a click, Tab, a thread opening, a refetch snapping
|
|
97
|
+
// the cursor to a survivor — drops the keyboard mark, so nothing follows it.
|
|
98
|
+
// A row taking DOM focus as the *consequence* of a keyboard move arrives here
|
|
99
|
+
// with the id that move just set; keeping the mark in that case is what stops
|
|
100
|
+
// the browser's own focus event from cancelling the load the move started.
|
|
101
|
+
const setFocusedMessageId = useCallback((id: string | undefined) => {
|
|
102
|
+
setKeyboardFocusedMessageId((current) =>
|
|
103
|
+
current === id ? current : undefined,
|
|
104
|
+
);
|
|
105
|
+
setFocusedId(id);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const selection = useSelection({ initialSelectedIds });
|
|
109
|
+
const {
|
|
110
|
+
selectedCount,
|
|
111
|
+
toggle: toggleCheck,
|
|
112
|
+
clearSelection,
|
|
113
|
+
selectRange,
|
|
114
|
+
setAnchor,
|
|
115
|
+
selectAll,
|
|
116
|
+
} = selection;
|
|
117
|
+
|
|
118
|
+
// The selection count is the only source of truth for whether the list is in
|
|
119
|
+
// multi-select mode (#115). A separate flag needs an effect to reconcile it
|
|
120
|
+
// back to the count, and across that render the two disagree.
|
|
121
|
+
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
122
|
+
|
|
123
|
+
// Set when a keyboard command moves the cursor. Real DOM focus then follows
|
|
124
|
+
// it onto the row once rendered, so the browser's own focus — and therefore
|
|
125
|
+
// Tab, Shift+Tab and the focus ring — agree with what the list highlights
|
|
126
|
+
// (#43).
|
|
127
|
+
const pendingDomFocusRef = useRef<string | null>(null);
|
|
128
|
+
// Whether the cursor's last move came from a row taking DOM focus (a click)
|
|
129
|
+
// rather than a command. Scrolling for a click moves the row out from under
|
|
130
|
+
// the pointer between mousedown and click, so the click lands on empty space
|
|
131
|
+
// and nothing opens (#85).
|
|
132
|
+
const cursorMovedByPointerRef = useRef(false);
|
|
133
|
+
|
|
134
|
+
const exitSelection = useCallback(() => {
|
|
135
|
+
onExitSelection?.();
|
|
136
|
+
clearSelection();
|
|
137
|
+
}, [clearSelection, onExitSelection]);
|
|
138
|
+
|
|
139
|
+
const focusIndex = useMemo(
|
|
140
|
+
() => (focusedMessageId ? orderedIds.indexOf(focusedMessageId) : -1),
|
|
141
|
+
[orderedIds, focusedMessageId],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Move the cursor by index. In multi-select mode (mobile) j/k toggle
|
|
145
|
+
// selection rather than moving a cursor.
|
|
146
|
+
const moveFocusToIndex = useCallback(
|
|
147
|
+
(index: number) => {
|
|
148
|
+
if (index < 0 || index >= orderedIds.length) return;
|
|
149
|
+
const messageId = orderedIds[index];
|
|
150
|
+
if (isMultiSelectMode) {
|
|
151
|
+
toggleCheck(messageId);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
pendingDomFocusRef.current = messageId;
|
|
155
|
+
cursorMovedByPointerRef.current = false;
|
|
156
|
+
setKeyboardFocusedMessageId(messageId);
|
|
157
|
+
setFocusedId(messageId);
|
|
158
|
+
},
|
|
159
|
+
[orderedIds, isMultiSelectMode, toggleCheck],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const focusNext = useCallback(() => {
|
|
163
|
+
if (orderedIds.length === 0) return;
|
|
164
|
+
moveFocusToIndex(
|
|
165
|
+
focusIndex < 0 ? 0 : Math.min(focusIndex + 1, orderedIds.length - 1),
|
|
166
|
+
);
|
|
167
|
+
}, [orderedIds.length, focusIndex, moveFocusToIndex]);
|
|
168
|
+
|
|
169
|
+
const focusPrevious = useCallback(() => {
|
|
170
|
+
if (orderedIds.length === 0) return;
|
|
171
|
+
moveFocusToIndex(focusIndex <= 0 ? 0 : focusIndex - 1);
|
|
172
|
+
}, [orderedIds.length, focusIndex, moveFocusToIndex]);
|
|
173
|
+
|
|
174
|
+
const focusFirst = useCallback(() => moveFocusToIndex(0), [moveFocusToIndex]);
|
|
175
|
+
const focusLast = useCallback(
|
|
176
|
+
() => moveFocusToIndex(orderedIds.length - 1),
|
|
177
|
+
[moveFocusToIndex, orderedIds.length],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const toggleFocusedSelection = useCallback(() => {
|
|
181
|
+
if (focusedMessageId) toggleCheck(focusedMessageId);
|
|
182
|
+
}, [focusedMessageId, toggleCheck]);
|
|
183
|
+
|
|
184
|
+
const handleRowSelect = useCallback(
|
|
185
|
+
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
186
|
+
const intent = rowSelectIntent(modifiers);
|
|
187
|
+
if (intent === "range") {
|
|
188
|
+
// The open/focused row is the fallback origin when the stored anchor
|
|
189
|
+
// has been filtered or searched out of the visible list, so the first
|
|
190
|
+
// shift-click still ranges from where the user is (#142, #144).
|
|
191
|
+
selectRange(orderedIds, messageId, focusedMessageId);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (intent === "toggle") {
|
|
195
|
+
toggleCheck(messageId);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
// Plain click: collapse any multi-selection and let navigation proceed.
|
|
199
|
+
// The clicked row becomes the next anchor for a subsequent shift-click,
|
|
200
|
+
// but is NOT added to the checkbox set (no toolbar on a plain open).
|
|
201
|
+
exitSelection();
|
|
202
|
+
setAnchor(messageId);
|
|
203
|
+
return false;
|
|
204
|
+
},
|
|
205
|
+
[
|
|
206
|
+
orderedIds,
|
|
207
|
+
focusedMessageId,
|
|
208
|
+
selectRange,
|
|
209
|
+
toggleCheck,
|
|
210
|
+
exitSelection,
|
|
211
|
+
setAnchor,
|
|
212
|
+
],
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// Shift+arrow moves the cursor one row and adds the row it lands on to the
|
|
216
|
+
// range — the keyboard equivalent of shift-click. The first press seeds the
|
|
217
|
+
// anchor on that row; consecutive presses extend from it. The range only
|
|
218
|
+
// grows, so reversing direction ranges back through the anchor rather than
|
|
219
|
+
// giving rows up.
|
|
220
|
+
const extendRange = useCallback(
|
|
221
|
+
(direction: -1 | 1) => {
|
|
222
|
+
const target = nextFocusId(orderedIds, focusedMessageId, direction);
|
|
223
|
+
if (target === undefined) return;
|
|
224
|
+
selectRange(orderedIds, target);
|
|
225
|
+
pendingDomFocusRef.current = target;
|
|
226
|
+
cursorMovedByPointerRef.current = false;
|
|
227
|
+
// Shift+arrow is building a range, not reading. The reading pane stays on
|
|
228
|
+
// whatever is open rather than chasing the growing edge of the selection.
|
|
229
|
+
setKeyboardFocusedMessageId(undefined);
|
|
230
|
+
setFocusedId(target);
|
|
231
|
+
},
|
|
232
|
+
[orderedIds, focusedMessageId, selectRange],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const extendRangeUp = useCallback(() => extendRange(-1), [extendRange]);
|
|
236
|
+
const extendRangeDown = useCallback(() => extendRange(1), [extendRange]);
|
|
237
|
+
|
|
238
|
+
const selectAllLoaded = useCallback(() => {
|
|
239
|
+
if (orderedIds.length > 0) selectAll(orderedIds);
|
|
240
|
+
}, [orderedIds, selectAll]);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
focusedMessageId,
|
|
244
|
+
setFocusedMessageId,
|
|
245
|
+
keyboardFocusedMessageId,
|
|
246
|
+
focusIndex,
|
|
247
|
+
pendingDomFocusRef,
|
|
248
|
+
cursorMovedByPointerRef,
|
|
249
|
+
selection,
|
|
250
|
+
isMultiSelectMode,
|
|
251
|
+
exitSelection,
|
|
252
|
+
moveFocusToIndex,
|
|
253
|
+
focusNext,
|
|
254
|
+
focusPrevious,
|
|
255
|
+
focusFirst,
|
|
256
|
+
focusLast,
|
|
257
|
+
toggleFocusedSelection,
|
|
258
|
+
extendRangeUp,
|
|
259
|
+
extendRangeDown,
|
|
260
|
+
selectAllLoaded,
|
|
261
|
+
handleRowSelect,
|
|
262
|
+
};
|
|
263
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list's keyboard layer as a host mounts it: the keys reach the cursor from
|
|
3
|
+
* inside the element the layer was given and from nowhere else, the row-click
|
|
4
|
+
* path reads its modifiers the same way, the selection follows the rows it is
|
|
5
|
+
* handed, and the footer offers only the actions the layer registered.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
9
|
+
import type { JSDOM } from "jsdom";
|
|
10
|
+
import { act, createElement } from "react";
|
|
11
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
+
import { type ListKeyboard, useListKeyboard } from "./use-list-keyboard.js";
|
|
13
|
+
|
|
14
|
+
const ALL_IDS = ["m1", "m2", "m3", "m4"];
|
|
15
|
+
|
|
16
|
+
let dom: JSDOM;
|
|
17
|
+
let container: HTMLElement;
|
|
18
|
+
let root: Root;
|
|
19
|
+
let list: ListKeyboard;
|
|
20
|
+
|
|
21
|
+
function Harness({ orderedIds }: { orderedIds: string[] }) {
|
|
22
|
+
list = useListKeyboard({ orderedIds, isDesktop: true });
|
|
23
|
+
return createElement("section", { id: "pane", ref: list.keyboard.ref });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mount = (orderedIds: string[] = ALL_IDS) => {
|
|
27
|
+
act(() => {
|
|
28
|
+
root.render(createElement(Harness, { orderedIds }));
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const pane = (): HTMLElement => {
|
|
33
|
+
const element = dom.window.document.getElementById("pane");
|
|
34
|
+
assert.ok(element, "the harness rendered no pane");
|
|
35
|
+
return element as unknown as HTMLElement;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const press = (
|
|
39
|
+
key: string,
|
|
40
|
+
init: KeyboardEventInit = {},
|
|
41
|
+
target: EventTarget = pane(),
|
|
42
|
+
) => {
|
|
43
|
+
act(() => {
|
|
44
|
+
target.dispatchEvent(
|
|
45
|
+
new dom.window.KeyboardEvent("keydown", {
|
|
46
|
+
key,
|
|
47
|
+
bubbles: true,
|
|
48
|
+
cancelable: true,
|
|
49
|
+
...init,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const click = (
|
|
56
|
+
id: string,
|
|
57
|
+
modifiers: { shiftKey?: boolean; metaKey?: boolean } = {},
|
|
58
|
+
): boolean => {
|
|
59
|
+
let took = false;
|
|
60
|
+
act(() => {
|
|
61
|
+
took =
|
|
62
|
+
list.selection.onRowSelect?.(id, {
|
|
63
|
+
shiftKey: false,
|
|
64
|
+
metaKey: false,
|
|
65
|
+
ctrlKey: false,
|
|
66
|
+
...modifiers,
|
|
67
|
+
}) ?? false;
|
|
68
|
+
});
|
|
69
|
+
return took;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const selected = () => Array.from(list.cursor.selection.selectedIds).sort();
|
|
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
|
+
mount();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
act(() => {
|
|
105
|
+
root.unmount();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("useListKeyboard", () => {
|
|
110
|
+
it("registers the keys the footer may offer and no others", () => {
|
|
111
|
+
assert.deepEqual(Object.keys(list.keyboard.handlers).sort(), [
|
|
112
|
+
"back",
|
|
113
|
+
"extendSelectDown",
|
|
114
|
+
"extendSelectUp",
|
|
115
|
+
"focusFirst",
|
|
116
|
+
"focusLast",
|
|
117
|
+
"focusNext",
|
|
118
|
+
"focusPrevious",
|
|
119
|
+
"selectAll",
|
|
120
|
+
"toggleSelect",
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("moves the cursor the pane draws", () => {
|
|
125
|
+
press("j");
|
|
126
|
+
press("j");
|
|
127
|
+
assert.equal(list.keyboard.focusedId, "m2");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("ticks the row under the cursor", () => {
|
|
131
|
+
press("j");
|
|
132
|
+
press("x");
|
|
133
|
+
assert.deepEqual(selected(), ["m1"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("takes every loaded row with ⌘A", () => {
|
|
137
|
+
press("a", { metaKey: true });
|
|
138
|
+
assert.deepEqual(selected(), ALL_IDS);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("leaves a key pressed outside its own element alone", () => {
|
|
142
|
+
press("j", {}, dom.window.document.body);
|
|
143
|
+
assert.equal(list.keyboard.focusedId, undefined);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("ticks a row on a cmd-click without opening it", () => {
|
|
147
|
+
assert.equal(click("m2", { metaKey: true }), true);
|
|
148
|
+
assert.deepEqual(selected(), ["m2"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("ranges from the last row touched on a shift-click", () => {
|
|
152
|
+
click("m2");
|
|
153
|
+
assert.equal(click("m4", { shiftKey: true }), true);
|
|
154
|
+
assert.deepEqual(selected(), ["m2", "m3", "m4"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("opens a plain click rather than taking it", () => {
|
|
158
|
+
assert.equal(click("m2"), false);
|
|
159
|
+
assert.deepEqual(selected(), []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("drops the ticked rows that leave the list, and keeps the rest", () => {
|
|
163
|
+
press("a", { metaKey: true });
|
|
164
|
+
assert.deepEqual(selected(), ALL_IDS);
|
|
165
|
+
mount(["m1", "m3"]);
|
|
166
|
+
assert.deepEqual(
|
|
167
|
+
selected(),
|
|
168
|
+
["m1", "m3"],
|
|
169
|
+
"a verb acts on the rows that are still on screen",
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list under the triage keyboard: the cursor, the selection and the two
|
|
3
|
+
* props `MessageListPane` draws them from.
|
|
4
|
+
*
|
|
5
|
+
* The app routes the same actions through its own list commands, because its
|
|
6
|
+
* rows are virtualized and its verbs reach the server. Everything else that
|
|
7
|
+
* mounts a list — the kit's stories and the Storybook prototype — drives it
|
|
8
|
+
* from here, so a shift-range, a cmd-toggle and select-all are one behaviour
|
|
9
|
+
* with one definition, and the footer offers exactly the keys that are wired.
|
|
10
|
+
*
|
|
11
|
+
* The layer binds its keys to the pane element rather than the window, so a
|
|
12
|
+
* page carrying several lists gives each of them only the keys pressed inside
|
|
13
|
+
* it.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useMemo, useState } from "react";
|
|
16
|
+
import type {
|
|
17
|
+
MessageListKeyboard,
|
|
18
|
+
MessageListSelection,
|
|
19
|
+
} from "../components/app-shell-types.js";
|
|
20
|
+
import type { TriageHandlers } from "./keymap.js";
|
|
21
|
+
import { type ListCursor, useListCursor } from "./use-list-cursor.js";
|
|
22
|
+
import { useTriageKeyboard } from "./use-triage-keyboard.js";
|
|
23
|
+
|
|
24
|
+
export interface UseListKeyboardOptions {
|
|
25
|
+
/** Row ids in display order. */
|
|
26
|
+
orderedIds: string[];
|
|
27
|
+
isDesktop: boolean;
|
|
28
|
+
/** Seeds the cursor — normally the open thread. */
|
|
29
|
+
initialFocusedId?: string;
|
|
30
|
+
/** Rows ticked on first render. */
|
|
31
|
+
initialSelectedIds?: readonly string[];
|
|
32
|
+
/** Off while something above the list owns the keyboard. */
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ListKeyboard {
|
|
37
|
+
cursor: ListCursor;
|
|
38
|
+
/** The pane's `selection` prop. */
|
|
39
|
+
selection: MessageListSelection;
|
|
40
|
+
/** The pane's `keyboard` prop. */
|
|
41
|
+
keyboard: MessageListKeyboard;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const useListKeyboard = ({
|
|
45
|
+
orderedIds,
|
|
46
|
+
isDesktop,
|
|
47
|
+
initialFocusedId,
|
|
48
|
+
initialSelectedIds,
|
|
49
|
+
enabled = true,
|
|
50
|
+
}: UseListKeyboardOptions): ListKeyboard => {
|
|
51
|
+
const [pane, setPane] = useState<HTMLElement | null>(null);
|
|
52
|
+
|
|
53
|
+
const cursor = useListCursor({
|
|
54
|
+
orderedIds,
|
|
55
|
+
isDesktop,
|
|
56
|
+
initialFocusedId,
|
|
57
|
+
initialSelectedIds,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const handlers: TriageHandlers = {
|
|
61
|
+
focusNext: cursor.focusNext,
|
|
62
|
+
focusPrevious: cursor.focusPrevious,
|
|
63
|
+
focusFirst: cursor.focusFirst,
|
|
64
|
+
focusLast: cursor.focusLast,
|
|
65
|
+
toggleSelect: cursor.toggleFocusedSelection,
|
|
66
|
+
extendSelectDown: cursor.extendRangeDown,
|
|
67
|
+
extendSelectUp: cursor.extendRangeUp,
|
|
68
|
+
selectAll: cursor.selectAllLoaded,
|
|
69
|
+
back: cursor.exitSelection,
|
|
70
|
+
};
|
|
71
|
+
useTriageKeyboard({ handlers, enabled, target: pane });
|
|
72
|
+
|
|
73
|
+
// A row that leaves the list — a filter, an account pill, a completed verb —
|
|
74
|
+
// cannot stay selected, or the count and the verbs act on rows nobody can
|
|
75
|
+
// see. The same rule the app runs in `ThreadListInteraction`.
|
|
76
|
+
const { intersectWith } = cursor.selection;
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
intersectWith(orderedIds);
|
|
79
|
+
}, [intersectWith, orderedIds]);
|
|
80
|
+
|
|
81
|
+
const { selectedIds, toggle } = cursor.selection;
|
|
82
|
+
const { handleRowSelect } = cursor;
|
|
83
|
+
const selection = useMemo<MessageListSelection>(
|
|
84
|
+
() => ({
|
|
85
|
+
selectedIds,
|
|
86
|
+
onToggle: toggle,
|
|
87
|
+
onRowSelect: handleRowSelect,
|
|
88
|
+
}),
|
|
89
|
+
[selectedIds, toggle, handleRowSelect],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
cursor,
|
|
94
|
+
selection,
|
|
95
|
+
keyboard: {
|
|
96
|
+
focusedId: cursor.focusedMessageId,
|
|
97
|
+
handlers,
|
|
98
|
+
ref: setPane,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook on a mounted component: `keymap-dispatch.test.ts` covers which
|
|
3
|
+
* action a stroke resolves to, and what is left is the wiring — which handler
|
|
4
|
+
* a real keydown reaches, what the `g …` window does across two presses, and
|
|
5
|
+
* what the layer leaves bound after it is taken down.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
9
|
+
import type { JSDOM } from "jsdom";
|
|
10
|
+
import { act, createElement } from "react";
|
|
11
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
+
import type { TriageAction, TriageHandlers } from "./keymap.js";
|
|
13
|
+
import { useTriageKeyboard } from "./use-triage-keyboard.js";
|
|
14
|
+
|
|
15
|
+
let dom: JSDOM;
|
|
16
|
+
let container: HTMLElement;
|
|
17
|
+
let root: Root;
|
|
18
|
+
let fired: TriageAction[];
|
|
19
|
+
|
|
20
|
+
const handlers = (): TriageHandlers => ({
|
|
21
|
+
focusNext: () => fired.push("focusNext"),
|
|
22
|
+
toggleSelect: () => fired.push("toggleSelect"),
|
|
23
|
+
selectAll: () => fired.push("selectAll"),
|
|
24
|
+
goInbox: () => fired.push("goInbox"),
|
|
25
|
+
back: () => fired.push("back"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function Harness({ enabled }: { enabled: boolean }) {
|
|
29
|
+
useTriageKeyboard({ handlers: handlers(), enabled });
|
|
30
|
+
return createElement("input", { id: "field" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const mount = (enabled = true) => {
|
|
34
|
+
act(() => {
|
|
35
|
+
root.render(createElement(Harness, { enabled }));
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const press = (
|
|
40
|
+
key: string,
|
|
41
|
+
init: KeyboardEventInit = {},
|
|
42
|
+
target: EventTarget = dom.window.document.body,
|
|
43
|
+
): KeyboardEvent => {
|
|
44
|
+
const event = new dom.window.KeyboardEvent("keydown", {
|
|
45
|
+
key,
|
|
46
|
+
bubbles: true,
|
|
47
|
+
cancelable: true,
|
|
48
|
+
...init,
|
|
49
|
+
});
|
|
50
|
+
act(() => {
|
|
51
|
+
target.dispatchEvent(event);
|
|
52
|
+
});
|
|
53
|
+
return event as unknown as KeyboardEvent;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
before(async () => {
|
|
57
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
58
|
+
dom = new JSDOMCtor(
|
|
59
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
60
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
61
|
+
);
|
|
62
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
63
|
+
globalThis.document = dom.window.document;
|
|
64
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
65
|
+
globalThis.Element = dom.window.Element;
|
|
66
|
+
globalThis.SVGElement = dom.window.SVGElement;
|
|
67
|
+
(
|
|
68
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
69
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
after(() => {
|
|
73
|
+
dom.window.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
fired = [];
|
|
78
|
+
container = dom.window.document.getElementById(
|
|
79
|
+
"root",
|
|
80
|
+
) as unknown as HTMLElement;
|
|
81
|
+
container.innerHTML = "";
|
|
82
|
+
root = createRoot(container);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
act(() => {
|
|
87
|
+
root.unmount();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("useTriageKeyboard", () => {
|
|
92
|
+
it("routes a stroke to the handler its action names", () => {
|
|
93
|
+
mount();
|
|
94
|
+
press("j");
|
|
95
|
+
assert.deepEqual(fired, ["focusNext"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("takes the browser's default off a stroke it serves", () => {
|
|
99
|
+
mount();
|
|
100
|
+
assert.equal(press("j").defaultPrevented, true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves a stroke no handler serves to the browser", () => {
|
|
104
|
+
mount();
|
|
105
|
+
assert.equal(press("r").defaultPrevented, false);
|
|
106
|
+
assert.deepEqual(fired, []);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("carries a go-to prefix across two strokes", () => {
|
|
110
|
+
mount();
|
|
111
|
+
press("g");
|
|
112
|
+
press("i");
|
|
113
|
+
assert.deepEqual(fired, ["goInbox"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("drops the prefix once the sequence resolves", () => {
|
|
117
|
+
mount();
|
|
118
|
+
press("g");
|
|
119
|
+
press("i");
|
|
120
|
+
press("i");
|
|
121
|
+
assert.deepEqual(fired, ["goInbox"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("stays inert while focus is in an editable surface", () => {
|
|
125
|
+
mount();
|
|
126
|
+
const field = dom.window.document.getElementById("field");
|
|
127
|
+
assert.ok(field);
|
|
128
|
+
press("j", {}, field);
|
|
129
|
+
assert.deepEqual(fired, []);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("claims ⌘A from the browser's select-all", () => {
|
|
133
|
+
mount();
|
|
134
|
+
assert.equal(press("a", { metaKey: true }).defaultPrevented, true);
|
|
135
|
+
assert.deepEqual(fired, ["selectAll"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("binds nothing while disabled", () => {
|
|
139
|
+
mount(false);
|
|
140
|
+
press("j");
|
|
141
|
+
assert.deepEqual(fired, []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("releases the keyboard when it comes down", () => {
|
|
145
|
+
mount();
|
|
146
|
+
act(() => {
|
|
147
|
+
root.unmount();
|
|
148
|
+
});
|
|
149
|
+
press("j");
|
|
150
|
+
assert.deepEqual(fired, []);
|
|
151
|
+
root = createRoot(container);
|
|
152
|
+
});
|
|
153
|
+
});
|