@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook itself, driven on a mounted component: the pure helpers are covered
|
|
3
|
+
* in `use-selection.test.ts`, and what is left is the state each verb leaves
|
|
4
|
+
* behind — what a toggle does to the anchor, what a refresh narrows away, what
|
|
5
|
+
* a second shift-range extends from.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
10
|
+
import type { JSDOM } from "jsdom";
|
|
11
|
+
import { act, createElement } from "react";
|
|
12
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
13
|
+
import { useSelection } from "./use-selection.js";
|
|
14
|
+
|
|
15
|
+
let dom: JSDOM;
|
|
16
|
+
let container: HTMLElement;
|
|
17
|
+
let root: Root;
|
|
18
|
+
let api: ReturnType<typeof useSelection>;
|
|
19
|
+
|
|
20
|
+
function Harness() {
|
|
21
|
+
api = useSelection();
|
|
22
|
+
return createElement("div", { id: "harness" });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mount = () => {
|
|
26
|
+
act(() => {
|
|
27
|
+
root.render(createElement(Harness));
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const run = (fn: () => void) => {
|
|
32
|
+
act(fn);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const selected = () => Array.from(api.selectedIds);
|
|
36
|
+
|
|
37
|
+
before(async () => {
|
|
38
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
39
|
+
dom = new JSDOMCtor(
|
|
40
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
41
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
42
|
+
);
|
|
43
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
44
|
+
globalThis.document = dom.window.document;
|
|
45
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
46
|
+
globalThis.Element = dom.window.Element;
|
|
47
|
+
globalThis.SVGElement = dom.window.SVGElement;
|
|
48
|
+
(
|
|
49
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
50
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
after(() => {
|
|
54
|
+
dom.window.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
container = dom.window.document.getElementById(
|
|
59
|
+
"root",
|
|
60
|
+
) as unknown as HTMLElement;
|
|
61
|
+
container.innerHTML = "";
|
|
62
|
+
root = createRoot(container);
|
|
63
|
+
mount();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
act(() => {
|
|
68
|
+
root.unmount();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const ids = ["a", "b", "c", "d"];
|
|
73
|
+
|
|
74
|
+
describe("useSelection", () => {
|
|
75
|
+
it("starts empty", () => {
|
|
76
|
+
assert.deepEqual(selected(), []);
|
|
77
|
+
assert.equal(api.selectedCount, 0);
|
|
78
|
+
assert.equal(api.hasSelection, false);
|
|
79
|
+
assert.equal(api.anchorId, undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("toggles a row on and off, anchoring on it either way", () => {
|
|
83
|
+
run(() => api.toggle("b"));
|
|
84
|
+
assert.deepEqual(selected(), ["b"]);
|
|
85
|
+
assert.equal(api.isSelected("b"), true);
|
|
86
|
+
assert.equal(api.anchorId, "b");
|
|
87
|
+
|
|
88
|
+
run(() => api.toggle("b"));
|
|
89
|
+
assert.deepEqual(selected(), []);
|
|
90
|
+
assert.equal(api.anchorId, "b");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("selects and deselects a single row", () => {
|
|
94
|
+
run(() => api.select("a"));
|
|
95
|
+
run(() => api.select("a"));
|
|
96
|
+
assert.deepEqual(selected(), ["a"]);
|
|
97
|
+
|
|
98
|
+
run(() => api.deselect("a"));
|
|
99
|
+
assert.deepEqual(selected(), []);
|
|
100
|
+
run(() => api.deselect("a"));
|
|
101
|
+
assert.deepEqual(selected(), []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("selects every row, then clears the selection and the anchor", () => {
|
|
105
|
+
run(() => api.select("a"));
|
|
106
|
+
run(() => api.selectAll(ids));
|
|
107
|
+
assert.deepEqual(selected(), ids);
|
|
108
|
+
assert.equal(api.hasSelection, true);
|
|
109
|
+
|
|
110
|
+
run(() => api.clearSelection());
|
|
111
|
+
assert.deepEqual(selected(), []);
|
|
112
|
+
assert.equal(api.anchorId, undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("toggles all rows: on when any are unticked, off when they all are", () => {
|
|
116
|
+
run(() => api.toggleAll(ids));
|
|
117
|
+
assert.deepEqual(selected(), ids);
|
|
118
|
+
|
|
119
|
+
run(() => api.toggleAll(ids));
|
|
120
|
+
assert.deepEqual(selected(), []);
|
|
121
|
+
|
|
122
|
+
run(() => api.toggle("a"));
|
|
123
|
+
run(() => api.toggleAll(ids));
|
|
124
|
+
assert.deepEqual(selected(), ids);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("seeds the anchor without touching the selection", () => {
|
|
128
|
+
run(() => api.setAnchor("c"));
|
|
129
|
+
assert.deepEqual(selected(), []);
|
|
130
|
+
assert.equal(api.anchorId, "c");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("extends a range from the anchor, and keeps extending from it", () => {
|
|
134
|
+
run(() => api.toggle("b"));
|
|
135
|
+
run(() => api.selectRange(ids, "d"));
|
|
136
|
+
assert.deepEqual(selected(), ["b", "c", "d"]);
|
|
137
|
+
assert.equal(api.anchorId, "b");
|
|
138
|
+
|
|
139
|
+
run(() => api.selectRange(ids, "a"));
|
|
140
|
+
assert.deepEqual(selected().sort(), ["a", "b", "c", "d"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("ranges from the fallback once the stored anchor has left the list", () => {
|
|
144
|
+
run(() => api.toggle("a"));
|
|
145
|
+
const visible = ["c", "d"];
|
|
146
|
+
run(() => api.selectRange(visible, "d", "c"));
|
|
147
|
+
assert.deepEqual(selected().sort(), ["a", "c", "d"]);
|
|
148
|
+
assert.equal(api.anchorId, "c");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("leaves the selection alone when the target is not in the list", () => {
|
|
152
|
+
run(() => api.toggle("a"));
|
|
153
|
+
run(() => api.selectRange(["c", "d"], "zzz"));
|
|
154
|
+
assert.deepEqual(selected(), ["a"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("narrows to the survivors of a refresh and keeps the rest", () => {
|
|
158
|
+
run(() => api.selectAll(["a", "b", "c"]));
|
|
159
|
+
run(() => api.intersectWith(["a", "c", "d"]));
|
|
160
|
+
assert.deepEqual(selected(), ["a", "c"]);
|
|
161
|
+
|
|
162
|
+
const before = api.selectedIds;
|
|
163
|
+
run(() => api.intersectWith(["a", "c", "d"]));
|
|
164
|
+
assert.equal(api.selectedIds, before);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("stays put when nothing is selected to narrow", () => {
|
|
168
|
+
const before = api.selectedIds;
|
|
169
|
+
run(() => api.intersectWith(["a"]));
|
|
170
|
+
assert.equal(api.selectedIds, before);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
computeRange,
|
|
5
|
+
deriveIsMultiSelectMode,
|
|
6
|
+
intersectSelectedIds,
|
|
7
|
+
isModified,
|
|
8
|
+
modifiersOf,
|
|
9
|
+
nextFocusId,
|
|
10
|
+
resolveRangeAnchor,
|
|
11
|
+
rowSelectIntent,
|
|
12
|
+
} from "./use-selection.js";
|
|
13
|
+
|
|
14
|
+
const ids = ["a", "b", "c", "d", "e"];
|
|
15
|
+
|
|
16
|
+
// Mirrors the hook's `selectRange` state transition (anchor + selection) using
|
|
17
|
+
// the same pure helpers it runs, so the mouse/keyboard sequences below exercise
|
|
18
|
+
// the real logic without an interactive DOM. Returns the next {selected, anchor}.
|
|
19
|
+
const applyRange = (
|
|
20
|
+
state: { selected: Set<string>; anchor: string | undefined },
|
|
21
|
+
orderedIds: string[],
|
|
22
|
+
targetId: string,
|
|
23
|
+
fallbackAnchor?: string,
|
|
24
|
+
): { selected: Set<string>; anchor: string | undefined } => {
|
|
25
|
+
const anchor = resolveRangeAnchor(
|
|
26
|
+
orderedIds,
|
|
27
|
+
state.anchor,
|
|
28
|
+
fallbackAnchor,
|
|
29
|
+
targetId,
|
|
30
|
+
);
|
|
31
|
+
const range = computeRange(orderedIds, anchor, targetId);
|
|
32
|
+
const selected = new Set(state.selected);
|
|
33
|
+
for (const id of range) selected.add(id);
|
|
34
|
+
return { selected, anchor };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Mirrors a cmd/ctrl-click: toggle membership and re-anchor on the clicked row.
|
|
38
|
+
const applyToggle = (
|
|
39
|
+
state: { selected: Set<string>; anchor: string | undefined },
|
|
40
|
+
targetId: string,
|
|
41
|
+
): { selected: Set<string>; anchor: string | undefined } => {
|
|
42
|
+
const selected = new Set(state.selected);
|
|
43
|
+
if (selected.has(targetId)) selected.delete(targetId);
|
|
44
|
+
else selected.add(targetId);
|
|
45
|
+
return { selected, anchor: targetId };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("computeRange", () => {
|
|
49
|
+
test("forward range: anchor above target selects the inclusive slice", () => {
|
|
50
|
+
assert.deepStrictEqual(computeRange(ids, "b", "d"), ["b", "c", "d"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("backward range: target above anchor selects the same inclusive slice", () => {
|
|
54
|
+
assert.deepStrictEqual(computeRange(ids, "d", "b"), ["b", "c", "d"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("anchor equals target selects just that one id", () => {
|
|
58
|
+
assert.deepStrictEqual(computeRange(ids, "c", "c"), ["c"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("no anchor selects just the target", () => {
|
|
62
|
+
assert.deepStrictEqual(computeRange(ids, undefined, "c"), ["c"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("anchor not present in the list selects just the target", () => {
|
|
66
|
+
assert.deepStrictEqual(computeRange(ids, "zzz", "c"), ["c"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("full span from first to last includes every id in order", () => {
|
|
70
|
+
assert.deepStrictEqual(computeRange(ids, "a", "e"), [
|
|
71
|
+
"a",
|
|
72
|
+
"b",
|
|
73
|
+
"c",
|
|
74
|
+
"d",
|
|
75
|
+
"e",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("target not present in the list selects nothing", () => {
|
|
80
|
+
assert.deepStrictEqual(computeRange(ids, "b", "zzz"), []);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("resolveRangeAnchor", () => {
|
|
85
|
+
test("keeps a still-visible stored anchor so consecutive shift-clicks extend from it", () => {
|
|
86
|
+
assert.strictEqual(resolveRangeAnchor(ids, "b", undefined, "d"), "b");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a stored anchor no longer visible falls back to the open/focused row", () => {
|
|
90
|
+
// The stored anchor "z" was filtered/searched out of the visible list; the
|
|
91
|
+
// open row "b" is still visible, so the range anchors there.
|
|
92
|
+
assert.strictEqual(resolveRangeAnchor(ids, "z", "b", "d"), "b");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("with neither a visible stored anchor nor a visible fallback, the target anchors itself", () => {
|
|
96
|
+
assert.strictEqual(resolveRangeAnchor(ids, "z", "y", "d"), "d");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("no stored anchor and no fallback selects just the target", () => {
|
|
100
|
+
assert.strictEqual(resolveRangeAnchor(ids, undefined, undefined, "c"), "c");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a stored anchor beats the fallback while it stays visible", () => {
|
|
104
|
+
assert.strictEqual(resolveRangeAnchor(ids, "a", "c", "e"), "a");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("shift-click range over a filtered/search-narrowed list (#142, #144)", () => {
|
|
109
|
+
test("shift-click range with an active filter builds the range within the visible rows", () => {
|
|
110
|
+
// Full inbox is a..h; the "Automated" filter leaves only these rows.
|
|
111
|
+
const filtered = ["b", "d", "f", "g"];
|
|
112
|
+
// The open row "d" is visible; nothing selected yet, no stored anchor.
|
|
113
|
+
let state = {
|
|
114
|
+
selected: new Set<string>(),
|
|
115
|
+
anchor: undefined as string | undefined,
|
|
116
|
+
};
|
|
117
|
+
// Shift-click "g": ranges from the open/focused row "d" to "g".
|
|
118
|
+
state = applyRange(state, filtered, "g", "d");
|
|
119
|
+
assert.deepStrictEqual([...state.selected], ["d", "f", "g"]);
|
|
120
|
+
assert.strictEqual(state.anchor, "d");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("range where the anchor left the visible set re-anchors instead of no-oping", () => {
|
|
124
|
+
const filtered = ["b", "d", "f", "g"];
|
|
125
|
+
// Stored anchor "a" was selected before the filter narrowed the list and is
|
|
126
|
+
// no longer visible; there is no open/focused fallback row.
|
|
127
|
+
let state = {
|
|
128
|
+
selected: new Set<string>(),
|
|
129
|
+
anchor: "a" as string | undefined,
|
|
130
|
+
};
|
|
131
|
+
// First shift-click adopts the clicked row as the new visible anchor.
|
|
132
|
+
state = applyRange(state, filtered, "f");
|
|
133
|
+
assert.deepStrictEqual([...state.selected], ["f"]);
|
|
134
|
+
assert.strictEqual(state.anchor, "f");
|
|
135
|
+
// Second shift-click now builds a real range from that adopted anchor.
|
|
136
|
+
state = applyRange(state, filtered, "b");
|
|
137
|
+
assert.deepStrictEqual([...state.selected].sort(), ["b", "d", "f"]);
|
|
138
|
+
assert.strictEqual(state.anchor, "f");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("multi-select across a search-results list: cmd-click then shift-click", () => {
|
|
142
|
+
// Search "npm" yields these results; the inbox anchor is gone from this set.
|
|
143
|
+
const results = ["m1", "m2", "m3", "m4", "m5"];
|
|
144
|
+
let state = {
|
|
145
|
+
selected: new Set<string>(),
|
|
146
|
+
anchor: "inbox-row" as string | undefined,
|
|
147
|
+
};
|
|
148
|
+
// Cmd-click "m2": toggles it in and re-anchors on it (a visible row).
|
|
149
|
+
state = applyToggle(state, "m2");
|
|
150
|
+
assert.deepStrictEqual([...state.selected], ["m2"]);
|
|
151
|
+
assert.strictEqual(state.anchor, "m2");
|
|
152
|
+
// Shift-click "m4": ranges from the cmd-clicked anchor across the results.
|
|
153
|
+
state = applyRange(state, results, "m4");
|
|
154
|
+
assert.deepStrictEqual([...state.selected].sort(), ["m2", "m3", "m4"]);
|
|
155
|
+
assert.strictEqual(state.anchor, "m2");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("nextFocusId", () => {
|
|
160
|
+
test("moves down one row", () => {
|
|
161
|
+
assert.strictEqual(nextFocusId(ids, "b", 1), "c");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("moves up one row", () => {
|
|
165
|
+
assert.strictEqual(nextFocusId(ids, "c", -1), "b");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("clamps at the bottom (no wrap)", () => {
|
|
169
|
+
assert.strictEqual(nextFocusId(ids, "e", 1), "e");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("clamps at the top (no wrap)", () => {
|
|
173
|
+
assert.strictEqual(nextFocusId(ids, "a", -1), "a");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("no focus + down starts at the first row", () => {
|
|
177
|
+
assert.strictEqual(nextFocusId(ids, undefined, 1), "a");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("no focus + up starts at the last row", () => {
|
|
181
|
+
assert.strictEqual(nextFocusId(ids, undefined, -1), "e");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("focus not in the list + down starts at the first row", () => {
|
|
185
|
+
assert.strictEqual(nextFocusId(ids, "zzz", 1), "a");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("empty list returns undefined", () => {
|
|
189
|
+
assert.strictEqual(nextFocusId([], "a", 1), undefined);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("intersectSelectedIds", () => {
|
|
194
|
+
test("a refresh that drops some selected ids and adds new rows keeps only the survivors (#111)", () => {
|
|
195
|
+
// Regression for #111: the effect used to clear the WHOLE selection the
|
|
196
|
+
// moment any single selected id left the list. Here "b" leaves (deleted
|
|
197
|
+
// elsewhere) while "f" arrives (new mail) — "a" and "c" must survive.
|
|
198
|
+
const selected = new Set(["a", "b", "c"]);
|
|
199
|
+
const refreshedThreadIds = ["a", "c", "d", "f"];
|
|
200
|
+
assert.deepStrictEqual(
|
|
201
|
+
intersectSelectedIds(selected, refreshedThreadIds),
|
|
202
|
+
new Set(["a", "c"]),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("never adds an id that wasn't already selected", () => {
|
|
207
|
+
const selected = new Set(["a"]);
|
|
208
|
+
assert.deepStrictEqual(
|
|
209
|
+
intersectSelectedIds(selected, ["a", "b", "c"]),
|
|
210
|
+
new Set(["a"]),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("every selected id surviving is a no-op (same members, not just same size)", () => {
|
|
215
|
+
const selected = new Set(["a", "b"]);
|
|
216
|
+
assert.deepStrictEqual(
|
|
217
|
+
intersectSelectedIds(selected, ["a", "b", "z"]),
|
|
218
|
+
new Set(["a", "b"]),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a post-delete retry selection survives a refetch that still contains it, minus what actually left", () => {
|
|
223
|
+
// Mirrors processRunOutcome materializing the failed ids as the new
|
|
224
|
+
// selection, then the cache-invalidation refetch running this same
|
|
225
|
+
// intersection against the freshly reloaded `threads`. One retry id
|
|
226
|
+
// ("fail-2") is momentarily missing from the refreshed page; the other
|
|
227
|
+
// two must stay selected so the Retry notice (gated on
|
|
228
|
+
// `selectedCount > 0`) doesn't disappear with it.
|
|
229
|
+
const retrySelection = new Set(["fail-1", "fail-2", "fail-3"]);
|
|
230
|
+
const refetchedThreadIds = ["fail-1", "fail-3", "unrelated-1"];
|
|
231
|
+
assert.deepStrictEqual(
|
|
232
|
+
intersectSelectedIds(retrySelection, refetchedThreadIds),
|
|
233
|
+
new Set(["fail-1", "fail-3"]),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("everything in the selection leaving empties it, rather than leaving stale ids behind", () => {
|
|
238
|
+
const selected = new Set(["a", "b"]);
|
|
239
|
+
assert.deepStrictEqual(intersectSelectedIds(selected, ["z"]), new Set());
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("an empty selection stays empty", () => {
|
|
243
|
+
assert.deepStrictEqual(
|
|
244
|
+
intersectSelectedIds(new Set(), ["a", "b"]),
|
|
245
|
+
new Set(),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("modifiersOf", () => {
|
|
251
|
+
test("keeps only the three keys a selection reads off an event", () => {
|
|
252
|
+
const event = {
|
|
253
|
+
shiftKey: true,
|
|
254
|
+
metaKey: false,
|
|
255
|
+
ctrlKey: true,
|
|
256
|
+
altKey: true,
|
|
257
|
+
button: 0,
|
|
258
|
+
};
|
|
259
|
+
assert.deepStrictEqual(modifiersOf(event), {
|
|
260
|
+
shiftKey: true,
|
|
261
|
+
metaKey: false,
|
|
262
|
+
ctrlKey: true,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("isModified", () => {
|
|
268
|
+
test("is true for any of the three keys a keyboard can add", () => {
|
|
269
|
+
assert.equal(
|
|
270
|
+
isModified({ shiftKey: true, metaKey: false, ctrlKey: false }),
|
|
271
|
+
true,
|
|
272
|
+
);
|
|
273
|
+
assert.equal(
|
|
274
|
+
isModified({ shiftKey: false, metaKey: true, ctrlKey: false }),
|
|
275
|
+
true,
|
|
276
|
+
);
|
|
277
|
+
assert.equal(
|
|
278
|
+
isModified({ shiftKey: false, metaKey: false, ctrlKey: true }),
|
|
279
|
+
true,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("is false for the bare press a tap delivers", () => {
|
|
284
|
+
assert.equal(
|
|
285
|
+
isModified({ shiftKey: false, metaKey: false, ctrlKey: false }),
|
|
286
|
+
false,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("rowSelectIntent", () => {
|
|
292
|
+
test("shift asks for a range, and wins over the toggle keys", () => {
|
|
293
|
+
assert.equal(
|
|
294
|
+
rowSelectIntent({ shiftKey: true, metaKey: false, ctrlKey: false }),
|
|
295
|
+
"range",
|
|
296
|
+
);
|
|
297
|
+
assert.equal(
|
|
298
|
+
rowSelectIntent({ shiftKey: true, metaKey: true, ctrlKey: false }),
|
|
299
|
+
"range",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("cmd and ctrl tick one row", () => {
|
|
304
|
+
assert.equal(
|
|
305
|
+
rowSelectIntent({ shiftKey: false, metaKey: true, ctrlKey: false }),
|
|
306
|
+
"toggle",
|
|
307
|
+
);
|
|
308
|
+
assert.equal(
|
|
309
|
+
rowSelectIntent({ shiftKey: false, metaKey: false, ctrlKey: true }),
|
|
310
|
+
"toggle",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a bare click opens the row", () => {
|
|
315
|
+
assert.equal(
|
|
316
|
+
rowSelectIntent({ shiftKey: false, metaKey: false, ctrlKey: false }),
|
|
317
|
+
"open",
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("deriveIsMultiSelectMode", () => {
|
|
323
|
+
test("is the touch affordance, and only while something is ticked", () => {
|
|
324
|
+
assert.equal(deriveIsMultiSelectMode(0, false), false);
|
|
325
|
+
assert.equal(deriveIsMultiSelectMode(1, false), true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("is never on at desktop widths, whatever the count", () => {
|
|
329
|
+
assert.equal(deriveIsMultiSelectMode(3, true), false);
|
|
330
|
+
});
|
|
331
|
+
});
|