react-arborist 2.0.0-rc → 2.0.0-rc.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.
@@ -23,7 +23,7 @@ export declare type DragPreviewProps = {
23
23
  dragIds: string[];
24
24
  isDragging: boolean;
25
25
  };
26
- export declare type DropCursorProps = {
26
+ export declare type CursorProps = {
27
27
  top: number;
28
28
  left: number;
29
29
  indent: number;
@@ -15,7 +15,7 @@ export interface TreeProps<T extends IdObj> {
15
15
  children?: ElementType<renderers.NodeRendererProps<T>>;
16
16
  renderRow?: ElementType<renderers.RowRendererProps<T>>;
17
17
  renderDragPreview?: ElementType<renderers.DragPreviewProps>;
18
- renderCursor?: ElementType<renderers.DropCursorProps>;
18
+ renderCursor?: ElementType<renderers.CursorProps>;
19
19
  renderContainer?: ElementType<{}>;
20
20
  rowHeight?: number;
21
21
  width?: number;
@@ -28,15 +28,19 @@ export interface TreeProps<T extends IdObj> {
28
28
  selectionFollowsFocus?: boolean;
29
29
  disableDrag?: string | boolean | BoolFunc<T>;
30
30
  disableDrop?: string | boolean | BoolFunc<T>;
31
- getChildren?: string | ((d: T) => T[]);
31
+ childrenAccessor?: string | ((d: T) => T[]);
32
+ idAccessor?: string | ((d: T) => string);
32
33
  onActivate?: (node: NodeApi<T>) => void;
33
34
  onSelect?: (nodes: NodeApi<T>[]) => void;
34
35
  onScroll?: (props: ListOnScrollProps) => void;
36
+ onToggle?: (id: string) => void;
37
+ onFocus?: (node: NodeApi<T>) => void;
35
38
  selection?: string;
36
39
  initialOpenState?: OpenMap;
37
40
  searchTerm?: string;
38
41
  searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;
39
42
  className?: string | undefined;
43
+ rowClassName?: string | undefined;
40
44
  dndRootElement?: globalThis.Node | null;
41
45
  onClick?: MouseEventHandler;
42
46
  onContextMenu?: MouseEventHandler;
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { NodeApi } from "./interfaces/node-api";
2
+ import { TreeApi } from "./interfaces/tree-api";
2
3
  import { IdObj } from "./types/utils";
3
4
  export declare function bound(n: number, min: number, max: number): number;
4
5
  export declare function isItem(node: NodeApi<any> | null): boolean | null;
@@ -10,6 +11,7 @@ export declare const isDecendent: (a: NodeApi<any>, b: NodeApi<any>) => boolean;
10
11
  export declare const indexOf: (node: NodeApi<any>) => number;
11
12
  export declare function noop(): void;
12
13
  export declare function dfs(node: NodeApi<any>, id: string): NodeApi<any> | null;
14
+ export declare function walk(node: NodeApi<any>, fn: (node: NodeApi<any>) => void): void;
13
15
  export declare function focusNextElement(target: HTMLElement): void;
14
16
  export declare function focusPrevElement(target: HTMLElement): void;
15
17
  export declare function access<T = boolean>(obj: any, accessor: string | boolean | Function): T;
@@ -18,3 +20,5 @@ export declare function identify(obj: string | IdObj): string;
18
20
  export declare function mergeRefs(...refs: any): (instance: any) => void;
19
21
  export declare function safeRun<T extends (...args: any[]) => any>(fn: T | undefined, ...args: Parameters<T>): any;
20
22
  export declare function waitFor(fn: () => boolean): Promise<void>;
23
+ export declare function getInsertIndex(tree: TreeApi<any>): number;
24
+ export declare function getInsertParentId(tree: TreeApi<any>): string | null;
package/package.json CHANGED
@@ -1,13 +1,24 @@
1
1
  {
2
2
  "name": "react-arborist",
3
- "version": "2.0.0-rc",
3
+ "version": "2.0.0-rc.2",
4
4
  "license": "MIT",
5
5
  "source": "src/index.ts",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/module.js",
8
8
  "types": "dist/index.d.ts",
9
- "repository": "github:brimdata/react-arborist",
9
+ "repository": "https://github.com/brimdata/react-arborist",
10
10
  "homepage": "https://react-arborist.netlify.app",
11
+ "keywords": [
12
+ "react",
13
+ "arborist",
14
+ "react-arborist",
15
+ "treeview",
16
+ "tree",
17
+ "vitualized",
18
+ "dnd",
19
+ "multiselection",
20
+ "filterable"
21
+ ],
11
22
  "dependencies": {
12
23
  "react-dnd": "^14.0.3",
13
24
  "react-dnd-html5-backend": "^14.0.1",
@@ -29,6 +40,7 @@
29
40
  "react-dom": ">= 16.14"
30
41
  },
31
42
  "devDependencies": {
43
+ "@parcel/core": "^2.3.2",
32
44
  "@types/jest": "^27.4.1",
33
45
  "@types/react": "^18.0.0",
34
46
  "@types/react-window": "^1.8.5",
@@ -1,6 +1,6 @@
1
1
  import { useDndContext, useTreeApi } from "../context";
2
2
 
3
- export function DropCursor() {
3
+ export function Cursor() {
4
4
  const tree = useTreeApi();
5
5
  const state = useDndContext();
6
6
  const cursor = state.cursor;
@@ -8,6 +8,11 @@ import { RowContainer } from "./row-container";
8
8
  let focusSearchTerm = "";
9
9
  let timeoutId: any = null;
10
10
 
11
+ /**
12
+ * All these keyboard shortcuts seem like they should be configurable.
13
+ * Each operation should be a given a name and separated from
14
+ * the event handler. Future clean up welcome.
15
+ */
11
16
  export function DefaultContainer() {
12
17
  useDataUpdates();
13
18
  const tree = useTreeApi();
@@ -37,6 +42,7 @@ export function DefaultContainer() {
37
42
  return;
38
43
  }
39
44
  if (e.key === "Backspace") {
45
+ if (!tree.props.onDelete) return;
40
46
  const ids = Array.from(tree.selectedIds);
41
47
  if (ids.length > 1) {
42
48
  let nextFocus = tree.mostRecentNode;
@@ -129,10 +135,12 @@ export function DefaultContainer() {
129
135
  return;
130
136
  }
131
137
  if (e.key === "a" && !e.metaKey) {
138
+ if (!tree.props.onCreate) return;
132
139
  tree.createLeaf();
133
140
  return;
134
141
  }
135
142
  if (e.key === "A" && !e.metaKey) {
143
+ if (!tree.props.onCreate) return;
136
144
  tree.createInternal();
137
145
  return;
138
146
  }
@@ -150,6 +158,7 @@ export function DefaultContainer() {
150
158
  return;
151
159
  }
152
160
  if (e.key === "Enter") {
161
+ if (!tree.props.onRename) return;
153
162
  setTimeout(() => {
154
163
  if (tree.focusedNode) tree.edit(tree.focusedNode);
155
164
  });
@@ -1,5 +1,5 @@
1
1
  import React, { CSSProperties } from "react";
2
- import { DropCursorProps } from "../types/renderers";
2
+ import { CursorProps } from "../types/renderers";
3
3
 
4
4
  const placeholderStyle = {
5
5
  display: "flex",
@@ -25,7 +25,7 @@ export const DefaultCursor = React.memo(function DefaultCursor({
25
25
  top,
26
26
  left,
27
27
  indent,
28
- }: DropCursorProps) {
28
+ }: CursorProps) {
29
29
  const style: CSSProperties = {
30
30
  position: "absolute",
31
31
  pointerEvents: "none",
@@ -1,15 +1,50 @@
1
- import React from "react";
1
+ import React, { useEffect, useRef } from "react";
2
2
  import { NodeRendererProps } from "../types/renderers";
3
3
  import { IdObj } from "../types/utils";
4
4
 
5
- export function DefaultNode<T extends IdObj>({
6
- style,
7
- node,
8
- dragHandle,
9
- }: NodeRendererProps<T>) {
5
+ export function DefaultNode<T extends IdObj>(props: NodeRendererProps<T>) {
10
6
  return (
11
- <div style={style} ref={dragHandle}>
12
- ID: {node.data.id}
7
+ <div ref={props.dragHandle} style={props.style}>
8
+ <span
9
+ onClick={(e) => {
10
+ e.stopPropagation();
11
+ props.node.toggle();
12
+ }}
13
+ >
14
+ {props.node.isLeaf ? "🌳" : props.node.isOpen ? "🗁" : "🗀"}
15
+ </span>{" "}
16
+ {props.node.isEditing ? <Edit {...props} /> : <Show {...props} />}
13
17
  </div>
14
18
  );
15
19
  }
20
+
21
+ function Show<T extends IdObj>(props: NodeRendererProps<T>) {
22
+ return (
23
+ <>
24
+ {/* @ts-ignore */}
25
+ <span>{props.node.data.name}</span>
26
+ </>
27
+ );
28
+ }
29
+
30
+ function Edit<T extends IdObj>({ node }: NodeRendererProps<T>) {
31
+ const input = useRef<any>();
32
+
33
+ useEffect(() => {
34
+ input.current?.focus();
35
+ input.current?.select();
36
+ }, []);
37
+
38
+ return (
39
+ <input
40
+ ref={input}
41
+ // @ts-ignore
42
+ defaultValue={node.data.name}
43
+ onBlur={() => node.reset()}
44
+ onKeyDown={(e) => {
45
+ if (e.key === "Escape") node.reset();
46
+ if (e.key === "Enter") node.submit(input.current?.value || "");
47
+ }}
48
+ ></input>
49
+ );
50
+ }
@@ -1,7 +1,7 @@
1
1
  import { forwardRef } from "react";
2
2
  import { useTreeApi } from "../context";
3
3
  import { treeBlur } from "../state/focus-slice";
4
- import { DropCursor } from "./cursor";
4
+ import { Cursor } from "./cursor";
5
5
 
6
6
  export const ListOuterElement = forwardRef(function Outer(
7
7
  props: React.HTMLProps<HTMLDivElement>,
@@ -15,7 +15,7 @@ export const ListOuterElement = forwardRef(function Outer(
15
15
  ref={ref}
16
16
  {...rest}
17
17
  onClick={(e) => {
18
- if (e.currentTarget === e.target) tree.selectNone();
18
+ if (e.currentTarget === e.target) tree.deselectAll();
19
19
  }}
20
20
  >
21
21
  <DropContainer />
@@ -35,11 +35,8 @@ const DropContainer = () => {
35
35
  left: "0",
36
36
  right: "0",
37
37
  }}
38
- onClick={(e) => {
39
- console.log(e.currentTarget, e.target);
40
- }}
41
38
  >
42
- <DropCursor />
39
+ <Cursor />
43
40
  </div>
44
41
  );
45
42
  };
@@ -67,7 +67,7 @@ export function TreeProvider<T extends IdObj>({
67
67
  if (api.props.selection) {
68
68
  api.select(api.props.selection);
69
69
  } else {
70
- api.selectNone();
70
+ api.deselectAll();
71
71
  }
72
72
  }, [api.props.selection]);
73
73
 
@@ -63,6 +63,7 @@ export const RowContainer = React.memo(function RowContainer<T extends IdObj>({
63
63
  "aria-selected": node.isSelected,
64
64
  style: rowStyle,
65
65
  tabIndex: -1,
66
+ className: tree.props.rowClassName,
66
67
  };
67
68
 
68
69
  useEffect(() => {
@@ -10,18 +10,19 @@ export function createRoot<T extends IdObj>(tree: TreeApi<T>): NodeApi<T> {
10
10
  level: number,
11
11
  parent: NodeApi<T> | null
12
12
  ) {
13
+ const id = tree.accessId(data);
13
14
  const node = new NodeApi<T>({
14
15
  tree,
15
16
  data,
16
17
  level,
17
18
  parent,
18
- id: data.id,
19
+ id,
19
20
  children: null,
20
21
  isDraggable: tree.isDraggable(data),
21
22
  isDroppable: tree.isDroppable(data),
22
23
  rowIndex: null,
23
24
  });
24
- const children = tree.getChildren(data);
25
+ const children = tree.accessChildren(data);
25
26
  if (children) {
26
27
  node.children = children.map((child: T) =>
27
28
  visitSelfAndChildren(child, level + 1, node)
@@ -1,6 +1,7 @@
1
1
  import { useDrop } from "react-dnd";
2
2
  import { useTreeApi } from "../context";
3
3
  import { DragItem } from "../types/dnd";
4
+ import { isDecendent } from "../utils";
4
5
  import { computeDrop } from "./compute-drop";
5
6
  import { DropResult } from "./drop-hook";
6
7
 
@@ -13,9 +14,28 @@ export function useOuterDrop() {
13
14
  accept: "NODE",
14
15
  hover: (item, m) => {
15
16
  if (!m.isOver({ shallow: true })) return;
17
+ if (m.canDrop()) {
18
+ const offset = m.getClientOffset();
19
+ if (!tree.listEl.current || !offset) return;
20
+ const { cursor } = computeDrop({
21
+ element: tree.listEl.current,
22
+ offset: offset,
23
+ indent: tree.indent,
24
+ node: null,
25
+ prevNode: tree.visibleNodes[tree.visibleNodes.length - 1],
26
+ nextNode: null,
27
+ });
28
+ if (cursor) tree.showCursor(cursor);
29
+ } else {
30
+ tree.hideCursor();
31
+ }
32
+ },
33
+ canDrop: (item, m) => {
34
+ if (!m.isOver({ shallow: true })) return false;
35
+ if (tree.isFiltered) return false;
16
36
  const offset = m.getClientOffset();
17
- if (!tree.listEl.current || !offset) return;
18
- const { cursor } = computeDrop({
37
+ if (!tree.listEl.current || !offset) return false;
38
+ const { drop } = computeDrop({
19
39
  element: tree.listEl.current,
20
40
  offset: offset,
21
41
  indent: tree.indent,
@@ -23,10 +43,16 @@ export function useOuterDrop() {
23
43
  prevNode: tree.visibleNodes[tree.visibleNodes.length - 1],
24
44
  nextNode: null,
25
45
  });
26
- if (cursor) tree.showCursor(cursor);
27
- },
28
- canDrop: (item, m) => {
29
- return m.isOver({ shallow: true });
46
+ if (!drop) return false;
47
+ const dropParent = tree.get(drop.parentId) ?? tree.root;
48
+
49
+ for (let id of item.dragIds) {
50
+ const drag = tree.get(id);
51
+ if (!drag) return false;
52
+ if (!dropParent) return false;
53
+ if (drag.isInternal && isDecendent(dropParent, drag)) return false;
54
+ }
55
+ return true;
30
56
  },
31
57
  drop: (item, m) => {
32
58
  if (m.didDrop()) return;
@@ -38,21 +38,6 @@ export class NodeApi<T extends IdObj = IdObj> {
38
38
  this.rowIndex = params.rowIndex;
39
39
  }
40
40
 
41
- get next(): NodeApi<T> | null {
42
- if (this.rowIndex === null) return null;
43
- return this.tree.at(this.rowIndex + 1);
44
- }
45
-
46
- get prev(): NodeApi<T> | null {
47
- if (this.rowIndex === null) return null;
48
- return this.tree.at(this.rowIndex - 1);
49
- }
50
-
51
- get nextSibling(): NodeApi<T> | null {
52
- const i = this.childIndex;
53
- return this.parent?.children![i + 1] ?? null;
54
- }
55
-
56
41
  get isRoot() {
57
42
  return this.id === ROOT_ID;
58
43
  }
@@ -69,6 +54,10 @@ export class NodeApi<T extends IdObj = IdObj> {
69
54
  return this.isLeaf ? false : this.tree.isOpen(this.id);
70
55
  }
71
56
 
57
+ get isClosed() {
58
+ return this.isLeaf ? false : !this.tree.isOpen(this.id);
59
+ }
60
+
72
61
  get isEditing() {
73
62
  return this.tree.editingId === this.id;
74
63
  }
@@ -77,6 +66,10 @@ export class NodeApi<T extends IdObj = IdObj> {
77
66
  return this.tree.isSelected(this.id);
78
67
  }
79
68
 
69
+ get isOnlySelection() {
70
+ return this.isSelected && this.tree.hasOneSelection;
71
+ }
72
+
80
73
  get isSelectedStart() {
81
74
  return this.isSelected && !this.prev?.isSelected;
82
75
  }
@@ -89,14 +82,6 @@ export class NodeApi<T extends IdObj = IdObj> {
89
82
  return this.tree.isFocused(this.id);
90
83
  }
91
84
 
92
- get childIndex() {
93
- if (this.parent && this.parent.children) {
94
- return this.parent.children.findIndex((child) => child.id === this.id);
95
- } else {
96
- return -1;
97
- }
98
- }
99
-
100
85
  get isDragging() {
101
86
  return this.tree.isDragging(this.id);
102
87
  }
@@ -107,17 +92,43 @@ export class NodeApi<T extends IdObj = IdObj> {
107
92
 
108
93
  get state() {
109
94
  return {
110
- isEditing: this.isEditing,
95
+ isClosed: this.isClosed,
111
96
  isDragging: this.isDragging,
112
- isSelected: this.isSelected,
113
- isSelectedStart: this.isSelectedStart,
114
- isSelectedEnd: this.isSelectedEnd,
97
+ isEditing: this.isEditing,
115
98
  isFocused: this.isFocused,
99
+ isInternal: this.isInternal,
100
+ isLeaf: this.isLeaf,
116
101
  isOpen: this.isOpen,
102
+ isSelected: this.isSelected,
103
+ isSelectedEnd: this.isSelectedEnd,
104
+ isSelectedStart: this.isSelectedStart,
117
105
  willReceiveDrop: this.willReceiveDrop,
118
106
  };
119
107
  }
120
108
 
109
+ get childIndex() {
110
+ if (this.parent && this.parent.children) {
111
+ return this.parent.children.findIndex((child) => child.id === this.id);
112
+ } else {
113
+ return -1;
114
+ }
115
+ }
116
+
117
+ get next(): NodeApi<T> | null {
118
+ if (this.rowIndex === null) return null;
119
+ return this.tree.at(this.rowIndex + 1);
120
+ }
121
+
122
+ get prev(): NodeApi<T> | null {
123
+ if (this.rowIndex === null) return null;
124
+ return this.tree.at(this.rowIndex - 1);
125
+ }
126
+
127
+ get nextSibling(): NodeApi<T> | null {
128
+ const i = this.childIndex;
129
+ return this.parent?.children![i + 1] ?? null;
130
+ }
131
+
121
132
  select() {
122
133
  this.tree.select(this);
123
134
  }
@@ -2,12 +2,7 @@ import { EditResult } from "../types/handlers";
2
2
  import { Identity, IdObj } from "../types/utils";
3
3
  import { TreeProps } from "../types/tree-props";
4
4
  import { MutableRefObject } from "react";
5
- import {
6
- Align,
7
- FixedSizeList,
8
- ListOnItemsRenderedProps,
9
- ListOnScrollProps,
10
- } from "react-window";
5
+ import { Align, FixedSizeList, ListOnItemsRenderedProps } from "react-window";
11
6
  import * as utils from "../utils";
12
7
  import { DefaultCursor } from "../components/default-cursor";
13
8
  import { DefaultRow } from "../components/default-row";
@@ -73,19 +68,19 @@ export class TreeApi<T extends IdObj> {
73
68
  /* Tree Props */
74
69
 
75
70
  get width() {
76
- return this.props.width || 300;
71
+ return this.props.width ?? 300;
77
72
  }
78
73
 
79
74
  get height() {
80
- return this.props.height || 500;
75
+ return this.props.height ?? 500;
81
76
  }
82
77
 
83
78
  get indent() {
84
- return this.props.indent || 24;
79
+ return this.props.indent ?? 24;
85
80
  }
86
81
 
87
82
  get rowHeight() {
88
- return this.props.rowHeight || 24;
83
+ return this.props.rowHeight ?? 24;
89
84
  }
90
85
 
91
86
  get searchTerm() {
@@ -102,11 +97,21 @@ export class TreeApi<T extends IdObj> {
102
97
  return (node: NodeApi<T>) => match(node, this.searchTerm);
103
98
  }
104
99
 
105
- getChildren(data: T) {
106
- const get = this.props.getChildren || "children";
100
+ accessChildren(data: T) {
101
+ const get = this.props.childrenAccessor || "children";
107
102
  return utils.access<T[] | undefined>(data, get) ?? null;
108
103
  }
109
104
 
105
+ accessId(data: T) {
106
+ const get = this.props.idAccessor || "id";
107
+ const id = utils.access<string>(data, get);
108
+ if (!id)
109
+ throw new Error(
110
+ "Data must contain an 'id' property or props.idAccessor must return a string"
111
+ );
112
+ return id;
113
+ }
114
+
110
115
  /* Node Access */
111
116
 
112
117
  get firstNode() {
@@ -171,33 +176,27 @@ export class TreeApi<T extends IdObj> {
171
176
  }
172
177
 
173
178
  createInternal() {
174
- return this.create("internal");
179
+ return this.create({ type: "internal" });
175
180
  }
176
181
 
177
182
  createLeaf() {
178
- return this.create("leaf");
179
- }
180
-
181
- private async create(type: "internal" | "leaf") {
182
- let index;
183
- let parentId;
184
- const focus = this.focusedNode;
185
- if (focus && focus.parent) {
186
- if (focus.isInternal && focus.isOpen) {
187
- parentId = focus.id;
188
- index = 0;
189
- } else {
190
- index = focus.childIndex + 1;
191
- parentId = focus.parent.isRoot ? null : focus.parent.id;
192
- }
193
- } else {
194
- index = this.root?.children?.length || -1;
195
- parentId = null;
196
- }
183
+ return this.create({ type: "leaf" });
184
+ }
185
+
186
+ async create(
187
+ opts: {
188
+ type?: "internal" | "leaf";
189
+ parentId?: null | string;
190
+ index?: null | number;
191
+ } = {}
192
+ ) {
197
193
  const data = await safeRun(this.props.onCreate, {
198
- parentId,
199
- index,
200
- type,
194
+ type: opts.type ?? "leaf",
195
+ parentId:
196
+ opts.parentId === undefined
197
+ ? utils.getInsertParentId(this)
198
+ : opts.parentId,
199
+ index: opts.index ?? utils.getInsertIndex(this),
201
200
  });
202
201
  if (data) {
203
202
  this.focus(data);
@@ -279,6 +278,7 @@ export class TreeApi<T extends IdObj> {
279
278
  } else {
280
279
  this.dispatch(focus(identify(node)));
281
280
  if (opts.scroll !== false) this.scrollTo(node);
281
+ if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
282
282
  }
283
283
  }
284
284
 
@@ -316,6 +316,7 @@ export class TreeApi<T extends IdObj> {
316
316
  this.dispatch(selection.anchor(id));
317
317
  this.dispatch(selection.mostRecent(id));
318
318
  this.scrollTo(id, opts.align);
319
+ if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
319
320
  safeRun(this.props.onSelect, this.selectedNodes);
320
321
  }
321
322
 
@@ -333,6 +334,7 @@ export class TreeApi<T extends IdObj> {
333
334
  this.dispatch(selection.anchor(node.id));
334
335
  this.dispatch(selection.mostRecent(node.id));
335
336
  this.scrollTo(node);
337
+ if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
336
338
  safeRun(this.props.onSelect, this.selectedNodes);
337
339
  }
338
340
 
@@ -345,10 +347,11 @@ export class TreeApi<T extends IdObj> {
345
347
  this.dispatch(selection.add(this.nodesBetween(anchor, identifyNull(id))));
346
348
  this.dispatch(selection.mostRecent(id));
347
349
  this.scrollTo(id);
350
+ if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
348
351
  safeRun(this.props.onSelect, this.selectedNodes);
349
352
  }
350
353
 
351
- selectNone() {
354
+ deselectAll() {
352
355
  this.dispatch(selection.clear());
353
356
  this.dispatch(selection.anchor(null));
354
357
  this.dispatch(selection.mostRecent(null));
@@ -360,6 +363,7 @@ export class TreeApi<T extends IdObj> {
360
363
  this.dispatch(focus(this.lastNode?.id));
361
364
  this.dispatch(selection.anchor(this.firstNode));
362
365
  this.dispatch(selection.mostRecent(this.lastNode));
366
+ if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
363
367
  safeRun(this.props.onSelect, this.selectedNodes);
364
368
  }
365
369
 
@@ -392,13 +396,17 @@ export class TreeApi<T extends IdObj> {
392
396
  open(identity: Identity) {
393
397
  const id = identifyNull(identity);
394
398
  if (!id) return;
399
+ if (this.isOpen(id)) return;
395
400
  this.dispatch(visibility.open(id, this.isFiltered));
401
+ safeRun(this.props.onToggle, id);
396
402
  }
397
403
 
398
404
  close(identity: Identity) {
399
405
  const id = identifyNull(identity);
400
406
  if (!id) return;
407
+ if (!this.isOpen(id)) return;
401
408
  this.dispatch(visibility.close(id, this.isFiltered));
409
+ safeRun(this.props.onToggle, id);
402
410
  }
403
411
 
404
412
  toggle(identity: Identity) {
@@ -434,6 +442,18 @@ export class TreeApi<T extends IdObj> {
434
442
  }
435
443
  }
436
444
 
445
+ openAll() {
446
+ utils.walk(this.root, (node) => {
447
+ if (node.isInternal) node.open();
448
+ });
449
+ }
450
+
451
+ closeAll() {
452
+ utils.walk(this.root, (node) => {
453
+ if (node.isInternal) node.close();
454
+ });
455
+ }
456
+
437
457
  /* Scrolling */
438
458
 
439
459
  scrollTo(identity: Identity, align: Align = "smart") {
@@ -466,6 +486,18 @@ export class TreeApi<T extends IdObj> {
466
486
  return this.state.nodes.focus.treeFocused;
467
487
  }
468
488
 
489
+ get hasNoSelection() {
490
+ return this.state.nodes.selection.ids.size === 0;
491
+ }
492
+
493
+ get hasOneSelection() {
494
+ return this.state.nodes.selection.ids.size === 1;
495
+ }
496
+
497
+ get hasMultipleSelections() {
498
+ return this.state.nodes.selection.ids.size > 1;
499
+ }
500
+
469
501
  isSelected(id?: string) {
470
502
  if (!id) return false;
471
503
  return this.state.nodes.selection.ids.has(id);
@@ -27,7 +27,7 @@ export type DragPreviewProps = {
27
27
  isDragging: boolean;
28
28
  };
29
29
 
30
- export type DropCursorProps = {
30
+ export type CursorProps = {
31
31
  top: number;
32
32
  left: number;
33
33
  indent: number;