react-arborist 3.10.0 → 3.10.2
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/README.md +16 -0
- package/dist/main/components/default-container.test.js +40 -12
- package/dist/main/components/row-container.js +7 -1
- package/dist/main/dnd/drop-hook.js +1 -11
- package/dist/main/dnd/outer-drop-hook.js +7 -0
- package/dist/main/interfaces/tree-api.d.ts +1 -0
- package/dist/main/interfaces/tree-api.js +13 -2
- package/dist/main/interfaces/tree-api.test.js +76 -0
- package/dist/module/components/default-container.test.js +41 -13
- package/dist/module/components/row-container.js +7 -1
- package/dist/module/dnd/drop-hook.js +1 -11
- package/dist/module/dnd/outer-drop-hook.js +7 -0
- package/dist/module/interfaces/tree-api.d.ts +1 -0
- package/dist/module/interfaces/tree-api.js +13 -2
- package/dist/module/interfaces/tree-api.test.js +76 -0
- package/package.json +1 -1
- package/src/components/default-container.test.tsx +30 -11
- package/src/components/row-container.tsx +6 -0
- package/src/dnd/drop-hook.ts +1 -11
- package/src/dnd/outer-drop-hook.ts +5 -0
- package/src/interfaces/tree-api.test.ts +90 -0
- package/src/interfaces/tree-api.ts +14 -2
package/README.md
CHANGED
|
@@ -800,6 +800,22 @@ function Node({ node, style }) {
|
|
|
800
800
|
|
|
801
801
|
Pass a partial `chars` object to override any of the default characters (e.g. for an ASCII-only style).
|
|
802
802
|
|
|
803
|
+
## Contributing
|
|
804
|
+
|
|
805
|
+
The package ships with Jest tests under `modules/react-arborist/src/**/*.test.ts?(x)`.
|
|
806
|
+
From the package directory you can run:
|
|
807
|
+
|
|
808
|
+
```bash
|
|
809
|
+
cd modules/react-arborist
|
|
810
|
+
yarn test
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Good starting points:
|
|
814
|
+
|
|
815
|
+
- `src/interfaces/tree-api.test.ts` for pure API behavior
|
|
816
|
+
- `src/components/provider.test.tsx` for rendered tree behavior
|
|
817
|
+
- `src/dnd/drag-hook.test.ts` for drag-and-drop behavior
|
|
818
|
+
|
|
803
819
|
## Author
|
|
804
820
|
|
|
805
821
|
[James Kerr](https://twitter.com/specialCaseDev) at [Brim Data](https://brimdata.io) for the [Zui desktop app](https://www.youtube.com/watch?v=I2y663n8d2A).
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
2
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
12
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
13
|
const react_1 = require("@testing-library/react");
|
|
@@ -13,33 +22,52 @@ const data = [
|
|
|
13
22
|
],
|
|
14
23
|
},
|
|
15
24
|
];
|
|
25
|
+
/* Selecting a row kicks off tree.scrollTo(), whose promise resolves on a
|
|
26
|
+
microtask after fireEvent's synchronous act() scope has exited — the
|
|
27
|
+
resulting List scrollToItem() update would otherwise warn about not being
|
|
28
|
+
wrapped in act(). Awaiting an async act flushes that trailing update. */
|
|
29
|
+
function click(el, init) {
|
|
30
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
31
|
+
yield (0, react_1.act)(() => __awaiter(this, void 0, void 0, function* () {
|
|
32
|
+
react_1.fireEvent.click(el, init);
|
|
33
|
+
}));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
16
36
|
/* #303: multi-select should respond to Ctrl+Click (Windows) as well as
|
|
17
37
|
Cmd/Meta+Click (macOS). */
|
|
18
|
-
test("Ctrl+Click adds a row to the selection (#303)", () => {
|
|
38
|
+
test("Ctrl+Click adds a row to the selection (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
19
39
|
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
|
|
20
40
|
const [, a, b] = react_1.screen.getAllByRole("treeitem");
|
|
21
|
-
|
|
41
|
+
yield click(a);
|
|
22
42
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
23
|
-
|
|
43
|
+
yield click(b, { ctrlKey: true });
|
|
24
44
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
25
45
|
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
26
|
-
});
|
|
27
|
-
test("Ctrl+Click toggles an already-selected row off (#303)", () => {
|
|
46
|
+
}));
|
|
47
|
+
test("Ctrl+Click toggles an already-selected row off (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
28
48
|
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
|
|
29
49
|
const [, a, b] = react_1.screen.getAllByRole("treeitem");
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
50
|
+
yield click(a);
|
|
51
|
+
yield click(b, { ctrlKey: true });
|
|
52
|
+
yield click(b, { ctrlKey: true });
|
|
33
53
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
34
54
|
expect(b.getAttribute("aria-selected")).toBe("false");
|
|
35
|
-
});
|
|
36
|
-
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => {
|
|
55
|
+
}));
|
|
56
|
+
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
37
57
|
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true, disableMultiSelection: true }));
|
|
38
58
|
const [, a, b] = react_1.screen.getAllByRole("treeitem");
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
yield click(a);
|
|
60
|
+
yield click(b, { ctrlKey: true });
|
|
41
61
|
expect(a.getAttribute("aria-selected")).toBe("false");
|
|
42
62
|
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
63
|
+
}));
|
|
64
|
+
/* #10: a row's background/selection highlight must span the full scrollable
|
|
65
|
+
width, not stop at the viewport edge, when content overflows horizontally. */
|
|
66
|
+
test("rows get min-width: max-content so the highlight spans overflow (#10)", () => {
|
|
67
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
|
|
68
|
+
for (const row of react_1.screen.getAllByRole("treeitem")) {
|
|
69
|
+
expect(row.style.minWidth).toBe("max-content");
|
|
70
|
+
}
|
|
43
71
|
});
|
|
44
72
|
/* #325: forward an accessible name and multiselectable state onto the
|
|
45
73
|
role="tree" element. */
|
|
@@ -61,7 +61,13 @@ exports.RowContainer = react_1.default.memo(function RowContainer({ index, style
|
|
|
61
61
|
const nodeStyle = (0, react_1.useMemo)(() => ({ paddingLeft: indent }), [indent]);
|
|
62
62
|
const rowStyle = (0, react_1.useMemo)(() => {
|
|
63
63
|
var _a, _b;
|
|
64
|
-
return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0)
|
|
64
|
+
return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0),
|
|
65
|
+
// react-window gives the row width: 100% of the viewport. When a deeply
|
|
66
|
+
// nested (or long) node overflows horizontally, that clips the row's
|
|
67
|
+
// background/selection highlight at the viewport edge. min-width:
|
|
68
|
+
// max-content lets the row grow with its content so the highlight spans
|
|
69
|
+
// the full scrollable width (#10).
|
|
70
|
+
minWidth: "max-content" }));
|
|
65
71
|
}, [style, tree.props.padding, tree.props.paddingTop]);
|
|
66
72
|
const rowAttrs = {
|
|
67
73
|
role: "treeitem",
|
|
@@ -5,8 +5,6 @@ const react_dnd_1 = require("react-dnd");
|
|
|
5
5
|
const context_1 = require("../context");
|
|
6
6
|
const compute_drop_1 = require("./compute-drop");
|
|
7
7
|
const dnd_slice_1 = require("../state/dnd-slice");
|
|
8
|
-
const utils_1 = require("../utils");
|
|
9
|
-
const create_root_1 = require("../data/create-root");
|
|
10
8
|
function useDropHook(el, node) {
|
|
11
9
|
const tree = (0, context_1.useTreeApi)();
|
|
12
10
|
const [_, dropRef] = (0, react_dnd_1.useDrop)(() => ({
|
|
@@ -37,15 +35,7 @@ function useDropHook(el, node) {
|
|
|
37
35
|
drop: (_, m) => {
|
|
38
36
|
if (!m.canDrop())
|
|
39
37
|
return null;
|
|
40
|
-
|
|
41
|
-
(0, utils_1.safeRun)(tree.props.onMove, {
|
|
42
|
-
dragIds,
|
|
43
|
-
parentId: parentId === create_root_1.ROOT_ID ? null : parentId,
|
|
44
|
-
index: index === null ? 0 : index, // When it's null it was dropped over a folder
|
|
45
|
-
dragNodes: tree.dragNodes,
|
|
46
|
-
parentNode: tree.get(parentId),
|
|
47
|
-
});
|
|
48
|
-
tree.open(parentId);
|
|
38
|
+
tree.drop();
|
|
49
39
|
},
|
|
50
40
|
}), [node, el.current, tree.props]);
|
|
51
41
|
return dropRef;
|
|
@@ -205,6 +205,7 @@ export declare class TreeApi<T> {
|
|
|
205
205
|
get dragDestinationParent(): NodeApi<T> | null;
|
|
206
206
|
get dragDestinationIndex(): number | null;
|
|
207
207
|
canDrop(): boolean;
|
|
208
|
+
drop(): void;
|
|
208
209
|
hideCursor(): void;
|
|
209
210
|
showCursor(cursor: Cursor): void;
|
|
210
211
|
open(identity: Identity, redraw?: boolean): void;
|
|
@@ -463,12 +463,13 @@ class TreeApi {
|
|
|
463
463
|
safeRun(this.props.onSelect, this.selectedNodes);
|
|
464
464
|
}
|
|
465
465
|
deselectAll() {
|
|
466
|
+
// setSelection fires onSelect; don't fire it again here (see #332).
|
|
466
467
|
this.setSelection({ ids: [], anchor: null, mostRecent: null });
|
|
467
|
-
safeRun(this.props.onSelect, this.selectedNodes);
|
|
468
468
|
}
|
|
469
469
|
selectAll() {
|
|
470
470
|
var _a, _b, _c;
|
|
471
471
|
const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
|
|
472
|
+
// setSelection fires onSelect; don't fire it again here (see #332).
|
|
472
473
|
this.setSelection({
|
|
473
474
|
ids: allSelectableNodes,
|
|
474
475
|
anchor: (_a = allSelectableNodes[0]) !== null && _a !== void 0 ? _a : null,
|
|
@@ -477,7 +478,6 @@ class TreeApi {
|
|
|
477
478
|
this.dispatch((0, focus_slice_1.focus)((_c = this.lastNode) === null || _c === void 0 ? void 0 : _c.id));
|
|
478
479
|
if (this.focusedNode)
|
|
479
480
|
safeRun(this.props.onFocus, this.focusedNode);
|
|
480
|
-
safeRun(this.props.onSelect, this.selectedNodes);
|
|
481
481
|
}
|
|
482
482
|
filterSelectableNodes(nodes) {
|
|
483
483
|
return nodes
|
|
@@ -551,6 +551,17 @@ class TreeApi {
|
|
|
551
551
|
return true;
|
|
552
552
|
}
|
|
553
553
|
}
|
|
554
|
+
drop() {
|
|
555
|
+
const { parentId, index, dragIds } = this.state.dnd;
|
|
556
|
+
safeRun(this.props.onMove, {
|
|
557
|
+
dragIds,
|
|
558
|
+
parentId: parentId === create_root_1.ROOT_ID ? null : parentId,
|
|
559
|
+
index: index === null ? 0 : index, // When it's null it was dropped over a folder
|
|
560
|
+
dragNodes: this.dragNodes,
|
|
561
|
+
parentNode: this.get(parentId),
|
|
562
|
+
});
|
|
563
|
+
this.open(parentId);
|
|
564
|
+
}
|
|
554
565
|
hideCursor() {
|
|
555
566
|
this.dispatch(dnd_slice_1.actions.cursor({ type: "none" }));
|
|
556
567
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const redux_1 = require("redux");
|
|
4
4
|
const root_reducer_1 = require("../state/root-reducer");
|
|
5
|
+
const dnd_slice_1 = require("../state/dnd-slice");
|
|
5
6
|
const tree_api_1 = require("./tree-api");
|
|
6
7
|
function setupApi(props) {
|
|
7
8
|
const store = (0, redux_1.createStore)(root_reducer_1.rootReducer);
|
|
@@ -13,6 +14,30 @@ test("tree.canDrop()", () => {
|
|
|
13
14
|
expect(setupApi({ disableDrop: false }).canDrop()).toBe(true);
|
|
14
15
|
});
|
|
15
16
|
const rowData = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
17
|
+
describe("tree.drop() fires onMove (#313)", () => {
|
|
18
|
+
test("reports the hovered parent and index, mapping the root id to null", () => {
|
|
19
|
+
const onMove = jest.fn();
|
|
20
|
+
const api = setupApi({ data: rowData, onMove });
|
|
21
|
+
// The bottom drop zone hovers the root with an index past the end, just like
|
|
22
|
+
// computeDrop() reports it. tree.drop() should map the root id back to null.
|
|
23
|
+
api.dispatch(dnd_slice_1.actions.dragStart("a", ["a"]));
|
|
24
|
+
api.dispatch(dnd_slice_1.actions.hovering(api.root.id, 3));
|
|
25
|
+
api.drop();
|
|
26
|
+
expect(onMove).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ dragIds: ["a"], parentId: null, index: 3 }));
|
|
28
|
+
});
|
|
29
|
+
test("coerces a null index (dropped onto a folder) to 0", () => {
|
|
30
|
+
const onMove = jest.fn();
|
|
31
|
+
const folderData = [{ id: "folder", children: [{ id: "child" }] }];
|
|
32
|
+
const api = setupApi({ data: folderData, onMove });
|
|
33
|
+
// Dropping onto a folder (rather than between rows) reports the folder as the
|
|
34
|
+
// parent with a null index, which tree.drop() should coerce to 0.
|
|
35
|
+
api.dispatch(dnd_slice_1.actions.dragStart("child", ["child"]));
|
|
36
|
+
api.dispatch(dnd_slice_1.actions.hovering("folder", null));
|
|
37
|
+
api.drop();
|
|
38
|
+
expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ parentId: "folder", index: 0 }));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
16
41
|
test("rowHeight defaults to 24", () => {
|
|
17
42
|
const api = setupApi({});
|
|
18
43
|
expect(api.rowHeight).toBe(24);
|
|
@@ -43,3 +68,54 @@ test("variable rowHeight function", () => {
|
|
|
43
68
|
// Out-of-range index falls back to the default height, never an invalid 0.
|
|
44
69
|
expect(api.rowHeightAt(99)).toBe(24);
|
|
45
70
|
});
|
|
71
|
+
describe("onSelect fires exactly once per selection method (#332)", () => {
|
|
72
|
+
function setupWithSpy() {
|
|
73
|
+
const onSelect = jest.fn();
|
|
74
|
+
const api = setupApi({ data: rowData, onSelect });
|
|
75
|
+
return { api, onSelect };
|
|
76
|
+
}
|
|
77
|
+
test("setSelection", () => {
|
|
78
|
+
const { api, onSelect } = setupWithSpy();
|
|
79
|
+
api.setSelection({ ids: ["a"], anchor: "a", mostRecent: "a" });
|
|
80
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
test("select", () => {
|
|
83
|
+
const { api, onSelect } = setupWithSpy();
|
|
84
|
+
api.select("a");
|
|
85
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
test("selectMulti", () => {
|
|
88
|
+
const { api, onSelect } = setupWithSpy();
|
|
89
|
+
api.selectMulti("a");
|
|
90
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
test("selectContiguous", () => {
|
|
93
|
+
const { api, onSelect } = setupWithSpy();
|
|
94
|
+
api.select("a");
|
|
95
|
+
onSelect.mockClear();
|
|
96
|
+
api.selectContiguous("c");
|
|
97
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
test("selectAll", () => {
|
|
100
|
+
const { api, onSelect } = setupWithSpy();
|
|
101
|
+
api.selectAll();
|
|
102
|
+
expect(api.selectedIds.size).toBe(3);
|
|
103
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
test("deselectAll", () => {
|
|
106
|
+
const { api, onSelect } = setupWithSpy();
|
|
107
|
+
api.selectAll();
|
|
108
|
+
onSelect.mockClear();
|
|
109
|
+
api.deselectAll();
|
|
110
|
+
expect(api.selectedIds.size).toBe(0);
|
|
111
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
test("deselect", () => {
|
|
114
|
+
const { api, onSelect } = setupWithSpy();
|
|
115
|
+
api.selectMulti("a");
|
|
116
|
+
api.selectMulti("b");
|
|
117
|
+
onSelect.mockClear();
|
|
118
|
+
api.deselect("a");
|
|
119
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
1
10
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
11
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
3
12
|
import { Tree } from "./tree";
|
|
4
13
|
const data = [
|
|
5
14
|
{
|
|
@@ -11,33 +20,52 @@ const data = [
|
|
|
11
20
|
],
|
|
12
21
|
},
|
|
13
22
|
];
|
|
23
|
+
/* Selecting a row kicks off tree.scrollTo(), whose promise resolves on a
|
|
24
|
+
microtask after fireEvent's synchronous act() scope has exited — the
|
|
25
|
+
resulting List scrollToItem() update would otherwise warn about not being
|
|
26
|
+
wrapped in act(). Awaiting an async act flushes that trailing update. */
|
|
27
|
+
function click(el, init) {
|
|
28
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
29
|
+
yield act(() => __awaiter(this, void 0, void 0, function* () {
|
|
30
|
+
fireEvent.click(el, init);
|
|
31
|
+
}));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
14
34
|
/* #303: multi-select should respond to Ctrl+Click (Windows) as well as
|
|
15
35
|
Cmd/Meta+Click (macOS). */
|
|
16
|
-
test("Ctrl+Click adds a row to the selection (#303)", () => {
|
|
36
|
+
test("Ctrl+Click adds a row to the selection (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
17
37
|
render(_jsx(Tree, { data: data, openByDefault: true }));
|
|
18
38
|
const [, a, b] = screen.getAllByRole("treeitem");
|
|
19
|
-
|
|
39
|
+
yield click(a);
|
|
20
40
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
21
|
-
|
|
41
|
+
yield click(b, { ctrlKey: true });
|
|
22
42
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
23
43
|
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
24
|
-
});
|
|
25
|
-
test("Ctrl+Click toggles an already-selected row off (#303)", () => {
|
|
44
|
+
}));
|
|
45
|
+
test("Ctrl+Click toggles an already-selected row off (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
26
46
|
render(_jsx(Tree, { data: data, openByDefault: true }));
|
|
27
47
|
const [, a, b] = screen.getAllByRole("treeitem");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
yield click(a);
|
|
49
|
+
yield click(b, { ctrlKey: true });
|
|
50
|
+
yield click(b, { ctrlKey: true });
|
|
31
51
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
32
52
|
expect(b.getAttribute("aria-selected")).toBe("false");
|
|
33
|
-
});
|
|
34
|
-
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => {
|
|
53
|
+
}));
|
|
54
|
+
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
35
55
|
render(_jsx(Tree, { data: data, openByDefault: true, disableMultiSelection: true }));
|
|
36
56
|
const [, a, b] = screen.getAllByRole("treeitem");
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
yield click(a);
|
|
58
|
+
yield click(b, { ctrlKey: true });
|
|
39
59
|
expect(a.getAttribute("aria-selected")).toBe("false");
|
|
40
60
|
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
61
|
+
}));
|
|
62
|
+
/* #10: a row's background/selection highlight must span the full scrollable
|
|
63
|
+
width, not stop at the viewport edge, when content overflows horizontally. */
|
|
64
|
+
test("rows get min-width: max-content so the highlight spans overflow (#10)", () => {
|
|
65
|
+
render(_jsx(Tree, { data: data, openByDefault: true }));
|
|
66
|
+
for (const row of screen.getAllByRole("treeitem")) {
|
|
67
|
+
expect(row.style.minWidth).toBe("max-content");
|
|
68
|
+
}
|
|
41
69
|
});
|
|
42
70
|
/* #325: forward an accessible name and multiselectable state onto the
|
|
43
71
|
role="tree" element. */
|
|
@@ -35,7 +35,13 @@ export const RowContainer = React.memo(function RowContainer({ index, style }) {
|
|
|
35
35
|
const nodeStyle = useMemo(() => ({ paddingLeft: indent }), [indent]);
|
|
36
36
|
const rowStyle = useMemo(() => {
|
|
37
37
|
var _a, _b;
|
|
38
|
-
return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0)
|
|
38
|
+
return (Object.assign(Object.assign({}, style), { top: parseFloat(style.top) + ((_b = (_a = tree.props.padding) !== null && _a !== void 0 ? _a : tree.props.paddingTop) !== null && _b !== void 0 ? _b : 0),
|
|
39
|
+
// react-window gives the row width: 100% of the viewport. When a deeply
|
|
40
|
+
// nested (or long) node overflows horizontally, that clips the row's
|
|
41
|
+
// background/selection highlight at the viewport edge. min-width:
|
|
42
|
+
// max-content lets the row grow with its content so the highlight spans
|
|
43
|
+
// the full scrollable width (#10).
|
|
44
|
+
minWidth: "max-content" }));
|
|
39
45
|
}, [style, tree.props.padding, tree.props.paddingTop]);
|
|
40
46
|
const rowAttrs = {
|
|
41
47
|
role: "treeitem",
|
|
@@ -2,8 +2,6 @@ import { useDrop } from "react-dnd";
|
|
|
2
2
|
import { useTreeApi } from "../context";
|
|
3
3
|
import { computeDrop } from "./compute-drop";
|
|
4
4
|
import { actions as dnd } from "../state/dnd-slice";
|
|
5
|
-
import { safeRun } from "../utils";
|
|
6
|
-
import { ROOT_ID } from "../data/create-root";
|
|
7
5
|
export function useDropHook(el, node) {
|
|
8
6
|
const tree = useTreeApi();
|
|
9
7
|
const [_, dropRef] = useDrop(() => ({
|
|
@@ -34,15 +32,7 @@ export function useDropHook(el, node) {
|
|
|
34
32
|
drop: (_, m) => {
|
|
35
33
|
if (!m.canDrop())
|
|
36
34
|
return null;
|
|
37
|
-
|
|
38
|
-
safeRun(tree.props.onMove, {
|
|
39
|
-
dragIds,
|
|
40
|
-
parentId: parentId === ROOT_ID ? null : parentId,
|
|
41
|
-
index: index === null ? 0 : index, // When it's null it was dropped over a folder
|
|
42
|
-
dragNodes: tree.dragNodes,
|
|
43
|
-
parentNode: tree.get(parentId),
|
|
44
|
-
});
|
|
45
|
-
tree.open(parentId);
|
|
35
|
+
tree.drop();
|
|
46
36
|
},
|
|
47
37
|
}), [node, el.current, tree.props]);
|
|
48
38
|
return dropRef;
|
|
@@ -205,6 +205,7 @@ export declare class TreeApi<T> {
|
|
|
205
205
|
get dragDestinationParent(): NodeApi<T> | null;
|
|
206
206
|
get dragDestinationIndex(): number | null;
|
|
207
207
|
canDrop(): boolean;
|
|
208
|
+
drop(): void;
|
|
208
209
|
hideCursor(): void;
|
|
209
210
|
showCursor(cursor: Cursor): void;
|
|
210
211
|
open(identity: Identity, redraw?: boolean): void;
|
|
@@ -437,12 +437,13 @@ export class TreeApi {
|
|
|
437
437
|
safeRun(this.props.onSelect, this.selectedNodes);
|
|
438
438
|
}
|
|
439
439
|
deselectAll() {
|
|
440
|
+
// setSelection fires onSelect; don't fire it again here (see #332).
|
|
440
441
|
this.setSelection({ ids: [], anchor: null, mostRecent: null });
|
|
441
|
-
safeRun(this.props.onSelect, this.selectedNodes);
|
|
442
442
|
}
|
|
443
443
|
selectAll() {
|
|
444
444
|
var _a, _b, _c;
|
|
445
445
|
const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
|
|
446
|
+
// setSelection fires onSelect; don't fire it again here (see #332).
|
|
446
447
|
this.setSelection({
|
|
447
448
|
ids: allSelectableNodes,
|
|
448
449
|
anchor: (_a = allSelectableNodes[0]) !== null && _a !== void 0 ? _a : null,
|
|
@@ -451,7 +452,6 @@ export class TreeApi {
|
|
|
451
452
|
this.dispatch(focus((_c = this.lastNode) === null || _c === void 0 ? void 0 : _c.id));
|
|
452
453
|
if (this.focusedNode)
|
|
453
454
|
safeRun(this.props.onFocus, this.focusedNode);
|
|
454
|
-
safeRun(this.props.onSelect, this.selectedNodes);
|
|
455
455
|
}
|
|
456
456
|
filterSelectableNodes(nodes) {
|
|
457
457
|
return nodes
|
|
@@ -525,6 +525,17 @@ export class TreeApi {
|
|
|
525
525
|
return true;
|
|
526
526
|
}
|
|
527
527
|
}
|
|
528
|
+
drop() {
|
|
529
|
+
const { parentId, index, dragIds } = this.state.dnd;
|
|
530
|
+
safeRun(this.props.onMove, {
|
|
531
|
+
dragIds,
|
|
532
|
+
parentId: parentId === ROOT_ID ? null : parentId,
|
|
533
|
+
index: index === null ? 0 : index, // When it's null it was dropped over a folder
|
|
534
|
+
dragNodes: this.dragNodes,
|
|
535
|
+
parentNode: this.get(parentId),
|
|
536
|
+
});
|
|
537
|
+
this.open(parentId);
|
|
538
|
+
}
|
|
528
539
|
hideCursor() {
|
|
529
540
|
this.dispatch(dnd.cursor({ type: "none" }));
|
|
530
541
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createStore } from "redux";
|
|
2
2
|
import { rootReducer } from "../state/root-reducer";
|
|
3
|
+
import { actions as dnd } from "../state/dnd-slice";
|
|
3
4
|
import { TreeApi } from "./tree-api";
|
|
4
5
|
function setupApi(props) {
|
|
5
6
|
const store = createStore(rootReducer);
|
|
@@ -11,6 +12,30 @@ test("tree.canDrop()", () => {
|
|
|
11
12
|
expect(setupApi({ disableDrop: false }).canDrop()).toBe(true);
|
|
12
13
|
});
|
|
13
14
|
const rowData = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
15
|
+
describe("tree.drop() fires onMove (#313)", () => {
|
|
16
|
+
test("reports the hovered parent and index, mapping the root id to null", () => {
|
|
17
|
+
const onMove = jest.fn();
|
|
18
|
+
const api = setupApi({ data: rowData, onMove });
|
|
19
|
+
// The bottom drop zone hovers the root with an index past the end, just like
|
|
20
|
+
// computeDrop() reports it. tree.drop() should map the root id back to null.
|
|
21
|
+
api.dispatch(dnd.dragStart("a", ["a"]));
|
|
22
|
+
api.dispatch(dnd.hovering(api.root.id, 3));
|
|
23
|
+
api.drop();
|
|
24
|
+
expect(onMove).toHaveBeenCalledTimes(1);
|
|
25
|
+
expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ dragIds: ["a"], parentId: null, index: 3 }));
|
|
26
|
+
});
|
|
27
|
+
test("coerces a null index (dropped onto a folder) to 0", () => {
|
|
28
|
+
const onMove = jest.fn();
|
|
29
|
+
const folderData = [{ id: "folder", children: [{ id: "child" }] }];
|
|
30
|
+
const api = setupApi({ data: folderData, onMove });
|
|
31
|
+
// Dropping onto a folder (rather than between rows) reports the folder as the
|
|
32
|
+
// parent with a null index, which tree.drop() should coerce to 0.
|
|
33
|
+
api.dispatch(dnd.dragStart("child", ["child"]));
|
|
34
|
+
api.dispatch(dnd.hovering("folder", null));
|
|
35
|
+
api.drop();
|
|
36
|
+
expect(onMove).toHaveBeenCalledWith(expect.objectContaining({ parentId: "folder", index: 0 }));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
14
39
|
test("rowHeight defaults to 24", () => {
|
|
15
40
|
const api = setupApi({});
|
|
16
41
|
expect(api.rowHeight).toBe(24);
|
|
@@ -41,3 +66,54 @@ test("variable rowHeight function", () => {
|
|
|
41
66
|
// Out-of-range index falls back to the default height, never an invalid 0.
|
|
42
67
|
expect(api.rowHeightAt(99)).toBe(24);
|
|
43
68
|
});
|
|
69
|
+
describe("onSelect fires exactly once per selection method (#332)", () => {
|
|
70
|
+
function setupWithSpy() {
|
|
71
|
+
const onSelect = jest.fn();
|
|
72
|
+
const api = setupApi({ data: rowData, onSelect });
|
|
73
|
+
return { api, onSelect };
|
|
74
|
+
}
|
|
75
|
+
test("setSelection", () => {
|
|
76
|
+
const { api, onSelect } = setupWithSpy();
|
|
77
|
+
api.setSelection({ ids: ["a"], anchor: "a", mostRecent: "a" });
|
|
78
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
test("select", () => {
|
|
81
|
+
const { api, onSelect } = setupWithSpy();
|
|
82
|
+
api.select("a");
|
|
83
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
test("selectMulti", () => {
|
|
86
|
+
const { api, onSelect } = setupWithSpy();
|
|
87
|
+
api.selectMulti("a");
|
|
88
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
test("selectContiguous", () => {
|
|
91
|
+
const { api, onSelect } = setupWithSpy();
|
|
92
|
+
api.select("a");
|
|
93
|
+
onSelect.mockClear();
|
|
94
|
+
api.selectContiguous("c");
|
|
95
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
test("selectAll", () => {
|
|
98
|
+
const { api, onSelect } = setupWithSpy();
|
|
99
|
+
api.selectAll();
|
|
100
|
+
expect(api.selectedIds.size).toBe(3);
|
|
101
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
test("deselectAll", () => {
|
|
104
|
+
const { api, onSelect } = setupWithSpy();
|
|
105
|
+
api.selectAll();
|
|
106
|
+
onSelect.mockClear();
|
|
107
|
+
api.deselectAll();
|
|
108
|
+
expect(api.selectedIds.size).toBe(0);
|
|
109
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
test("deselect", () => {
|
|
112
|
+
const { api, onSelect } = setupWithSpy();
|
|
113
|
+
api.selectMulti("a");
|
|
114
|
+
api.selectMulti("b");
|
|
115
|
+
onSelect.mockClear();
|
|
116
|
+
api.deselect("a");
|
|
117
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
1
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
2
2
|
import { Tree } from "./tree";
|
|
3
3
|
|
|
4
4
|
type Datum = { id: string; name: string; children?: Datum[] };
|
|
@@ -14,43 +14,62 @@ const data: Datum[] = [
|
|
|
14
14
|
},
|
|
15
15
|
];
|
|
16
16
|
|
|
17
|
+
/* Selecting a row kicks off tree.scrollTo(), whose promise resolves on a
|
|
18
|
+
microtask after fireEvent's synchronous act() scope has exited — the
|
|
19
|
+
resulting List scrollToItem() update would otherwise warn about not being
|
|
20
|
+
wrapped in act(). Awaiting an async act flushes that trailing update. */
|
|
21
|
+
async function click(el: Element, init?: MouseEventInit) {
|
|
22
|
+
await act(async () => {
|
|
23
|
+
fireEvent.click(el, init);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
/* #303: multi-select should respond to Ctrl+Click (Windows) as well as
|
|
18
28
|
Cmd/Meta+Click (macOS). */
|
|
19
|
-
test("Ctrl+Click adds a row to the selection (#303)", () => {
|
|
29
|
+
test("Ctrl+Click adds a row to the selection (#303)", async () => {
|
|
20
30
|
render(<Tree<Datum> data={data} openByDefault />);
|
|
21
31
|
const [, a, b] = screen.getAllByRole("treeitem");
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
await click(a);
|
|
24
34
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
25
35
|
|
|
26
|
-
|
|
36
|
+
await click(b, { ctrlKey: true });
|
|
27
37
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
28
38
|
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
29
39
|
});
|
|
30
40
|
|
|
31
|
-
test("Ctrl+Click toggles an already-selected row off (#303)", () => {
|
|
41
|
+
test("Ctrl+Click toggles an already-selected row off (#303)", async () => {
|
|
32
42
|
render(<Tree<Datum> data={data} openByDefault />);
|
|
33
43
|
const [, a, b] = screen.getAllByRole("treeitem");
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
await click(a);
|
|
46
|
+
await click(b, { ctrlKey: true });
|
|
47
|
+
await click(b, { ctrlKey: true });
|
|
38
48
|
|
|
39
49
|
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
40
50
|
expect(b.getAttribute("aria-selected")).toBe("false");
|
|
41
51
|
});
|
|
42
52
|
|
|
43
|
-
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => {
|
|
53
|
+
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", async () => {
|
|
44
54
|
render(<Tree<Datum> data={data} openByDefault disableMultiSelection />);
|
|
45
55
|
const [, a, b] = screen.getAllByRole("treeitem");
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
await click(a);
|
|
58
|
+
await click(b, { ctrlKey: true });
|
|
49
59
|
|
|
50
60
|
expect(a.getAttribute("aria-selected")).toBe("false");
|
|
51
61
|
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
52
62
|
});
|
|
53
63
|
|
|
64
|
+
/* #10: a row's background/selection highlight must span the full scrollable
|
|
65
|
+
width, not stop at the viewport edge, when content overflows horizontally. */
|
|
66
|
+
test("rows get min-width: max-content so the highlight spans overflow (#10)", () => {
|
|
67
|
+
render(<Tree<Datum> data={data} openByDefault />);
|
|
68
|
+
for (const row of screen.getAllByRole("treeitem")) {
|
|
69
|
+
expect((row as HTMLElement).style.minWidth).toBe("max-content");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
54
73
|
/* #325: forward an accessible name and multiselectable state onto the
|
|
55
74
|
role="tree" element. */
|
|
56
75
|
test("forwards aria-label to the role=tree element (#325)", () => {
|
|
@@ -48,6 +48,12 @@ export const RowContainer = React.memo(function RowContainer<T>({ index, style }
|
|
|
48
48
|
() => ({
|
|
49
49
|
...style,
|
|
50
50
|
top: parseFloat(style.top as string) + (tree.props.padding ?? tree.props.paddingTop ?? 0),
|
|
51
|
+
// react-window gives the row width: 100% of the viewport. When a deeply
|
|
52
|
+
// nested (or long) node overflows horizontally, that clips the row's
|
|
53
|
+
// background/selection highlight at the viewport edge. min-width:
|
|
54
|
+
// max-content lets the row grow with its content so the highlight spans
|
|
55
|
+
// the full scrollable width (#10).
|
|
56
|
+
minWidth: "max-content",
|
|
51
57
|
}),
|
|
52
58
|
[style, tree.props.padding, tree.props.paddingTop],
|
|
53
59
|
);
|
package/src/dnd/drop-hook.ts
CHANGED
|
@@ -5,8 +5,6 @@ import { NodeApi } from "../interfaces/node-api";
|
|
|
5
5
|
import { DragItem } from "../types/dnd";
|
|
6
6
|
import { computeDrop } from "./compute-drop";
|
|
7
7
|
import { actions as dnd } from "../state/dnd-slice";
|
|
8
|
-
import { safeRun } from "../utils";
|
|
9
|
-
import { ROOT_ID } from "../data/create-root";
|
|
10
8
|
|
|
11
9
|
export type DropResult = {
|
|
12
10
|
parentId: string | null;
|
|
@@ -43,15 +41,7 @@ export function useDropHook(
|
|
|
43
41
|
},
|
|
44
42
|
drop: (_, m) => {
|
|
45
43
|
if (!m.canDrop()) return null;
|
|
46
|
-
|
|
47
|
-
safeRun(tree.props.onMove, {
|
|
48
|
-
dragIds,
|
|
49
|
-
parentId: parentId === ROOT_ID ? null : parentId,
|
|
50
|
-
index: index === null ? 0 : index, // When it's null it was dropped over a folder
|
|
51
|
-
dragNodes: tree.dragNodes,
|
|
52
|
-
parentNode: tree.get(parentId),
|
|
53
|
-
});
|
|
54
|
-
tree.open(parentId);
|
|
44
|
+
tree.drop();
|
|
55
45
|
},
|
|
56
46
|
}),
|
|
57
47
|
[node, el.current, tree.props],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createStore } from "redux";
|
|
2
2
|
import { rootReducer } from "../state/root-reducer";
|
|
3
|
+
import { actions as dnd } from "../state/dnd-slice";
|
|
3
4
|
import { TreeProps } from "../types/tree-props";
|
|
4
5
|
import { TreeApi } from "./tree-api";
|
|
5
6
|
|
|
@@ -16,6 +17,36 @@ test("tree.canDrop()", () => {
|
|
|
16
17
|
|
|
17
18
|
const rowData = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
|
18
19
|
|
|
20
|
+
describe("tree.drop() fires onMove (#313)", () => {
|
|
21
|
+
test("reports the hovered parent and index, mapping the root id to null", () => {
|
|
22
|
+
const onMove = jest.fn();
|
|
23
|
+
const api = setupApi({ data: rowData, onMove });
|
|
24
|
+
// The bottom drop zone hovers the root with an index past the end, just like
|
|
25
|
+
// computeDrop() reports it. tree.drop() should map the root id back to null.
|
|
26
|
+
api.dispatch(dnd.dragStart("a", ["a"]));
|
|
27
|
+
api.dispatch(dnd.hovering(api.root.id, 3));
|
|
28
|
+
api.drop();
|
|
29
|
+
expect(onMove).toHaveBeenCalledTimes(1);
|
|
30
|
+
expect(onMove).toHaveBeenCalledWith(
|
|
31
|
+
expect.objectContaining({ dragIds: ["a"], parentId: null, index: 3 }),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("coerces a null index (dropped onto a folder) to 0", () => {
|
|
36
|
+
const onMove = jest.fn();
|
|
37
|
+
const folderData = [{ id: "folder", children: [{ id: "child" }] }];
|
|
38
|
+
const api = setupApi({ data: folderData, onMove });
|
|
39
|
+
// Dropping onto a folder (rather than between rows) reports the folder as the
|
|
40
|
+
// parent with a null index, which tree.drop() should coerce to 0.
|
|
41
|
+
api.dispatch(dnd.dragStart("child", ["child"]));
|
|
42
|
+
api.dispatch(dnd.hovering("folder", null));
|
|
43
|
+
api.drop();
|
|
44
|
+
expect(onMove).toHaveBeenCalledWith(
|
|
45
|
+
expect.objectContaining({ parentId: "folder", index: 0 }),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
19
50
|
test("rowHeight defaults to 24", () => {
|
|
20
51
|
const api = setupApi({});
|
|
21
52
|
expect(api.rowHeight).toBe(24);
|
|
@@ -48,3 +79,62 @@ test("variable rowHeight function", () => {
|
|
|
48
79
|
// Out-of-range index falls back to the default height, never an invalid 0.
|
|
49
80
|
expect(api.rowHeightAt(99)).toBe(24);
|
|
50
81
|
});
|
|
82
|
+
|
|
83
|
+
describe("onSelect fires exactly once per selection method (#332)", () => {
|
|
84
|
+
function setupWithSpy() {
|
|
85
|
+
const onSelect = jest.fn();
|
|
86
|
+
const api = setupApi({ data: rowData, onSelect });
|
|
87
|
+
return { api, onSelect };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("setSelection", () => {
|
|
91
|
+
const { api, onSelect } = setupWithSpy();
|
|
92
|
+
api.setSelection({ ids: ["a"], anchor: "a", mostRecent: "a" });
|
|
93
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("select", () => {
|
|
97
|
+
const { api, onSelect } = setupWithSpy();
|
|
98
|
+
api.select("a");
|
|
99
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("selectMulti", () => {
|
|
103
|
+
const { api, onSelect } = setupWithSpy();
|
|
104
|
+
api.selectMulti("a");
|
|
105
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("selectContiguous", () => {
|
|
109
|
+
const { api, onSelect } = setupWithSpy();
|
|
110
|
+
api.select("a");
|
|
111
|
+
onSelect.mockClear();
|
|
112
|
+
api.selectContiguous("c");
|
|
113
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("selectAll", () => {
|
|
117
|
+
const { api, onSelect } = setupWithSpy();
|
|
118
|
+
api.selectAll();
|
|
119
|
+
expect(api.selectedIds.size).toBe(3);
|
|
120
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("deselectAll", () => {
|
|
124
|
+
const { api, onSelect } = setupWithSpy();
|
|
125
|
+
api.selectAll();
|
|
126
|
+
onSelect.mockClear();
|
|
127
|
+
api.deselectAll();
|
|
128
|
+
expect(api.selectedIds.size).toBe(0);
|
|
129
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("deselect", () => {
|
|
133
|
+
const { api, onSelect } = setupWithSpy();
|
|
134
|
+
api.selectMulti("a");
|
|
135
|
+
api.selectMulti("b");
|
|
136
|
+
onSelect.mockClear();
|
|
137
|
+
api.deselect("a");
|
|
138
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -455,12 +455,13 @@ export class TreeApi<T> {
|
|
|
455
455
|
}
|
|
456
456
|
|
|
457
457
|
deselectAll() {
|
|
458
|
+
// setSelection fires onSelect; don't fire it again here (see #332).
|
|
458
459
|
this.setSelection({ ids: [], anchor: null, mostRecent: null });
|
|
459
|
-
safeRun(this.props.onSelect, this.selectedNodes);
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
selectAll() {
|
|
463
463
|
const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
|
|
464
|
+
// setSelection fires onSelect; don't fire it again here (see #332).
|
|
464
465
|
this.setSelection({
|
|
465
466
|
ids: allSelectableNodes,
|
|
466
467
|
anchor: allSelectableNodes[0] ?? null,
|
|
@@ -468,7 +469,6 @@ export class TreeApi<T> {
|
|
|
468
469
|
});
|
|
469
470
|
this.dispatch(focus(this.lastNode?.id));
|
|
470
471
|
if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
|
|
471
|
-
safeRun(this.props.onSelect, this.selectedNodes);
|
|
472
472
|
}
|
|
473
473
|
|
|
474
474
|
private filterSelectableNodes(nodes: (IdObj | string)[]) {
|
|
@@ -546,6 +546,18 @@ export class TreeApi<T> {
|
|
|
546
546
|
}
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
drop() {
|
|
550
|
+
const { parentId, index, dragIds } = this.state.dnd;
|
|
551
|
+
safeRun(this.props.onMove, {
|
|
552
|
+
dragIds,
|
|
553
|
+
parentId: parentId === ROOT_ID ? null : parentId,
|
|
554
|
+
index: index === null ? 0 : index, // When it's null it was dropped over a folder
|
|
555
|
+
dragNodes: this.dragNodes,
|
|
556
|
+
parentNode: this.get(parentId),
|
|
557
|
+
});
|
|
558
|
+
this.open(parentId);
|
|
559
|
+
}
|
|
560
|
+
|
|
549
561
|
hideCursor() {
|
|
550
562
|
this.dispatch(dnd.cursor({ type: "none" }));
|
|
551
563
|
}
|