react-arborist 3.8.0 → 3.10.0
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 +20 -4
- package/dist/main/components/default-container.js +4 -4
- package/dist/main/components/default-container.test.d.ts +1 -0
- package/dist/main/components/default-container.test.js +61 -0
- package/dist/main/dnd/drag-hook.d.ts +2 -0
- package/dist/main/dnd/drag-hook.js +12 -4
- package/dist/main/dnd/drag-hook.test.d.ts +1 -0
- package/dist/main/dnd/drag-hook.test.js +19 -0
- package/dist/main/interfaces/node-api.js +1 -1
- package/dist/main/types/dnd.d.ts +3 -1
- package/dist/main/types/tree-props.d.ts +3 -0
- package/dist/module/components/default-container.js +4 -4
- package/dist/module/components/default-container.test.d.ts +1 -0
- package/dist/module/components/default-container.test.js +59 -0
- package/dist/module/dnd/drag-hook.d.ts +2 -0
- package/dist/module/dnd/drag-hook.js +11 -4
- package/dist/module/dnd/drag-hook.test.d.ts +1 -0
- package/dist/module/dnd/drag-hook.test.js +17 -0
- package/dist/module/interfaces/node-api.js +1 -1
- package/dist/module/types/dnd.d.ts +3 -1
- package/dist/module/types/tree-props.d.ts +3 -0
- package/package.json +1 -1
- package/src/components/default-container.test.tsx +74 -0
- package/src/components/default-container.tsx +6 -3
- package/src/dnd/drag-hook.test.ts +22 -0
- package/src/dnd/drag-hook.ts +14 -6
- package/src/interfaces/node-api.ts +1 -1
- package/src/types/dnd.ts +6 -1
- package/src/types/tree-props.ts +13 -0
package/README.md
CHANGED
|
@@ -245,15 +245,17 @@ function App() {
|
|
|
245
245
|
|
|
246
246
|
### Dynamic sizing
|
|
247
247
|
|
|
248
|
-
You can add a ref to it with this package [
|
|
248
|
+
You can add a ref to it with this package [Pmndrs/react-use-measure](https://github.com/pmndrs/react-use-measure)
|
|
249
249
|
|
|
250
|
-
That hook will
|
|
250
|
+
That hook will measure the boundaries (for instance width, height, top, left) of a view you reference. Then you pass the width and the height to the Tree.
|
|
251
251
|
|
|
252
252
|
```js
|
|
253
|
-
|
|
253
|
+
import useMeasure from "react-use-measure";
|
|
254
|
+
|
|
255
|
+
const [ref, bounds] = useMeasure();
|
|
254
256
|
|
|
255
257
|
<div className="parent" ref={ref}>
|
|
256
|
-
<Tree height={height} width={width} />
|
|
258
|
+
<Tree height={bounds.height} width={bounds.width} />
|
|
257
259
|
</div>
|
|
258
260
|
```
|
|
259
261
|
|
|
@@ -351,9 +353,23 @@ interface TreeProps<T> {
|
|
|
351
353
|
{ backend: unknown }
|
|
352
354
|
>["backend"];
|
|
353
355
|
dndManager?: ReturnType<typeof useDragDropManager>;
|
|
356
|
+
dragType?: string | ((node: NodeApi<T>) => string);
|
|
354
357
|
}
|
|
355
358
|
```
|
|
356
359
|
|
|
360
|
+
### Dragging Nodes to External Drop Targets
|
|
361
|
+
|
|
362
|
+
The react-dnd drag item created for each row carries the dragged node's `data`, so a drop target rendered outside the tree can read it:
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
const [, drop] = useDrop(() => ({
|
|
366
|
+
accept: "NODE",
|
|
367
|
+
drop: (item) => console.log(item.data), // the dragged node's data
|
|
368
|
+
}));
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The tree and your external target must share one react-dnd backend. Wrap both in a single `DndProvider` and pass its manager to the tree via the `dndManager` prop. By default rows advertise the `"NODE"` item type; set the `dragType` prop (a fixed string, or a function of the node) to advertise a custom type instead. Note that the tree's own drop targets only accept `"NODE"`, so a row given a custom `dragType` is no longer reorderable within the tree.
|
|
372
|
+
|
|
357
373
|
## Row Component Props
|
|
358
374
|
|
|
359
375
|
The _\<RowRenderer\>_ is responsible for attaching the drop ref, the row style (top, height) and the aria-attributes. The default should work fine for most use cases, but it can be replaced by your own component if you need. See the _renderRow_ prop in the _\<Tree\>_ component.
|
|
@@ -18,7 +18,7 @@ let timeoutId = null;
|
|
|
18
18
|
function DefaultContainer() {
|
|
19
19
|
(0, context_1.useDataUpdates)();
|
|
20
20
|
const tree = (0, context_1.useTreeApi)();
|
|
21
|
-
return ((0, jsx_runtime_1.jsx)("div", { role: "tree", style: {
|
|
21
|
+
return ((0, jsx_runtime_1.jsx)("div", { role: "tree", "aria-label": tree.props["aria-label"], "aria-labelledby": tree.props["aria-labelledby"], "aria-multiselectable": !tree.props.disableMultiSelection || undefined, style: {
|
|
22
22
|
height: tree.height,
|
|
23
23
|
width: tree.width,
|
|
24
24
|
minHeight: 0,
|
|
@@ -144,16 +144,16 @@ function DefaultContainer() {
|
|
|
144
144
|
}
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
|
-
if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
|
|
147
|
+
if (e.key === "a" && (e.metaKey || e.ctrlKey) && !tree.props.disableMultiSelection) {
|
|
148
148
|
e.preventDefault();
|
|
149
149
|
tree.selectAll();
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
152
|
-
if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
|
|
152
|
+
if (e.key === "a" && !e.metaKey && !e.ctrlKey && tree.props.onCreate) {
|
|
153
153
|
tree.createLeaf();
|
|
154
154
|
return;
|
|
155
155
|
}
|
|
156
|
-
if (e.key === "A" && !e.metaKey) {
|
|
156
|
+
if (e.key === "A" && !e.metaKey && !e.ctrlKey) {
|
|
157
157
|
if (!tree.props.onCreate)
|
|
158
158
|
return;
|
|
159
159
|
tree.createInternal();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_1 = require("@testing-library/react");
|
|
5
|
+
const tree_1 = require("./tree");
|
|
6
|
+
const data = [
|
|
7
|
+
{
|
|
8
|
+
id: "1",
|
|
9
|
+
name: "root",
|
|
10
|
+
children: [
|
|
11
|
+
{ id: "2", name: "a" },
|
|
12
|
+
{ id: "3", name: "b", children: [{ id: "4", name: "c" }] },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
/* #303: multi-select should respond to Ctrl+Click (Windows) as well as
|
|
17
|
+
Cmd/Meta+Click (macOS). */
|
|
18
|
+
test("Ctrl+Click adds a row to the selection (#303)", () => {
|
|
19
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
|
|
20
|
+
const [, a, b] = react_1.screen.getAllByRole("treeitem");
|
|
21
|
+
react_1.fireEvent.click(a);
|
|
22
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
23
|
+
react_1.fireEvent.click(b, { ctrlKey: true });
|
|
24
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
25
|
+
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
26
|
+
});
|
|
27
|
+
test("Ctrl+Click toggles an already-selected row off (#303)", () => {
|
|
28
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true }));
|
|
29
|
+
const [, a, b] = react_1.screen.getAllByRole("treeitem");
|
|
30
|
+
react_1.fireEvent.click(a);
|
|
31
|
+
react_1.fireEvent.click(b, { ctrlKey: true });
|
|
32
|
+
react_1.fireEvent.click(b, { ctrlKey: true });
|
|
33
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
34
|
+
expect(b.getAttribute("aria-selected")).toBe("false");
|
|
35
|
+
});
|
|
36
|
+
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => {
|
|
37
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, openByDefault: true, disableMultiSelection: true }));
|
|
38
|
+
const [, a, b] = react_1.screen.getAllByRole("treeitem");
|
|
39
|
+
react_1.fireEvent.click(a);
|
|
40
|
+
react_1.fireEvent.click(b, { ctrlKey: true });
|
|
41
|
+
expect(a.getAttribute("aria-selected")).toBe("false");
|
|
42
|
+
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
43
|
+
});
|
|
44
|
+
/* #325: forward an accessible name and multiselectable state onto the
|
|
45
|
+
role="tree" element. */
|
|
46
|
+
test("forwards aria-label to the role=tree element (#325)", () => {
|
|
47
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, "aria-label": "File explorer" }));
|
|
48
|
+
expect(react_1.screen.getByRole("tree").getAttribute("aria-label")).toBe("File explorer");
|
|
49
|
+
});
|
|
50
|
+
test("forwards aria-labelledby to the role=tree element (#325)", () => {
|
|
51
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, "aria-labelledby": "heading-id" }));
|
|
52
|
+
expect(react_1.screen.getByRole("tree").getAttribute("aria-labelledby")).toBe("heading-id");
|
|
53
|
+
});
|
|
54
|
+
test("marks the tree aria-multiselectable by default (#325)", () => {
|
|
55
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data }));
|
|
56
|
+
expect(react_1.screen.getByRole("tree").getAttribute("aria-multiselectable")).toBe("true");
|
|
57
|
+
});
|
|
58
|
+
test("omits aria-multiselectable when multi-select is disabled (#325)", () => {
|
|
59
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(tree_1.Tree, { data: data, disableMultiSelection: true }));
|
|
60
|
+
expect(react_1.screen.getByRole("tree").hasAttribute("aria-multiselectable")).toBe(false);
|
|
61
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { ConnectDragSource } from "react-dnd";
|
|
2
2
|
import { NodeApi } from "../interfaces/node-api";
|
|
3
|
+
import { TreeProps } from "../types/tree-props";
|
|
4
|
+
export declare function dragTypeForNode<T>(dragType: TreeProps<T>["dragType"], node: NodeApi<T>): string;
|
|
3
5
|
export declare function useDragHook<T>(node: NodeApi<T>): ConnectDragSource;
|
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dragTypeForNode = dragTypeForNode;
|
|
3
4
|
exports.useDragHook = useDragHook;
|
|
4
5
|
const react_1 = require("react");
|
|
5
6
|
const react_dnd_1 = require("react-dnd");
|
|
6
7
|
const react_dnd_html5_backend_1 = require("react-dnd-html5-backend");
|
|
7
8
|
const context_1 = require("../context");
|
|
8
9
|
const dnd_slice_1 = require("../state/dnd-slice");
|
|
10
|
+
/* The react-dnd item type a row's drag source broadcasts. The dragType prop
|
|
11
|
+
can be a fixed string or a per-node function; it defaults to "NODE". */
|
|
12
|
+
function dragTypeForNode(dragType, node) {
|
|
13
|
+
if (typeof dragType === "function")
|
|
14
|
+
return dragType(node);
|
|
15
|
+
return dragType !== null && dragType !== void 0 ? dragType : "NODE";
|
|
16
|
+
}
|
|
9
17
|
function useDragHook(node) {
|
|
10
18
|
const tree = (0, context_1.useTreeApi)();
|
|
11
19
|
const ids = tree.selectedIds;
|
|
12
20
|
const [_, ref, preview] = (0, react_dnd_1.useDrag)(() => ({
|
|
13
21
|
canDrag: () => node.isDraggable,
|
|
14
|
-
type:
|
|
22
|
+
type: dragTypeForNode(tree.props.dragType, node),
|
|
15
23
|
item: () => {
|
|
16
|
-
// This is fired once at the
|
|
24
|
+
// This is fired once at the beginning of a drag operation
|
|
17
25
|
const dragIds = tree.isSelected(node.id) ? Array.from(ids) : [node.id];
|
|
18
26
|
tree.dispatch(dnd_slice_1.actions.dragStart(node.id, dragIds));
|
|
19
|
-
return { id: node.id, dragIds };
|
|
27
|
+
return { id: node.id, dragIds, data: node.data };
|
|
20
28
|
},
|
|
21
29
|
end: () => {
|
|
22
30
|
tree.hideCursor();
|
|
23
31
|
tree.redrawList();
|
|
24
32
|
tree.dispatch(dnd_slice_1.actions.dragEnd());
|
|
25
33
|
},
|
|
26
|
-
}), [ids, node]);
|
|
34
|
+
}), [ids, node, tree.props.dragType]);
|
|
27
35
|
(0, react_1.useEffect)(() => {
|
|
28
36
|
preview((0, react_dnd_html5_backend_1.getEmptyImage)());
|
|
29
37
|
}, [preview]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const drag_hook_1 = require("./drag-hook");
|
|
4
|
+
/* dragTypeForNode only reads node.data when dragType is a function, so a
|
|
5
|
+
minimal stub stands in for a real NodeApi. */
|
|
6
|
+
function nodeWith(data) {
|
|
7
|
+
return { data };
|
|
8
|
+
}
|
|
9
|
+
test("defaults to the internal NODE type when dragType is undefined", () => {
|
|
10
|
+
expect((0, drag_hook_1.dragTypeForNode)(undefined, nodeWith({ id: "a" }))).toBe("NODE");
|
|
11
|
+
});
|
|
12
|
+
test("uses a fixed string dragType for every node", () => {
|
|
13
|
+
expect((0, drag_hook_1.dragTypeForNode)("FILE", nodeWith({ id: "a" }))).toBe("FILE");
|
|
14
|
+
});
|
|
15
|
+
test("resolves a per-node dragType function against the node", () => {
|
|
16
|
+
const dragType = (node) => node.data.kind.toUpperCase();
|
|
17
|
+
expect((0, drag_hook_1.dragTypeForNode)(dragType, nodeWith({ kind: "folder" }))).toBe("FOLDER");
|
|
18
|
+
expect((0, drag_hook_1.dragTypeForNode)(dragType, nodeWith({ kind: "file" }))).toBe("FILE");
|
|
19
|
+
});
|
|
@@ -5,7 +5,7 @@ const create_root_1 = require("../data/create-root");
|
|
|
5
5
|
class NodeApi {
|
|
6
6
|
constructor(params) {
|
|
7
7
|
this.handleClick = (e) => {
|
|
8
|
-
if (e.metaKey && !this.tree.props.disableMultiSelection) {
|
|
8
|
+
if ((e.metaKey || e.ctrlKey) && !this.tree.props.disableMultiSelection) {
|
|
9
9
|
if (this.isSelected)
|
|
10
10
|
this.deselect();
|
|
11
11
|
else
|
package/dist/main/types/dnd.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface TreeProps<T> {
|
|
|
50
50
|
initialOpenState?: OpenMap;
|
|
51
51
|
searchTerm?: string;
|
|
52
52
|
searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
|
|
53
|
+
"aria-label"?: string;
|
|
54
|
+
"aria-labelledby"?: string;
|
|
53
55
|
className?: string | undefined;
|
|
54
56
|
rowClassName?: string | undefined;
|
|
55
57
|
dndRootElement?: globalThis.Node | null;
|
|
@@ -59,6 +61,7 @@ export interface TreeProps<T> {
|
|
|
59
61
|
backend: unknown;
|
|
60
62
|
}>["backend"];
|
|
61
63
|
dndManager?: ReturnType<typeof useDragDropManager>;
|
|
64
|
+
dragType?: string | ((node: NodeApi<T>) => string);
|
|
62
65
|
outerElementType?: ReactWindowCommonProps["outerElementType"];
|
|
63
66
|
innerElementType?: ReactWindowCommonProps["innerElementType"];
|
|
64
67
|
}
|
|
@@ -15,7 +15,7 @@ let timeoutId = null;
|
|
|
15
15
|
export function DefaultContainer() {
|
|
16
16
|
useDataUpdates();
|
|
17
17
|
const tree = useTreeApi();
|
|
18
|
-
return (_jsx("div", { role: "tree", style: {
|
|
18
|
+
return (_jsx("div", { role: "tree", "aria-label": tree.props["aria-label"], "aria-labelledby": tree.props["aria-labelledby"], "aria-multiselectable": !tree.props.disableMultiSelection || undefined, style: {
|
|
19
19
|
height: tree.height,
|
|
20
20
|
width: tree.width,
|
|
21
21
|
minHeight: 0,
|
|
@@ -141,16 +141,16 @@ export function DefaultContainer() {
|
|
|
141
141
|
}
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
|
-
if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
|
|
144
|
+
if (e.key === "a" && (e.metaKey || e.ctrlKey) && !tree.props.disableMultiSelection) {
|
|
145
145
|
e.preventDefault();
|
|
146
146
|
tree.selectAll();
|
|
147
147
|
return;
|
|
148
148
|
}
|
|
149
|
-
if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
|
|
149
|
+
if (e.key === "a" && !e.metaKey && !e.ctrlKey && tree.props.onCreate) {
|
|
150
150
|
tree.createLeaf();
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
|
-
if (e.key === "A" && !e.metaKey) {
|
|
153
|
+
if (e.key === "A" && !e.metaKey && !e.ctrlKey) {
|
|
154
154
|
if (!tree.props.onCreate)
|
|
155
155
|
return;
|
|
156
156
|
tree.createInternal();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { Tree } from "./tree";
|
|
4
|
+
const data = [
|
|
5
|
+
{
|
|
6
|
+
id: "1",
|
|
7
|
+
name: "root",
|
|
8
|
+
children: [
|
|
9
|
+
{ id: "2", name: "a" },
|
|
10
|
+
{ id: "3", name: "b", children: [{ id: "4", name: "c" }] },
|
|
11
|
+
],
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
/* #303: multi-select should respond to Ctrl+Click (Windows) as well as
|
|
15
|
+
Cmd/Meta+Click (macOS). */
|
|
16
|
+
test("Ctrl+Click adds a row to the selection (#303)", () => {
|
|
17
|
+
render(_jsx(Tree, { data: data, openByDefault: true }));
|
|
18
|
+
const [, a, b] = screen.getAllByRole("treeitem");
|
|
19
|
+
fireEvent.click(a);
|
|
20
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
21
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
22
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
23
|
+
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
24
|
+
});
|
|
25
|
+
test("Ctrl+Click toggles an already-selected row off (#303)", () => {
|
|
26
|
+
render(_jsx(Tree, { data: data, openByDefault: true }));
|
|
27
|
+
const [, a, b] = screen.getAllByRole("treeitem");
|
|
28
|
+
fireEvent.click(a);
|
|
29
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
30
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
31
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
32
|
+
expect(b.getAttribute("aria-selected")).toBe("false");
|
|
33
|
+
});
|
|
34
|
+
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => {
|
|
35
|
+
render(_jsx(Tree, { data: data, openByDefault: true, disableMultiSelection: true }));
|
|
36
|
+
const [, a, b] = screen.getAllByRole("treeitem");
|
|
37
|
+
fireEvent.click(a);
|
|
38
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
39
|
+
expect(a.getAttribute("aria-selected")).toBe("false");
|
|
40
|
+
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
41
|
+
});
|
|
42
|
+
/* #325: forward an accessible name and multiselectable state onto the
|
|
43
|
+
role="tree" element. */
|
|
44
|
+
test("forwards aria-label to the role=tree element (#325)", () => {
|
|
45
|
+
render(_jsx(Tree, { data: data, "aria-label": "File explorer" }));
|
|
46
|
+
expect(screen.getByRole("tree").getAttribute("aria-label")).toBe("File explorer");
|
|
47
|
+
});
|
|
48
|
+
test("forwards aria-labelledby to the role=tree element (#325)", () => {
|
|
49
|
+
render(_jsx(Tree, { data: data, "aria-labelledby": "heading-id" }));
|
|
50
|
+
expect(screen.getByRole("tree").getAttribute("aria-labelledby")).toBe("heading-id");
|
|
51
|
+
});
|
|
52
|
+
test("marks the tree aria-multiselectable by default (#325)", () => {
|
|
53
|
+
render(_jsx(Tree, { data: data }));
|
|
54
|
+
expect(screen.getByRole("tree").getAttribute("aria-multiselectable")).toBe("true");
|
|
55
|
+
});
|
|
56
|
+
test("omits aria-multiselectable when multi-select is disabled (#325)", () => {
|
|
57
|
+
render(_jsx(Tree, { data: data, disableMultiSelection: true }));
|
|
58
|
+
expect(screen.getByRole("tree").hasAttribute("aria-multiselectable")).toBe(false);
|
|
59
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { ConnectDragSource } from "react-dnd";
|
|
2
2
|
import { NodeApi } from "../interfaces/node-api";
|
|
3
|
+
import { TreeProps } from "../types/tree-props";
|
|
4
|
+
export declare function dragTypeForNode<T>(dragType: TreeProps<T>["dragType"], node: NodeApi<T>): string;
|
|
3
5
|
export declare function useDragHook<T>(node: NodeApi<T>): ConnectDragSource;
|
|
@@ -3,24 +3,31 @@ import { useDrag } from "react-dnd";
|
|
|
3
3
|
import { getEmptyImage } from "react-dnd-html5-backend";
|
|
4
4
|
import { useTreeApi } from "../context";
|
|
5
5
|
import { actions as dnd } from "../state/dnd-slice";
|
|
6
|
+
/* The react-dnd item type a row's drag source broadcasts. The dragType prop
|
|
7
|
+
can be a fixed string or a per-node function; it defaults to "NODE". */
|
|
8
|
+
export function dragTypeForNode(dragType, node) {
|
|
9
|
+
if (typeof dragType === "function")
|
|
10
|
+
return dragType(node);
|
|
11
|
+
return dragType !== null && dragType !== void 0 ? dragType : "NODE";
|
|
12
|
+
}
|
|
6
13
|
export function useDragHook(node) {
|
|
7
14
|
const tree = useTreeApi();
|
|
8
15
|
const ids = tree.selectedIds;
|
|
9
16
|
const [_, ref, preview] = useDrag(() => ({
|
|
10
17
|
canDrag: () => node.isDraggable,
|
|
11
|
-
type:
|
|
18
|
+
type: dragTypeForNode(tree.props.dragType, node),
|
|
12
19
|
item: () => {
|
|
13
|
-
// This is fired once at the
|
|
20
|
+
// This is fired once at the beginning of a drag operation
|
|
14
21
|
const dragIds = tree.isSelected(node.id) ? Array.from(ids) : [node.id];
|
|
15
22
|
tree.dispatch(dnd.dragStart(node.id, dragIds));
|
|
16
|
-
return { id: node.id, dragIds };
|
|
23
|
+
return { id: node.id, dragIds, data: node.data };
|
|
17
24
|
},
|
|
18
25
|
end: () => {
|
|
19
26
|
tree.hideCursor();
|
|
20
27
|
tree.redrawList();
|
|
21
28
|
tree.dispatch(dnd.dragEnd());
|
|
22
29
|
},
|
|
23
|
-
}), [ids, node]);
|
|
30
|
+
}), [ids, node, tree.props.dragType]);
|
|
24
31
|
useEffect(() => {
|
|
25
32
|
preview(getEmptyImage());
|
|
26
33
|
}, [preview]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { dragTypeForNode } from "./drag-hook";
|
|
2
|
+
/* dragTypeForNode only reads node.data when dragType is a function, so a
|
|
3
|
+
minimal stub stands in for a real NodeApi. */
|
|
4
|
+
function nodeWith(data) {
|
|
5
|
+
return { data };
|
|
6
|
+
}
|
|
7
|
+
test("defaults to the internal NODE type when dragType is undefined", () => {
|
|
8
|
+
expect(dragTypeForNode(undefined, nodeWith({ id: "a" }))).toBe("NODE");
|
|
9
|
+
});
|
|
10
|
+
test("uses a fixed string dragType for every node", () => {
|
|
11
|
+
expect(dragTypeForNode("FILE", nodeWith({ id: "a" }))).toBe("FILE");
|
|
12
|
+
});
|
|
13
|
+
test("resolves a per-node dragType function against the node", () => {
|
|
14
|
+
const dragType = (node) => node.data.kind.toUpperCase();
|
|
15
|
+
expect(dragTypeForNode(dragType, nodeWith({ kind: "folder" }))).toBe("FOLDER");
|
|
16
|
+
expect(dragTypeForNode(dragType, nodeWith({ kind: "file" }))).toBe("FILE");
|
|
17
|
+
});
|
|
@@ -2,7 +2,7 @@ import { ROOT_ID } from "../data/create-root";
|
|
|
2
2
|
export class NodeApi {
|
|
3
3
|
constructor(params) {
|
|
4
4
|
this.handleClick = (e) => {
|
|
5
|
-
if (e.metaKey && !this.tree.props.disableMultiSelection) {
|
|
5
|
+
if ((e.metaKey || e.ctrlKey) && !this.tree.props.disableMultiSelection) {
|
|
6
6
|
if (this.isSelected)
|
|
7
7
|
this.deselect();
|
|
8
8
|
else
|
|
@@ -50,6 +50,8 @@ export interface TreeProps<T> {
|
|
|
50
50
|
initialOpenState?: OpenMap;
|
|
51
51
|
searchTerm?: string;
|
|
52
52
|
searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
|
|
53
|
+
"aria-label"?: string;
|
|
54
|
+
"aria-labelledby"?: string;
|
|
53
55
|
className?: string | undefined;
|
|
54
56
|
rowClassName?: string | undefined;
|
|
55
57
|
dndRootElement?: globalThis.Node | null;
|
|
@@ -59,6 +61,7 @@ export interface TreeProps<T> {
|
|
|
59
61
|
backend: unknown;
|
|
60
62
|
}>["backend"];
|
|
61
63
|
dndManager?: ReturnType<typeof useDragDropManager>;
|
|
64
|
+
dragType?: string | ((node: NodeApi<T>) => string);
|
|
62
65
|
outerElementType?: ReactWindowCommonProps["outerElementType"];
|
|
63
66
|
innerElementType?: ReactWindowCommonProps["innerElementType"];
|
|
64
67
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { Tree } from "./tree";
|
|
3
|
+
|
|
4
|
+
type Datum = { id: string; name: string; children?: Datum[] };
|
|
5
|
+
|
|
6
|
+
const data: Datum[] = [
|
|
7
|
+
{
|
|
8
|
+
id: "1",
|
|
9
|
+
name: "root",
|
|
10
|
+
children: [
|
|
11
|
+
{ id: "2", name: "a" },
|
|
12
|
+
{ id: "3", name: "b", children: [{ id: "4", name: "c" }] },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/* #303: multi-select should respond to Ctrl+Click (Windows) as well as
|
|
18
|
+
Cmd/Meta+Click (macOS). */
|
|
19
|
+
test("Ctrl+Click adds a row to the selection (#303)", () => {
|
|
20
|
+
render(<Tree<Datum> data={data} openByDefault />);
|
|
21
|
+
const [, a, b] = screen.getAllByRole("treeitem");
|
|
22
|
+
|
|
23
|
+
fireEvent.click(a);
|
|
24
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
25
|
+
|
|
26
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
27
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
28
|
+
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Ctrl+Click toggles an already-selected row off (#303)", () => {
|
|
32
|
+
render(<Tree<Datum> data={data} openByDefault />);
|
|
33
|
+
const [, a, b] = screen.getAllByRole("treeitem");
|
|
34
|
+
|
|
35
|
+
fireEvent.click(a);
|
|
36
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
37
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
38
|
+
|
|
39
|
+
expect(a.getAttribute("aria-selected")).toBe("true");
|
|
40
|
+
expect(b.getAttribute("aria-selected")).toBe("false");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Ctrl+Click falls through to a plain select when multi-select is disabled (#303)", () => {
|
|
44
|
+
render(<Tree<Datum> data={data} openByDefault disableMultiSelection />);
|
|
45
|
+
const [, a, b] = screen.getAllByRole("treeitem");
|
|
46
|
+
|
|
47
|
+
fireEvent.click(a);
|
|
48
|
+
fireEvent.click(b, { ctrlKey: true });
|
|
49
|
+
|
|
50
|
+
expect(a.getAttribute("aria-selected")).toBe("false");
|
|
51
|
+
expect(b.getAttribute("aria-selected")).toBe("true");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/* #325: forward an accessible name and multiselectable state onto the
|
|
55
|
+
role="tree" element. */
|
|
56
|
+
test("forwards aria-label to the role=tree element (#325)", () => {
|
|
57
|
+
render(<Tree<Datum> data={data} aria-label="File explorer" />);
|
|
58
|
+
expect(screen.getByRole("tree").getAttribute("aria-label")).toBe("File explorer");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("forwards aria-labelledby to the role=tree element (#325)", () => {
|
|
62
|
+
render(<Tree<Datum> data={data} aria-labelledby="heading-id" />);
|
|
63
|
+
expect(screen.getByRole("tree").getAttribute("aria-labelledby")).toBe("heading-id");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("marks the tree aria-multiselectable by default (#325)", () => {
|
|
67
|
+
render(<Tree<Datum> data={data} />);
|
|
68
|
+
expect(screen.getByRole("tree").getAttribute("aria-multiselectable")).toBe("true");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("omits aria-multiselectable when multi-select is disabled (#325)", () => {
|
|
72
|
+
render(<Tree<Datum> data={data} disableMultiSelection />);
|
|
73
|
+
expect(screen.getByRole("tree").hasAttribute("aria-multiselectable")).toBe(false);
|
|
74
|
+
});
|
|
@@ -19,6 +19,9 @@ export function DefaultContainer() {
|
|
|
19
19
|
return (
|
|
20
20
|
<div
|
|
21
21
|
role="tree"
|
|
22
|
+
aria-label={tree.props["aria-label"]}
|
|
23
|
+
aria-labelledby={tree.props["aria-labelledby"]}
|
|
24
|
+
aria-multiselectable={!tree.props.disableMultiSelection || undefined}
|
|
22
25
|
style={{
|
|
23
26
|
height: tree.height,
|
|
24
27
|
width: tree.width,
|
|
@@ -133,16 +136,16 @@ export function DefaultContainer() {
|
|
|
133
136
|
}
|
|
134
137
|
return;
|
|
135
138
|
}
|
|
136
|
-
if (e.key === "a" && e.metaKey && !tree.props.disableMultiSelection) {
|
|
139
|
+
if (e.key === "a" && (e.metaKey || e.ctrlKey) && !tree.props.disableMultiSelection) {
|
|
137
140
|
e.preventDefault();
|
|
138
141
|
tree.selectAll();
|
|
139
142
|
return;
|
|
140
143
|
}
|
|
141
|
-
if (e.key === "a" && !e.metaKey && tree.props.onCreate) {
|
|
144
|
+
if (e.key === "a" && !e.metaKey && !e.ctrlKey && tree.props.onCreate) {
|
|
142
145
|
tree.createLeaf();
|
|
143
146
|
return;
|
|
144
147
|
}
|
|
145
|
-
if (e.key === "A" && !e.metaKey) {
|
|
148
|
+
if (e.key === "A" && !e.metaKey && !e.ctrlKey) {
|
|
146
149
|
if (!tree.props.onCreate) return;
|
|
147
150
|
tree.createInternal();
|
|
148
151
|
return;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NodeApi } from "../interfaces/node-api";
|
|
2
|
+
import { dragTypeForNode } from "./drag-hook";
|
|
3
|
+
|
|
4
|
+
/* dragTypeForNode only reads node.data when dragType is a function, so a
|
|
5
|
+
minimal stub stands in for a real NodeApi. */
|
|
6
|
+
function nodeWith<T>(data: T): NodeApi<T> {
|
|
7
|
+
return { data } as NodeApi<T>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("defaults to the internal NODE type when dragType is undefined", () => {
|
|
11
|
+
expect(dragTypeForNode(undefined, nodeWith({ id: "a" }))).toBe("NODE");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("uses a fixed string dragType for every node", () => {
|
|
15
|
+
expect(dragTypeForNode("FILE", nodeWith({ id: "a" }))).toBe("FILE");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("resolves a per-node dragType function against the node", () => {
|
|
19
|
+
const dragType = (node: NodeApi<{ kind: string }>) => node.data.kind.toUpperCase();
|
|
20
|
+
expect(dragTypeForNode(dragType, nodeWith({ kind: "folder" }))).toBe("FOLDER");
|
|
21
|
+
expect(dragTypeForNode(dragType, nodeWith({ kind: "file" }))).toBe("FILE");
|
|
22
|
+
});
|
package/src/dnd/drag-hook.ts
CHANGED
|
@@ -4,21 +4,29 @@ import { getEmptyImage } from "react-dnd-html5-backend";
|
|
|
4
4
|
import { useTreeApi } from "../context";
|
|
5
5
|
import { NodeApi } from "../interfaces/node-api";
|
|
6
6
|
import { DragItem } from "../types/dnd";
|
|
7
|
+
import { TreeProps } from "../types/tree-props";
|
|
7
8
|
import { DropResult } from "./drop-hook";
|
|
8
9
|
import { actions as dnd } from "../state/dnd-slice";
|
|
9
10
|
|
|
11
|
+
/* The react-dnd item type a row's drag source broadcasts. The dragType prop
|
|
12
|
+
can be a fixed string or a per-node function; it defaults to "NODE". */
|
|
13
|
+
export function dragTypeForNode<T>(dragType: TreeProps<T>["dragType"], node: NodeApi<T>): string {
|
|
14
|
+
if (typeof dragType === "function") return dragType(node);
|
|
15
|
+
return dragType ?? "NODE";
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export function useDragHook<T>(node: NodeApi<T>): ConnectDragSource {
|
|
11
|
-
const tree = useTreeApi();
|
|
19
|
+
const tree = useTreeApi<T>();
|
|
12
20
|
const ids = tree.selectedIds;
|
|
13
|
-
const [_, ref, preview] = useDrag<DragItem
|
|
21
|
+
const [_, ref, preview] = useDrag<DragItem<T>, DropResult, void>(
|
|
14
22
|
() => ({
|
|
15
23
|
canDrag: () => node.isDraggable,
|
|
16
|
-
type:
|
|
24
|
+
type: dragTypeForNode(tree.props.dragType, node),
|
|
17
25
|
item: () => {
|
|
18
|
-
// This is fired once at the
|
|
26
|
+
// This is fired once at the beginning of a drag operation
|
|
19
27
|
const dragIds = tree.isSelected(node.id) ? Array.from(ids) : [node.id];
|
|
20
28
|
tree.dispatch(dnd.dragStart(node.id, dragIds));
|
|
21
|
-
return { id: node.id, dragIds };
|
|
29
|
+
return { id: node.id, dragIds, data: node.data };
|
|
22
30
|
},
|
|
23
31
|
end: () => {
|
|
24
32
|
tree.hideCursor();
|
|
@@ -26,7 +34,7 @@ export function useDragHook<T>(node: NodeApi<T>): ConnectDragSource {
|
|
|
26
34
|
tree.dispatch(dnd.dragEnd());
|
|
27
35
|
},
|
|
28
36
|
}),
|
|
29
|
-
[ids, node],
|
|
37
|
+
[ids, node, tree.props.dragType],
|
|
30
38
|
);
|
|
31
39
|
|
|
32
40
|
useEffect(() => {
|
|
@@ -200,7 +200,7 @@ export class NodeApi<T = any> {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
handleClick = (e: React.MouseEvent) => {
|
|
203
|
-
if (e.metaKey && !this.tree.props.disableMultiSelection) {
|
|
203
|
+
if ((e.metaKey || e.ctrlKey) && !this.tree.props.disableMultiSelection) {
|
|
204
204
|
if (this.isSelected) this.deselect();
|
|
205
205
|
else this.selectMulti();
|
|
206
206
|
} else if (e.shiftKey && !this.tree.props.disableMultiSelection) {
|
package/src/types/dnd.ts
CHANGED
|
@@ -4,6 +4,11 @@ export type CursorLocation = {
|
|
|
4
4
|
parentId: string | null;
|
|
5
5
|
};
|
|
6
6
|
|
|
7
|
-
export type DragItem = {
|
|
7
|
+
export type DragItem<T = any> = {
|
|
8
|
+
/* The id of the row the drag started on. */
|
|
8
9
|
id: string;
|
|
10
|
+
/* Every node carried by the drag (the selection, or just `id`). */
|
|
11
|
+
dragIds: string[];
|
|
12
|
+
/* The dragged node's data, so external drop targets can read it. */
|
|
13
|
+
data: T;
|
|
9
14
|
};
|
package/src/types/tree-props.ts
CHANGED
|
@@ -69,6 +69,10 @@ export interface TreeProps<T> {
|
|
|
69
69
|
searchTerm?: string;
|
|
70
70
|
searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
|
|
71
71
|
|
|
72
|
+
/* Accessibility */
|
|
73
|
+
"aria-label"?: string;
|
|
74
|
+
"aria-labelledby"?: string;
|
|
75
|
+
|
|
72
76
|
/* Extra */
|
|
73
77
|
className?: string | undefined;
|
|
74
78
|
rowClassName?: string | undefined;
|
|
@@ -79,6 +83,15 @@ export interface TreeProps<T> {
|
|
|
79
83
|
dndBackend?: Extract<DndProviderProps<unknown, unknown>, { backend: unknown }>["backend"];
|
|
80
84
|
dndManager?: ReturnType<typeof useDragDropManager>;
|
|
81
85
|
|
|
86
|
+
/* The react-dnd item type each row's drag source advertises. Defaults to
|
|
87
|
+
"NODE". Set a custom value (or a per-node function) so rows can be dropped
|
|
88
|
+
onto external react-dnd targets that accept that type. The dragged node's
|
|
89
|
+
data is always exposed on the drag item, so an external target accepting
|
|
90
|
+
the default "NODE" type can read it without setting this. Note: the tree's
|
|
91
|
+
own drop targets only accept "NODE", so a row given a custom type is no
|
|
92
|
+
longer reorderable within the tree. */
|
|
93
|
+
dragType?: string | ((node: NodeApi<T>) => string);
|
|
94
|
+
|
|
82
95
|
/* Custom react-window outer/inner elements */
|
|
83
96
|
outerElementType?: ReactWindowCommonProps["outerElementType"];
|
|
84
97
|
innerElementType?: ReactWindowCommonProps["innerElementType"];
|