react-arborist 3.4.3 → 3.6.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 CHANGED
@@ -268,6 +268,8 @@ const { ref, width, height } = useResizeObserver();
268
268
  - Interfaces
269
269
  - [Node API](#node-api-reference)
270
270
  - [Tree API](#tree-api-reference)
271
+ - Utilities
272
+ - [getTreeLinePrefix](#gettreelineprefix)
271
273
 
272
274
  ## Tree Component Props
273
275
 
@@ -303,11 +305,12 @@ interface TreeProps<T> {
303
305
  padding?: number;
304
306
 
305
307
  /* Config */
306
- childrenAccessor?: string | ((d: T) => T[] | null);
308
+ childrenAccessor?: string | ((d: T) => readonly T[] | null);
307
309
  idAccessor?: string | ((d: T) => string);
308
310
  openByDefault?: boolean;
309
311
  selectionFollowsFocus?: boolean;
310
312
  disableMultiSelection?: boolean;
313
+ disableSelect?: string | boolean | BoolFunc<T>;
311
314
  disableEdit?: string | boolean | BoolFunc<T>;
312
315
  disableDrag?: string | boolean | BoolFunc<T>;
313
316
  disableDrop?:
@@ -343,7 +346,11 @@ interface TreeProps<T> {
343
346
  dndRootElement?: globalThis.Node | null;
344
347
  onClick?: MouseEventHandler;
345
348
  onContextMenu?: MouseEventHandler;
346
- dndManager?: DragDropManager;
349
+ dndBackend?: Extract<
350
+ DndProviderProps<unknown, unknown>,
351
+ { backend: unknown }
352
+ >["backend"];
353
+ dndManager?: ReturnType<typeof useDragDropManager>;
347
354
  }
348
355
  ```
349
356
 
@@ -648,17 +655,17 @@ _tree_.**isSelected**(_id_) : _boolean_
648
655
 
649
656
  Returns true if the node with _id_ is selected.
650
657
 
651
- _tree_.**select**(_id_)
658
+ _tree_.**select**(_id_, _[opts]_)
652
659
 
653
- Select only the node with _id_.
660
+ Select only the node with _id_. Accepts an optional options object: `{ align?: "auto" | "smart" | "center" | "end" | "start"; focus?: boolean }`. `align` is forwarded to the scroll behavior; passing `focus: false` suppresses the focus change and the `onFocus` callback.
654
661
 
655
662
  _tree_.**deselect**(_id_)
656
663
 
657
664
  Deselect the node with _id_.
658
665
 
659
- _tree_.**selectMulti**(_id_)
666
+ _tree_.**selectMulti**(_id_, _[opts]_)
660
667
 
661
- Add to the selection the node with _id_.
668
+ Add to the selection the node with _id_. Accepts the same options object as `select`.
662
669
 
663
670
  _tree_.**selectContiguous**(_id_)
664
671
 
@@ -740,6 +747,43 @@ _tree_.**root** : _NodeApi_
740
747
 
741
748
  Returns the root _NodeApi_ instance. Its children are the Node representations of the _data_ prop array.
742
749
 
750
+ ## Utilities
751
+
752
+ ### getTreeLinePrefix
753
+
754
+ Generates a tree-line prefix string (using Unix `tree`-style box-drawing characters like `├`, `└`, `│`) for a given node. Useful when you want ASCII/Unicode connector lines in a custom node renderer.
755
+
756
+ ```ts
757
+ function getTreeLinePrefix(
758
+ node: NodeApi<any>,
759
+ chars?: Partial<TreeLineChars>
760
+ ): string;
761
+
762
+ type TreeLineChars = {
763
+ last: string; // default: "└ "
764
+ middle: string; // default: "├ "
765
+ pipe: string; // default: "│ "
766
+ blank: string; // default: "\u3000 "
767
+ };
768
+ ```
769
+
770
+ Wrap the prefix in a monospace span so the connectors line up:
771
+
772
+ ```tsx
773
+ import { Tree, getTreeLinePrefix } from "react-arborist";
774
+
775
+ function Node({ node, style }) {
776
+ return (
777
+ <div style={style}>
778
+ <span style={{ fontFamily: "monospace" }}>{getTreeLinePrefix(node)}</span>
779
+ {node.data.name}
780
+ </div>
781
+ );
782
+ }
783
+ ```
784
+
785
+ Pass a partial `chars` object to override any of the default characters (e.g. for an ASCII-only style).
786
+
743
787
  ## Author
744
788
 
745
789
  [James Kerr](https://twitter.com/specialCaseDev) at [Brim Data](https://brimdata.io) for the [Zui desktop app](https://www.youtube.com/watch?v=I2y663n8d2A).
@@ -47,5 +47,12 @@ function TreeProvider({ treeProps, imperativeHandle, children, }) {
47
47
  store.current.dispatch(open_slice_1.actions.clear(true));
48
48
  }
49
49
  }, [api.props.searchTerm]);
50
- return ((0, jsx_runtime_1.jsx)(context_1.TreeApiContext.Provider, { value: api, children: (0, jsx_runtime_1.jsx)(context_1.DataUpdatesContext.Provider, { value: updateCount.current, children: (0, jsx_runtime_1.jsx)(context_1.NodesContext.Provider, { value: state.nodes, children: (0, jsx_runtime_1.jsx)(context_1.DndContext.Provider, { value: state.dnd, children: (0, jsx_runtime_1.jsx)(react_dnd_1.DndProvider, Object.assign({ backend: react_dnd_html5_backend_1.HTML5Backend, options: { rootElement: api.props.dndRootElement || undefined } }, (treeProps.dndManager && { manager: treeProps.dndManager }), { children: children })) }) }) }) }));
50
+ return ((0, jsx_runtime_1.jsx)(context_1.TreeApiContext.Provider, { value: api, children: (0, jsx_runtime_1.jsx)(context_1.DataUpdatesContext.Provider, { value: updateCount.current, children: (0, jsx_runtime_1.jsx)(context_1.NodesContext.Provider, { value: state.nodes, children: (0, jsx_runtime_1.jsx)(context_1.DndContext.Provider, { value: state.dnd, children: (0, jsx_runtime_1.jsx)(react_dnd_1.DndProvider, Object.assign({}, (treeProps.dndManager
51
+ ? { manager: treeProps.dndManager }
52
+ : {
53
+ backend: treeProps.dndBackend || react_dnd_html5_backend_1.HTML5Backend,
54
+ options: {
55
+ rootElement: api.props.dndRootElement || undefined,
56
+ },
57
+ }), { children: children })) }) }) }) }));
51
58
  }
@@ -6,3 +6,5 @@ export * from "./interfaces/node-api";
6
6
  export * from "./interfaces/tree-api";
7
7
  export * from "./data/simple-tree";
8
8
  export * from "./hooks/use-simple-tree";
9
+ export { getTreeLinePrefix } from "./utils";
10
+ export type { TreeLineChars } from "./utils";
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.Tree = void 0;
17
+ exports.getTreeLinePrefix = exports.Tree = void 0;
18
18
  /* The Public Api */
19
19
  var tree_1 = require("./components/tree");
20
20
  Object.defineProperty(exports, "Tree", { enumerable: true, get: function () { return tree_1.Tree; } });
@@ -25,3 +25,5 @@ __exportStar(require("./interfaces/node-api"), exports);
25
25
  __exportStar(require("./interfaces/tree-api"), exports);
26
26
  __exportStar(require("./data/simple-tree"), exports);
27
27
  __exportStar(require("./hooks/use-simple-tree"), exports);
28
+ var utils_1 = require("./utils");
29
+ Object.defineProperty(exports, "getTreeLinePrefix", { enumerable: true, get: function () { return utils_1.getTreeLinePrefix; } });
@@ -26,6 +26,7 @@ export declare class NodeApi<T = any> {
26
26
  get isOpen(): boolean;
27
27
  get isClosed(): boolean;
28
28
  get isEditable(): boolean;
29
+ get isSelectable(): boolean;
29
30
  get isEditing(): boolean;
30
31
  get isSelected(): boolean;
31
32
  get isOnlySelection(): boolean;
@@ -43,6 +43,9 @@ class NodeApi {
43
43
  get isEditable() {
44
44
  return this.tree.isEditable(this.data);
45
45
  }
46
+ get isSelectable() {
47
+ return this.tree.isSelectable(this.data);
48
+ }
46
49
  get isEditing() {
47
50
  return this.tree.editingId === this.id;
48
51
  }
@@ -136,7 +136,7 @@ export declare class TreeApi<T> {
136
136
  get(id: string | null): NodeApi<T> | null;
137
137
  at(index: number): NodeApi<T> | null;
138
138
  nodesBetween(startId: string | null, endId: string | null): NodeApi<T>[];
139
- indexOf(id: string | null | IdObj): number | null;
139
+ indexOf(id: Identity): number | null;
140
140
  get editingId(): string | null;
141
141
  createInternal(): Promise<void>;
142
142
  createLeaf(): Promise<void>;
@@ -145,11 +145,11 @@ export declare class TreeApi<T> {
145
145
  parentId?: null | string;
146
146
  index?: null | number;
147
147
  }): Promise<void>;
148
- delete(node: string | IdObj | null | string[] | IdObj[]): Promise<void>;
148
+ delete(node: Identity | string[] | IdObj[]): Promise<void>;
149
149
  edit(node: string | IdObj): Promise<EditResult>;
150
150
  submit(identity: Identity, value: string): Promise<void>;
151
151
  reset(): void;
152
- activate(id: string | IdObj | null): void;
152
+ activate(id: Identity): void;
153
153
  private resolveEdit;
154
154
  get selectedIds(): Set<string>;
155
155
  get selectedNodes(): NodeApi<T>[];
@@ -163,14 +163,18 @@ export declare class TreeApi<T> {
163
163
  focus?: boolean;
164
164
  }): void;
165
165
  deselect(node: Identity): void;
166
- selectMulti(identity: Identity): void;
166
+ selectMulti(identity: Identity, opts?: {
167
+ align?: Align;
168
+ focus?: boolean;
169
+ }): void;
167
170
  selectContiguous(identity: Identity): void;
168
171
  deselectAll(): void;
169
172
  selectAll(): void;
173
+ private filterSelectableNodes;
170
174
  setSelection(args: {
171
175
  ids: (IdObj | string)[] | null;
172
- anchor: IdObj | string | null;
173
- mostRecent: IdObj | string | null;
176
+ anchor: Identity;
177
+ mostRecent: Identity;
174
178
  }): void;
175
179
  get cursorParentId(): string | null;
176
180
  get cursorOverFolder(): boolean;
@@ -199,10 +203,12 @@ export declare class TreeApi<T> {
199
203
  isOpen(id?: string): boolean;
200
204
  isEditable(data: T): boolean;
201
205
  isDraggable(data: T): boolean;
202
- isDragging(node: string | IdObj | null): boolean;
206
+ isSelectable(data: T): boolean;
207
+ private isActionPossible;
208
+ isDragging(node: Identity): boolean;
203
209
  isFocused(id: string): boolean;
204
210
  isMatch(node: NodeApi<T>): boolean;
205
- willReceiveDrop(node: string | IdObj | null): boolean;
211
+ willReceiveDrop(node: Identity): boolean;
206
212
  onFocus(): void;
207
213
  onBlur(): void;
208
214
  onItemsRendered(args: ListOnItemsRenderedProps): void;
@@ -327,20 +327,24 @@ class TreeApi {
327
327
  this.focus(this.at(index));
328
328
  }
329
329
  select(node, opts = {}) {
330
+ var _a;
330
331
  if (!node)
331
332
  return;
332
333
  const changeFocus = opts.focus !== false;
333
334
  const id = identify(node);
334
335
  if (changeFocus)
335
336
  this.dispatch((0, focus_slice_1.focus)(id));
336
- this.dispatch(selection_slice_1.actions.only(id));
337
- this.dispatch(selection_slice_1.actions.anchor(id));
338
- this.dispatch(selection_slice_1.actions.mostRecent(id));
337
+ if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
338
+ this.setSelection({
339
+ ids: [id],
340
+ anchor: id,
341
+ mostRecent: id,
342
+ });
343
+ }
339
344
  this.scrollTo(id, opts.align);
340
345
  if (this.focusedNode && changeFocus) {
341
346
  safeRun(this.props.onFocus, this.focusedNode);
342
347
  }
343
- safeRun(this.props.onSelect, this.selectedNodes);
344
348
  }
345
349
  deselect(node) {
346
350
  if (!node)
@@ -349,28 +353,37 @@ class TreeApi {
349
353
  this.dispatch(selection_slice_1.actions.remove(id));
350
354
  safeRun(this.props.onSelect, this.selectedNodes);
351
355
  }
352
- selectMulti(identity) {
356
+ selectMulti(identity, opts = {}) {
353
357
  const node = this.get(identifyNull(identity));
354
358
  if (!node)
355
359
  return;
356
- this.dispatch((0, focus_slice_1.focus)(node.id));
357
- this.dispatch(selection_slice_1.actions.add(node.id));
358
- this.dispatch(selection_slice_1.actions.anchor(node.id));
359
- this.dispatch(selection_slice_1.actions.mostRecent(node.id));
360
- this.scrollTo(node);
361
- if (this.focusedNode)
360
+ const changeFocus = opts.focus !== false;
361
+ if (changeFocus)
362
+ this.dispatch((0, focus_slice_1.focus)(node.id));
363
+ if (node.isSelectable) {
364
+ this.dispatch(selection_slice_1.actions.add(node.id));
365
+ this.dispatch(selection_slice_1.actions.anchor(node.id));
366
+ this.dispatch(selection_slice_1.actions.mostRecent(node.id));
367
+ }
368
+ this.scrollTo(node, opts.align);
369
+ if (this.focusedNode && changeFocus) {
362
370
  safeRun(this.props.onFocus, this.focusedNode);
371
+ }
363
372
  safeRun(this.props.onSelect, this.selectedNodes);
364
373
  }
365
374
  selectContiguous(identity) {
375
+ var _a;
366
376
  if (!identity)
367
377
  return;
368
378
  const id = identify(identity);
369
- const { anchor, mostRecent } = this.state.nodes.selection;
370
379
  this.dispatch((0, focus_slice_1.focus)(id));
371
- this.dispatch(selection_slice_1.actions.remove(this.nodesBetween(anchor, mostRecent)));
372
- this.dispatch(selection_slice_1.actions.add(this.nodesBetween(anchor, identifyNull(id))));
373
- this.dispatch(selection_slice_1.actions.mostRecent(id));
380
+ if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
381
+ const { anchor, mostRecent } = this.state.nodes.selection;
382
+ const selectableNodes = this.filterSelectableNodes(this.nodesBetween(anchor, identifyNull(id)));
383
+ this.dispatch(selection_slice_1.actions.remove(this.nodesBetween(anchor, mostRecent)));
384
+ this.dispatch(selection_slice_1.actions.add(selectableNodes));
385
+ this.dispatch(selection_slice_1.actions.mostRecent(id));
386
+ }
374
387
  this.scrollTo(id);
375
388
  if (this.focusedNode)
376
389
  safeRun(this.props.onFocus, this.focusedNode);
@@ -381,17 +394,23 @@ class TreeApi {
381
394
  safeRun(this.props.onSelect, this.selectedNodes);
382
395
  }
383
396
  selectAll() {
384
- var _a;
397
+ var _a, _b, _c;
398
+ const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
385
399
  this.setSelection({
386
- ids: Object.keys(this.idToIndex),
387
- anchor: this.firstNode,
388
- mostRecent: this.lastNode,
400
+ ids: allSelectableNodes,
401
+ anchor: (_a = allSelectableNodes[0]) !== null && _a !== void 0 ? _a : null,
402
+ mostRecent: (_b = allSelectableNodes[allSelectableNodes.length - 1]) !== null && _b !== void 0 ? _b : null,
389
403
  });
390
- this.dispatch((0, focus_slice_1.focus)((_a = this.lastNode) === null || _a === void 0 ? void 0 : _a.id));
404
+ this.dispatch((0, focus_slice_1.focus)((_c = this.lastNode) === null || _c === void 0 ? void 0 : _c.id));
391
405
  if (this.focusedNode)
392
406
  safeRun(this.props.onFocus, this.focusedNode);
393
407
  safeRun(this.props.onSelect, this.selectedNodes);
394
408
  }
409
+ filterSelectableNodes(nodes) {
410
+ return nodes
411
+ .map((n) => this.get(identify(n)))
412
+ .filter((n) => !!n && n.isSelectable);
413
+ }
395
414
  setSelection(args) {
396
415
  var _a;
397
416
  const ids = new Set((_a = args.ids) === null || _a === void 0 ? void 0 : _a.map(identify));
@@ -589,12 +608,16 @@ class TreeApi {
589
608
  }
590
609
  }
591
610
  isEditable(data) {
592
- const check = this.props.disableEdit || (() => false);
593
- return !utils.access(data, check);
611
+ return this.isActionPossible(data, this.props.disableEdit);
594
612
  }
595
613
  isDraggable(data) {
596
- const check = this.props.disableDrag || (() => false);
597
- return !utils.access(data, check);
614
+ return this.isActionPossible(data, this.props.disableDrag);
615
+ }
616
+ isSelectable(data) {
617
+ return this.isActionPossible(data, this.props.disableSelect);
618
+ }
619
+ isActionPossible(data, disabler = () => false) {
620
+ return !utils.access(data, disabler);
598
621
  }
599
622
  isDragging(node) {
600
623
  const id = identifyNull(node);
@@ -5,7 +5,7 @@ import { ElementType, MouseEventHandler } from "react";
5
5
  import { ListOnScrollProps } from "react-window";
6
6
  import { NodeApi } from "../interfaces/node-api";
7
7
  import { OpenMap } from "../state/open-slice";
8
- import { useDragDropManager } from "react-dnd";
8
+ import { useDragDropManager, DndProviderProps } from "react-dnd";
9
9
  export interface TreeProps<T> {
10
10
  data?: readonly T[];
11
11
  initialData?: readonly T[];
@@ -31,6 +31,7 @@ export interface TreeProps<T> {
31
31
  openByDefault?: boolean;
32
32
  selectionFollowsFocus?: boolean;
33
33
  disableMultiSelection?: boolean;
34
+ disableSelect?: string | boolean | BoolFunc<T>;
34
35
  disableEdit?: string | boolean | BoolFunc<T>;
35
36
  disableDrag?: string | boolean | BoolFunc<T>;
36
37
  disableDrop?: string | boolean | ((args: {
@@ -52,5 +53,8 @@ export interface TreeProps<T> {
52
53
  dndRootElement?: globalThis.Node | null;
53
54
  onClick?: MouseEventHandler;
54
55
  onContextMenu?: MouseEventHandler;
56
+ dndBackend?: Extract<DndProviderProps<unknown, unknown>, {
57
+ backend: unknown;
58
+ }>["backend"];
55
59
  dndManager?: ReturnType<typeof useDragDropManager>;
56
60
  }
@@ -22,4 +22,58 @@ export declare function mergeRefs(...refs: any): (instance: any) => void;
22
22
  export declare function safeRun<T extends (...args: any[]) => any>(fn: T | undefined, ...args: Parameters<T>): any;
23
23
  export declare function waitFor(fn: () => boolean): Promise<void>;
24
24
  export declare function getInsertIndex(tree: TreeApi<any>): number;
25
+ export type TreeLineChars = {
26
+ last: string;
27
+ middle: string;
28
+ pipe: string;
29
+ blank: string;
30
+ };
31
+ /**
32
+ * Generate a tree-line prefix string for a node.
33
+ *
34
+ * Returns characters like `├ `, `└ `, `│` that visually connect
35
+ * parent and child nodes, similar to the Unix `tree` command.
36
+ *
37
+ * **Styling note:** The prefix uses Box Drawing characters (`│`, `├`, `└`)
38
+ * which require a monospace font for correct alignment. Wrap the prefix
39
+ * in a `<span>` with `fontFamily: "monospace"` and use a consistent
40
+ * `fontSize` (e.g. 14–16px). Inherited `line-height` or `font-size`
41
+ * from parent elements can cause misalignment.
42
+ *
43
+ * @example Basic usage
44
+ * ```tsx
45
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
46
+ * return (
47
+ * <div style={style}>
48
+ * <span style={{ fontFamily: "monospace", fontSize: 14 }}>
49
+ * {getTreeLinePrefix(node)}
50
+ * </span>
51
+ * {node.data.name}
52
+ * </div>
53
+ * );
54
+ * }
55
+ * ```
56
+ *
57
+ * @example With folder/file icons
58
+ * ```tsx
59
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
60
+ * const icon = node.isLeaf ? "📄" : node.isOpen ? "📂" : "📁";
61
+ * return (
62
+ * <div style={style}>
63
+ * <span style={{ fontFamily: "monospace", fontSize: 16 }}>
64
+ * {getTreeLinePrefix(node)}
65
+ * </span>
66
+ * {icon} {node.data.name}
67
+ * </div>
68
+ * );
69
+ * }
70
+ * ```
71
+ *
72
+ * @example Custom characters
73
+ * ```tsx
74
+ * // ASCII-only style
75
+ * getTreeLinePrefix(node, { last: "`- ", middle: "|- ", pipe: "|", blank: " " })
76
+ * ```
77
+ */
78
+ export declare function getTreeLinePrefix(node: NodeApi<any>, chars?: Partial<TreeLineChars>): string;
25
79
  export declare function getInsertParentId(tree: TreeApi<any>): string | null;
@@ -17,6 +17,7 @@ exports.mergeRefs = mergeRefs;
17
17
  exports.safeRun = safeRun;
18
18
  exports.waitFor = waitFor;
19
19
  exports.getInsertIndex = getInsertIndex;
20
+ exports.getTreeLinePrefix = getTreeLinePrefix;
20
21
  exports.getInsertParentId = getInsertParentId;
21
22
  function bound(n, min, max) {
22
23
  return Math.max(Math.min(n, max), min);
@@ -176,6 +177,73 @@ function getInsertIndex(tree) {
176
177
  return focus.childIndex + 1;
177
178
  return 0;
178
179
  }
180
+ const defaultTreeLineChars = {
181
+ last: "└ ",
182
+ middle: "├ ",
183
+ pipe: "│ ",
184
+ blank: "\u3000 ",
185
+ };
186
+ /**
187
+ * Generate a tree-line prefix string for a node.
188
+ *
189
+ * Returns characters like `├ `, `└ `, `│` that visually connect
190
+ * parent and child nodes, similar to the Unix `tree` command.
191
+ *
192
+ * **Styling note:** The prefix uses Box Drawing characters (`│`, `├`, `└`)
193
+ * which require a monospace font for correct alignment. Wrap the prefix
194
+ * in a `<span>` with `fontFamily: "monospace"` and use a consistent
195
+ * `fontSize` (e.g. 14–16px). Inherited `line-height` or `font-size`
196
+ * from parent elements can cause misalignment.
197
+ *
198
+ * @example Basic usage
199
+ * ```tsx
200
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
201
+ * return (
202
+ * <div style={style}>
203
+ * <span style={{ fontFamily: "monospace", fontSize: 14 }}>
204
+ * {getTreeLinePrefix(node)}
205
+ * </span>
206
+ * {node.data.name}
207
+ * </div>
208
+ * );
209
+ * }
210
+ * ```
211
+ *
212
+ * @example With folder/file icons
213
+ * ```tsx
214
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
215
+ * const icon = node.isLeaf ? "📄" : node.isOpen ? "📂" : "📁";
216
+ * return (
217
+ * <div style={style}>
218
+ * <span style={{ fontFamily: "monospace", fontSize: 16 }}>
219
+ * {getTreeLinePrefix(node)}
220
+ * </span>
221
+ * {icon} {node.data.name}
222
+ * </div>
223
+ * );
224
+ * }
225
+ * ```
226
+ *
227
+ * @example Custom characters
228
+ * ```tsx
229
+ * // ASCII-only style
230
+ * getTreeLinePrefix(node, { last: "`- ", middle: "|- ", pipe: "|", blank: " " })
231
+ * ```
232
+ */
233
+ function getTreeLinePrefix(node, chars = {}) {
234
+ const c = Object.assign(Object.assign({}, defaultTreeLineChars), chars);
235
+ if (node.level === 0)
236
+ return "";
237
+ const isLast = node.nextSibling === null;
238
+ let prefix = isLast ? c.last : c.middle;
239
+ let ancestor = node.parent;
240
+ while (ancestor && ancestor.level > 0) {
241
+ const isAncestorLast = ancestor.nextSibling === null;
242
+ prefix = (isAncestorLast ? c.blank : c.pipe) + prefix;
243
+ ancestor = ancestor.parent;
244
+ }
245
+ return prefix;
246
+ }
179
247
  function getInsertParentId(tree) {
180
248
  const focus = tree.focusedNode;
181
249
  if (!focus)
@@ -44,5 +44,12 @@ export function TreeProvider({ treeProps, imperativeHandle, children, }) {
44
44
  store.current.dispatch(visibility.clear(true));
45
45
  }
46
46
  }, [api.props.searchTerm]);
47
- return (_jsx(TreeApiContext.Provider, { value: api, children: _jsx(DataUpdatesContext.Provider, { value: updateCount.current, children: _jsx(NodesContext.Provider, { value: state.nodes, children: _jsx(DndContext.Provider, { value: state.dnd, children: _jsx(DndProvider, Object.assign({ backend: HTML5Backend, options: { rootElement: api.props.dndRootElement || undefined } }, (treeProps.dndManager && { manager: treeProps.dndManager }), { children: children })) }) }) }) }));
47
+ return (_jsx(TreeApiContext.Provider, { value: api, children: _jsx(DataUpdatesContext.Provider, { value: updateCount.current, children: _jsx(NodesContext.Provider, { value: state.nodes, children: _jsx(DndContext.Provider, { value: state.dnd, children: _jsx(DndProvider, Object.assign({}, (treeProps.dndManager
48
+ ? { manager: treeProps.dndManager }
49
+ : {
50
+ backend: treeProps.dndBackend || HTML5Backend,
51
+ options: {
52
+ rootElement: api.props.dndRootElement || undefined,
53
+ },
54
+ }), { children: children })) }) }) }) }));
48
55
  }
@@ -6,3 +6,5 @@ export * from "./interfaces/node-api";
6
6
  export * from "./interfaces/tree-api";
7
7
  export * from "./data/simple-tree";
8
8
  export * from "./hooks/use-simple-tree";
9
+ export { getTreeLinePrefix } from "./utils";
10
+ export type { TreeLineChars } from "./utils";
@@ -7,3 +7,4 @@ export * from "./interfaces/node-api";
7
7
  export * from "./interfaces/tree-api";
8
8
  export * from "./data/simple-tree";
9
9
  export * from "./hooks/use-simple-tree";
10
+ export { getTreeLinePrefix } from "./utils";
@@ -26,6 +26,7 @@ export declare class NodeApi<T = any> {
26
26
  get isOpen(): boolean;
27
27
  get isClosed(): boolean;
28
28
  get isEditable(): boolean;
29
+ get isSelectable(): boolean;
29
30
  get isEditing(): boolean;
30
31
  get isSelected(): boolean;
31
32
  get isOnlySelection(): boolean;
@@ -40,6 +40,9 @@ export class NodeApi {
40
40
  get isEditable() {
41
41
  return this.tree.isEditable(this.data);
42
42
  }
43
+ get isSelectable() {
44
+ return this.tree.isSelectable(this.data);
45
+ }
43
46
  get isEditing() {
44
47
  return this.tree.editingId === this.id;
45
48
  }
@@ -136,7 +136,7 @@ export declare class TreeApi<T> {
136
136
  get(id: string | null): NodeApi<T> | null;
137
137
  at(index: number): NodeApi<T> | null;
138
138
  nodesBetween(startId: string | null, endId: string | null): NodeApi<T>[];
139
- indexOf(id: string | null | IdObj): number | null;
139
+ indexOf(id: Identity): number | null;
140
140
  get editingId(): string | null;
141
141
  createInternal(): Promise<void>;
142
142
  createLeaf(): Promise<void>;
@@ -145,11 +145,11 @@ export declare class TreeApi<T> {
145
145
  parentId?: null | string;
146
146
  index?: null | number;
147
147
  }): Promise<void>;
148
- delete(node: string | IdObj | null | string[] | IdObj[]): Promise<void>;
148
+ delete(node: Identity | string[] | IdObj[]): Promise<void>;
149
149
  edit(node: string | IdObj): Promise<EditResult>;
150
150
  submit(identity: Identity, value: string): Promise<void>;
151
151
  reset(): void;
152
- activate(id: string | IdObj | null): void;
152
+ activate(id: Identity): void;
153
153
  private resolveEdit;
154
154
  get selectedIds(): Set<string>;
155
155
  get selectedNodes(): NodeApi<T>[];
@@ -163,14 +163,18 @@ export declare class TreeApi<T> {
163
163
  focus?: boolean;
164
164
  }): void;
165
165
  deselect(node: Identity): void;
166
- selectMulti(identity: Identity): void;
166
+ selectMulti(identity: Identity, opts?: {
167
+ align?: Align;
168
+ focus?: boolean;
169
+ }): void;
167
170
  selectContiguous(identity: Identity): void;
168
171
  deselectAll(): void;
169
172
  selectAll(): void;
173
+ private filterSelectableNodes;
170
174
  setSelection(args: {
171
175
  ids: (IdObj | string)[] | null;
172
- anchor: IdObj | string | null;
173
- mostRecent: IdObj | string | null;
176
+ anchor: Identity;
177
+ mostRecent: Identity;
174
178
  }): void;
175
179
  get cursorParentId(): string | null;
176
180
  get cursorOverFolder(): boolean;
@@ -199,10 +203,12 @@ export declare class TreeApi<T> {
199
203
  isOpen(id?: string): boolean;
200
204
  isEditable(data: T): boolean;
201
205
  isDraggable(data: T): boolean;
202
- isDragging(node: string | IdObj | null): boolean;
206
+ isSelectable(data: T): boolean;
207
+ private isActionPossible;
208
+ isDragging(node: Identity): boolean;
203
209
  isFocused(id: string): boolean;
204
210
  isMatch(node: NodeApi<T>): boolean;
205
- willReceiveDrop(node: string | IdObj | null): boolean;
211
+ willReceiveDrop(node: Identity): boolean;
206
212
  onFocus(): void;
207
213
  onBlur(): void;
208
214
  onItemsRendered(args: ListOnItemsRenderedProps): void;
@@ -301,20 +301,24 @@ export class TreeApi {
301
301
  this.focus(this.at(index));
302
302
  }
303
303
  select(node, opts = {}) {
304
+ var _a;
304
305
  if (!node)
305
306
  return;
306
307
  const changeFocus = opts.focus !== false;
307
308
  const id = identify(node);
308
309
  if (changeFocus)
309
310
  this.dispatch(focus(id));
310
- this.dispatch(selection.only(id));
311
- this.dispatch(selection.anchor(id));
312
- this.dispatch(selection.mostRecent(id));
311
+ if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
312
+ this.setSelection({
313
+ ids: [id],
314
+ anchor: id,
315
+ mostRecent: id,
316
+ });
317
+ }
313
318
  this.scrollTo(id, opts.align);
314
319
  if (this.focusedNode && changeFocus) {
315
320
  safeRun(this.props.onFocus, this.focusedNode);
316
321
  }
317
- safeRun(this.props.onSelect, this.selectedNodes);
318
322
  }
319
323
  deselect(node) {
320
324
  if (!node)
@@ -323,28 +327,37 @@ export class TreeApi {
323
327
  this.dispatch(selection.remove(id));
324
328
  safeRun(this.props.onSelect, this.selectedNodes);
325
329
  }
326
- selectMulti(identity) {
330
+ selectMulti(identity, opts = {}) {
327
331
  const node = this.get(identifyNull(identity));
328
332
  if (!node)
329
333
  return;
330
- this.dispatch(focus(node.id));
331
- this.dispatch(selection.add(node.id));
332
- this.dispatch(selection.anchor(node.id));
333
- this.dispatch(selection.mostRecent(node.id));
334
- this.scrollTo(node);
335
- if (this.focusedNode)
334
+ const changeFocus = opts.focus !== false;
335
+ if (changeFocus)
336
+ this.dispatch(focus(node.id));
337
+ if (node.isSelectable) {
338
+ this.dispatch(selection.add(node.id));
339
+ this.dispatch(selection.anchor(node.id));
340
+ this.dispatch(selection.mostRecent(node.id));
341
+ }
342
+ this.scrollTo(node, opts.align);
343
+ if (this.focusedNode && changeFocus) {
336
344
  safeRun(this.props.onFocus, this.focusedNode);
345
+ }
337
346
  safeRun(this.props.onSelect, this.selectedNodes);
338
347
  }
339
348
  selectContiguous(identity) {
349
+ var _a;
340
350
  if (!identity)
341
351
  return;
342
352
  const id = identify(identity);
343
- const { anchor, mostRecent } = this.state.nodes.selection;
344
353
  this.dispatch(focus(id));
345
- this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent)));
346
- this.dispatch(selection.add(this.nodesBetween(anchor, identifyNull(id))));
347
- this.dispatch(selection.mostRecent(id));
354
+ if ((_a = this.get(id)) === null || _a === void 0 ? void 0 : _a.isSelectable) {
355
+ const { anchor, mostRecent } = this.state.nodes.selection;
356
+ const selectableNodes = this.filterSelectableNodes(this.nodesBetween(anchor, identifyNull(id)));
357
+ this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent)));
358
+ this.dispatch(selection.add(selectableNodes));
359
+ this.dispatch(selection.mostRecent(id));
360
+ }
348
361
  this.scrollTo(id);
349
362
  if (this.focusedNode)
350
363
  safeRun(this.props.onFocus, this.focusedNode);
@@ -355,17 +368,23 @@ export class TreeApi {
355
368
  safeRun(this.props.onSelect, this.selectedNodes);
356
369
  }
357
370
  selectAll() {
358
- var _a;
371
+ var _a, _b, _c;
372
+ const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex));
359
373
  this.setSelection({
360
- ids: Object.keys(this.idToIndex),
361
- anchor: this.firstNode,
362
- mostRecent: this.lastNode,
374
+ ids: allSelectableNodes,
375
+ anchor: (_a = allSelectableNodes[0]) !== null && _a !== void 0 ? _a : null,
376
+ mostRecent: (_b = allSelectableNodes[allSelectableNodes.length - 1]) !== null && _b !== void 0 ? _b : null,
363
377
  });
364
- this.dispatch(focus((_a = this.lastNode) === null || _a === void 0 ? void 0 : _a.id));
378
+ this.dispatch(focus((_c = this.lastNode) === null || _c === void 0 ? void 0 : _c.id));
365
379
  if (this.focusedNode)
366
380
  safeRun(this.props.onFocus, this.focusedNode);
367
381
  safeRun(this.props.onSelect, this.selectedNodes);
368
382
  }
383
+ filterSelectableNodes(nodes) {
384
+ return nodes
385
+ .map((n) => this.get(identify(n)))
386
+ .filter((n) => !!n && n.isSelectable);
387
+ }
369
388
  setSelection(args) {
370
389
  var _a;
371
390
  const ids = new Set((_a = args.ids) === null || _a === void 0 ? void 0 : _a.map(identify));
@@ -563,12 +582,16 @@ export class TreeApi {
563
582
  }
564
583
  }
565
584
  isEditable(data) {
566
- const check = this.props.disableEdit || (() => false);
567
- return !utils.access(data, check);
585
+ return this.isActionPossible(data, this.props.disableEdit);
568
586
  }
569
587
  isDraggable(data) {
570
- const check = this.props.disableDrag || (() => false);
571
- return !utils.access(data, check);
588
+ return this.isActionPossible(data, this.props.disableDrag);
589
+ }
590
+ isSelectable(data) {
591
+ return this.isActionPossible(data, this.props.disableSelect);
592
+ }
593
+ isActionPossible(data, disabler = () => false) {
594
+ return !utils.access(data, disabler);
572
595
  }
573
596
  isDragging(node) {
574
597
  const id = identifyNull(node);
@@ -5,7 +5,7 @@ import { ElementType, MouseEventHandler } from "react";
5
5
  import { ListOnScrollProps } from "react-window";
6
6
  import { NodeApi } from "../interfaces/node-api";
7
7
  import { OpenMap } from "../state/open-slice";
8
- import { useDragDropManager } from "react-dnd";
8
+ import { useDragDropManager, DndProviderProps } from "react-dnd";
9
9
  export interface TreeProps<T> {
10
10
  data?: readonly T[];
11
11
  initialData?: readonly T[];
@@ -31,6 +31,7 @@ export interface TreeProps<T> {
31
31
  openByDefault?: boolean;
32
32
  selectionFollowsFocus?: boolean;
33
33
  disableMultiSelection?: boolean;
34
+ disableSelect?: string | boolean | BoolFunc<T>;
34
35
  disableEdit?: string | boolean | BoolFunc<T>;
35
36
  disableDrag?: string | boolean | BoolFunc<T>;
36
37
  disableDrop?: string | boolean | ((args: {
@@ -52,5 +53,8 @@ export interface TreeProps<T> {
52
53
  dndRootElement?: globalThis.Node | null;
53
54
  onClick?: MouseEventHandler;
54
55
  onContextMenu?: MouseEventHandler;
56
+ dndBackend?: Extract<DndProviderProps<unknown, unknown>, {
57
+ backend: unknown;
58
+ }>["backend"];
55
59
  dndManager?: ReturnType<typeof useDragDropManager>;
56
60
  }
@@ -22,4 +22,58 @@ export declare function mergeRefs(...refs: any): (instance: any) => void;
22
22
  export declare function safeRun<T extends (...args: any[]) => any>(fn: T | undefined, ...args: Parameters<T>): any;
23
23
  export declare function waitFor(fn: () => boolean): Promise<void>;
24
24
  export declare function getInsertIndex(tree: TreeApi<any>): number;
25
+ export type TreeLineChars = {
26
+ last: string;
27
+ middle: string;
28
+ pipe: string;
29
+ blank: string;
30
+ };
31
+ /**
32
+ * Generate a tree-line prefix string for a node.
33
+ *
34
+ * Returns characters like `├ `, `└ `, `│` that visually connect
35
+ * parent and child nodes, similar to the Unix `tree` command.
36
+ *
37
+ * **Styling note:** The prefix uses Box Drawing characters (`│`, `├`, `└`)
38
+ * which require a monospace font for correct alignment. Wrap the prefix
39
+ * in a `<span>` with `fontFamily: "monospace"` and use a consistent
40
+ * `fontSize` (e.g. 14–16px). Inherited `line-height` or `font-size`
41
+ * from parent elements can cause misalignment.
42
+ *
43
+ * @example Basic usage
44
+ * ```tsx
45
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
46
+ * return (
47
+ * <div style={style}>
48
+ * <span style={{ fontFamily: "monospace", fontSize: 14 }}>
49
+ * {getTreeLinePrefix(node)}
50
+ * </span>
51
+ * {node.data.name}
52
+ * </div>
53
+ * );
54
+ * }
55
+ * ```
56
+ *
57
+ * @example With folder/file icons
58
+ * ```tsx
59
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
60
+ * const icon = node.isLeaf ? "📄" : node.isOpen ? "📂" : "📁";
61
+ * return (
62
+ * <div style={style}>
63
+ * <span style={{ fontFamily: "monospace", fontSize: 16 }}>
64
+ * {getTreeLinePrefix(node)}
65
+ * </span>
66
+ * {icon} {node.data.name}
67
+ * </div>
68
+ * );
69
+ * }
70
+ * ```
71
+ *
72
+ * @example Custom characters
73
+ * ```tsx
74
+ * // ASCII-only style
75
+ * getTreeLinePrefix(node, { last: "`- ", middle: "|- ", pipe: "|", blank: " " })
76
+ * ```
77
+ */
78
+ export declare function getTreeLinePrefix(node: NodeApi<any>, chars?: Partial<TreeLineChars>): string;
25
79
  export declare function getInsertParentId(tree: TreeApi<any>): string | null;
@@ -154,6 +154,73 @@ export function getInsertIndex(tree) {
154
154
  return focus.childIndex + 1;
155
155
  return 0;
156
156
  }
157
+ const defaultTreeLineChars = {
158
+ last: "└ ",
159
+ middle: "├ ",
160
+ pipe: "│ ",
161
+ blank: "\u3000 ",
162
+ };
163
+ /**
164
+ * Generate a tree-line prefix string for a node.
165
+ *
166
+ * Returns characters like `├ `, `└ `, `│` that visually connect
167
+ * parent and child nodes, similar to the Unix `tree` command.
168
+ *
169
+ * **Styling note:** The prefix uses Box Drawing characters (`│`, `├`, `└`)
170
+ * which require a monospace font for correct alignment. Wrap the prefix
171
+ * in a `<span>` with `fontFamily: "monospace"` and use a consistent
172
+ * `fontSize` (e.g. 14–16px). Inherited `line-height` or `font-size`
173
+ * from parent elements can cause misalignment.
174
+ *
175
+ * @example Basic usage
176
+ * ```tsx
177
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
178
+ * return (
179
+ * <div style={style}>
180
+ * <span style={{ fontFamily: "monospace", fontSize: 14 }}>
181
+ * {getTreeLinePrefix(node)}
182
+ * </span>
183
+ * {node.data.name}
184
+ * </div>
185
+ * );
186
+ * }
187
+ * ```
188
+ *
189
+ * @example With folder/file icons
190
+ * ```tsx
191
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
192
+ * const icon = node.isLeaf ? "📄" : node.isOpen ? "📂" : "📁";
193
+ * return (
194
+ * <div style={style}>
195
+ * <span style={{ fontFamily: "monospace", fontSize: 16 }}>
196
+ * {getTreeLinePrefix(node)}
197
+ * </span>
198
+ * {icon} {node.data.name}
199
+ * </div>
200
+ * );
201
+ * }
202
+ * ```
203
+ *
204
+ * @example Custom characters
205
+ * ```tsx
206
+ * // ASCII-only style
207
+ * getTreeLinePrefix(node, { last: "`- ", middle: "|- ", pipe: "|", blank: " " })
208
+ * ```
209
+ */
210
+ export function getTreeLinePrefix(node, chars = {}) {
211
+ const c = Object.assign(Object.assign({}, defaultTreeLineChars), chars);
212
+ if (node.level === 0)
213
+ return "";
214
+ const isLast = node.nextSibling === null;
215
+ let prefix = isLast ? c.last : c.middle;
216
+ let ancestor = node.parent;
217
+ while (ancestor && ancestor.level > 0) {
218
+ const isAncestorLast = ancestor.nextSibling === null;
219
+ prefix = (isAncestorLast ? c.blank : c.pipe) + prefix;
220
+ ancestor = ancestor.parent;
221
+ }
222
+ return prefix;
223
+ }
157
224
  export function getInsertParentId(tree) {
158
225
  const focus = tree.focusedNode;
159
226
  if (!focus)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-arborist",
3
- "version": "3.4.3",
3
+ "version": "3.6.0",
4
4
  "license": "MIT",
5
5
  "source": "src/index.ts",
6
6
  "main": "dist/main/index.js",
@@ -22,10 +22,10 @@
22
22
  ],
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "https://github.com/brimdata/react-arborist.git"
25
+ "url": "https://github.com/jameskerr/react-arborist.git"
26
26
  },
27
27
  "homepage": "https://react-arborist.netlify.app",
28
- "bugs": "https://github.com/brimdata/react-arborist/issues",
28
+ "bugs": "https://github.com/jameskerr/react-arborist/issues",
29
29
  "keywords": [
30
30
  "react",
31
31
  "arborist",
@@ -59,4 +59,4 @@
59
59
  "ts-jest": "^29.1.1",
60
60
  "typescript": "^5.6.0"
61
61
  }
62
- }
62
+ }
@@ -84,9 +84,14 @@ export function TreeProvider<T>({
84
84
  <NodesContext.Provider value={state.nodes}>
85
85
  <DndContext.Provider value={state.dnd}>
86
86
  <DndProvider
87
- backend={HTML5Backend}
88
- options={{ rootElement: api.props.dndRootElement || undefined }}
89
- {...(treeProps.dndManager && { manager: treeProps.dndManager })}
87
+ {...(treeProps.dndManager
88
+ ? { manager: treeProps.dndManager }
89
+ : {
90
+ backend: treeProps.dndBackend || HTML5Backend,
91
+ options: {
92
+ rootElement: api.props.dndRootElement || undefined,
93
+ },
94
+ })}
90
95
  >
91
96
  {children}
92
97
  </DndProvider>
package/src/index.ts CHANGED
@@ -7,3 +7,5 @@ export * from "./interfaces/node-api";
7
7
  export * from "./interfaces/tree-api";
8
8
  export * from "./data/simple-tree";
9
9
  export * from "./hooks/use-simple-tree";
10
+ export { getTreeLinePrefix } from "./utils";
11
+ export type { TreeLineChars } from "./utils";
@@ -59,6 +59,10 @@ export class NodeApi<T = any> {
59
59
  return this.tree.isEditable(this.data);
60
60
  }
61
61
 
62
+ get isSelectable() {
63
+ return this.tree.isSelectable(this.data);
64
+ }
65
+
62
66
  get isEditing() {
63
67
  return this.tree.editingId === this.id;
64
68
  }
@@ -1,5 +1,5 @@
1
1
  import { EditResult } from "../types/handlers";
2
- import { Identity, IdObj } from "../types/utils";
2
+ import { BoolFunc, Identity, IdObj } from "../types/utils";
3
3
  import { TreeProps } from "../types/tree-props";
4
4
  import { MutableRefObject } from "react";
5
5
  import { Align, FixedSizeList, ListOnItemsRenderedProps } from "react-window";
@@ -169,7 +169,7 @@ export class TreeApi<T> {
169
169
  return this.visibleNodes.slice(start, end + 1);
170
170
  }
171
171
 
172
- indexOf(id: string | null | IdObj) {
172
+ indexOf(id: Identity) {
173
173
  const key = utils.identifyNull(id);
174
174
  if (!key) return null;
175
175
  return this.idToIndex[key];
@@ -219,7 +219,7 @@ export class TreeApi<T> {
219
219
  }
220
220
  }
221
221
 
222
- async delete(node: string | IdObj | null | string[] | IdObj[]) {
222
+ async delete(node: Identity | string[] | IdObj[]) {
223
223
  if (!node) return;
224
224
  const idents = Array.isArray(node) ? node : [node];
225
225
  const ids = idents.map(identify);
@@ -256,7 +256,7 @@ export class TreeApi<T> {
256
256
  setTimeout(() => this.onFocus()); // Return focus to element;
257
257
  }
258
258
 
259
- activate(id: string | IdObj | null) {
259
+ activate(id: Identity) {
260
260
  const node = this.get(identifyNull(id));
261
261
  if (!node) return;
262
262
  safeRun(this.props.onActivate, node);
@@ -328,14 +328,17 @@ export class TreeApi<T> {
328
328
  const changeFocus = opts.focus !== false;
329
329
  const id = identify(node);
330
330
  if (changeFocus) this.dispatch(focus(id));
331
- this.dispatch(selection.only(id));
332
- this.dispatch(selection.anchor(id));
333
- this.dispatch(selection.mostRecent(id));
331
+ if (this.get(id)?.isSelectable) {
332
+ this.setSelection({
333
+ ids: [id],
334
+ anchor: id,
335
+ mostRecent: id,
336
+ });
337
+ }
334
338
  this.scrollTo(id, opts.align);
335
339
  if (this.focusedNode && changeFocus) {
336
340
  safeRun(this.props.onFocus, this.focusedNode);
337
341
  }
338
- safeRun(this.props.onSelect, this.selectedNodes);
339
342
  }
340
343
 
341
344
  deselect(node: Identity) {
@@ -345,26 +348,36 @@ export class TreeApi<T> {
345
348
  safeRun(this.props.onSelect, this.selectedNodes);
346
349
  }
347
350
 
348
- selectMulti(identity: Identity) {
351
+ selectMulti(identity: Identity, opts: { align?: Align; focus?: boolean } = {}) {
349
352
  const node = this.get(identifyNull(identity));
350
353
  if (!node) return;
351
- this.dispatch(focus(node.id));
352
- this.dispatch(selection.add(node.id));
353
- this.dispatch(selection.anchor(node.id));
354
- this.dispatch(selection.mostRecent(node.id));
355
- this.scrollTo(node);
356
- if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
354
+ const changeFocus = opts.focus !== false;
355
+ if (changeFocus) this.dispatch(focus(node.id));
356
+ if (node.isSelectable) {
357
+ this.dispatch(selection.add(node.id));
358
+ this.dispatch(selection.anchor(node.id));
359
+ this.dispatch(selection.mostRecent(node.id));
360
+ }
361
+ this.scrollTo(node, opts.align);
362
+ if (this.focusedNode && changeFocus) {
363
+ safeRun(this.props.onFocus, this.focusedNode);
364
+ }
357
365
  safeRun(this.props.onSelect, this.selectedNodes);
358
366
  }
359
367
 
360
368
  selectContiguous(identity: Identity) {
361
369
  if (!identity) return;
362
370
  const id = identify(identity);
363
- const { anchor, mostRecent } = this.state.nodes.selection;
364
371
  this.dispatch(focus(id));
365
- this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent)));
366
- this.dispatch(selection.add(this.nodesBetween(anchor, identifyNull(id))));
367
- this.dispatch(selection.mostRecent(id));
372
+ if (this.get(id)?.isSelectable) {
373
+ const { anchor, mostRecent } = this.state.nodes.selection;
374
+ const selectableNodes = this.filterSelectableNodes(
375
+ this.nodesBetween(anchor, identifyNull(id)),
376
+ );
377
+ this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent)));
378
+ this.dispatch(selection.add(selectableNodes));
379
+ this.dispatch(selection.mostRecent(id));
380
+ }
368
381
  this.scrollTo(id);
369
382
  if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
370
383
  safeRun(this.props.onSelect, this.selectedNodes);
@@ -376,20 +389,29 @@ export class TreeApi<T> {
376
389
  }
377
390
 
378
391
  selectAll() {
392
+ const allSelectableNodes = this.filterSelectableNodes(
393
+ Object.keys(this.idToIndex),
394
+ );
379
395
  this.setSelection({
380
- ids: Object.keys(this.idToIndex),
381
- anchor: this.firstNode,
382
- mostRecent: this.lastNode,
396
+ ids: allSelectableNodes,
397
+ anchor: allSelectableNodes[0] ?? null,
398
+ mostRecent: allSelectableNodes[allSelectableNodes.length - 1] ?? null,
383
399
  });
384
400
  this.dispatch(focus(this.lastNode?.id));
385
401
  if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
386
402
  safeRun(this.props.onSelect, this.selectedNodes);
387
403
  }
388
404
 
405
+ private filterSelectableNodes(nodes: (IdObj | string)[]) {
406
+ return nodes
407
+ .map((n) => this.get(identify(n)))
408
+ .filter((n): n is NodeApi<T> => !!n && n.isSelectable);
409
+ }
410
+
389
411
  setSelection(args: {
390
412
  ids: (IdObj | string)[] | null;
391
- anchor: IdObj | string | null;
392
- mostRecent: IdObj | string | null;
413
+ anchor: Identity;
414
+ mostRecent: Identity;
393
415
  }) {
394
416
  const ids = new Set(args.ids?.map(identify));
395
417
  const anchor = identifyNull(args.anchor);
@@ -593,16 +615,25 @@ export class TreeApi<T> {
593
615
  }
594
616
 
595
617
  isEditable(data: T) {
596
- const check = this.props.disableEdit || (() => false);
597
- return !utils.access(data, check);
618
+ return this.isActionPossible(data, this.props.disableEdit);
598
619
  }
599
620
 
600
621
  isDraggable(data: T) {
601
- const check = this.props.disableDrag || (() => false);
602
- return !utils.access(data, check);
622
+ return this.isActionPossible(data, this.props.disableDrag);
623
+ }
624
+
625
+ isSelectable(data: T) {
626
+ return this.isActionPossible(data, this.props.disableSelect);
627
+ }
628
+
629
+ private isActionPossible(
630
+ data: T,
631
+ disabler: string | boolean | BoolFunc<T> = () => false,
632
+ ) {
633
+ return !utils.access(data, disabler);
603
634
  }
604
635
 
605
- isDragging(node: string | IdObj | null) {
636
+ isDragging(node: Identity) {
606
637
  const id = identifyNull(node);
607
638
  if (!id) return false;
608
639
  return this.state.nodes.drag.id === id;
@@ -616,7 +647,7 @@ export class TreeApi<T> {
616
647
  return this.matchFn(node);
617
648
  }
618
649
 
619
- willReceiveDrop(node: string | IdObj | null) {
650
+ willReceiveDrop(node: Identity) {
620
651
  const id = identifyNull(node);
621
652
  if (!id) return false;
622
653
  const { destinationParentId, destinationIndex } = this.state.nodes.drag;
@@ -5,7 +5,7 @@ import { ElementType, MouseEventHandler } from "react";
5
5
  import { ListOnScrollProps } from "react-window";
6
6
  import { NodeApi } from "../interfaces/node-api";
7
7
  import { OpenMap } from "../state/open-slice";
8
- import { useDragDropManager } from "react-dnd";
8
+ import { useDragDropManager, DndProviderProps } from "react-dnd";
9
9
 
10
10
  export interface TreeProps<T> {
11
11
  /* Data Options */
@@ -41,6 +41,7 @@ export interface TreeProps<T> {
41
41
  openByDefault?: boolean;
42
42
  selectionFollowsFocus?: boolean;
43
43
  disableMultiSelection?: boolean;
44
+ disableSelect?: string | boolean | BoolFunc<T>;
44
45
  disableEdit?: string | boolean | BoolFunc<T>;
45
46
  disableDrag?: string | boolean | BoolFunc<T>;
46
47
  disableDrop?:
@@ -76,5 +77,9 @@ export interface TreeProps<T> {
76
77
  dndRootElement?: globalThis.Node | null;
77
78
  onClick?: MouseEventHandler;
78
79
  onContextMenu?: MouseEventHandler;
80
+ dndBackend?: Extract<
81
+ DndProviderProps<unknown, unknown>,
82
+ { backend: unknown }
83
+ >["backend"];
79
84
  dndManager?: ReturnType<typeof useDragDropManager>;
80
85
  }
package/src/utils.ts CHANGED
@@ -173,6 +173,87 @@ export function getInsertIndex(tree: TreeApi<any>) {
173
173
  return 0;
174
174
  }
175
175
 
176
+ export type TreeLineChars = {
177
+ last: string;
178
+ middle: string;
179
+ pipe: string;
180
+ blank: string;
181
+ };
182
+
183
+ const defaultTreeLineChars: TreeLineChars = {
184
+ last: "└ ",
185
+ middle: "├ ",
186
+ pipe: "│ ",
187
+ blank: "\u3000 ",
188
+ };
189
+
190
+ /**
191
+ * Generate a tree-line prefix string for a node.
192
+ *
193
+ * Returns characters like `├ `, `└ `, `│` that visually connect
194
+ * parent and child nodes, similar to the Unix `tree` command.
195
+ *
196
+ * **Styling note:** The prefix uses Box Drawing characters (`│`, `├`, `└`)
197
+ * which require a monospace font for correct alignment. Wrap the prefix
198
+ * in a `<span>` with `fontFamily: "monospace"` and use a consistent
199
+ * `fontSize` (e.g. 14–16px). Inherited `line-height` or `font-size`
200
+ * from parent elements can cause misalignment.
201
+ *
202
+ * @example Basic usage
203
+ * ```tsx
204
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
205
+ * return (
206
+ * <div style={style}>
207
+ * <span style={{ fontFamily: "monospace", fontSize: 14 }}>
208
+ * {getTreeLinePrefix(node)}
209
+ * </span>
210
+ * {node.data.name}
211
+ * </div>
212
+ * );
213
+ * }
214
+ * ```
215
+ *
216
+ * @example With folder/file icons
217
+ * ```tsx
218
+ * function MyNode({ node, style }: NodeRendererProps<MyData>) {
219
+ * const icon = node.isLeaf ? "📄" : node.isOpen ? "📂" : "📁";
220
+ * return (
221
+ * <div style={style}>
222
+ * <span style={{ fontFamily: "monospace", fontSize: 16 }}>
223
+ * {getTreeLinePrefix(node)}
224
+ * </span>
225
+ * {icon} {node.data.name}
226
+ * </div>
227
+ * );
228
+ * }
229
+ * ```
230
+ *
231
+ * @example Custom characters
232
+ * ```tsx
233
+ * // ASCII-only style
234
+ * getTreeLinePrefix(node, { last: "`- ", middle: "|- ", pipe: "|", blank: " " })
235
+ * ```
236
+ */
237
+ export function getTreeLinePrefix(
238
+ node: NodeApi<any>,
239
+ chars: Partial<TreeLineChars> = {}
240
+ ): string {
241
+ const c = { ...defaultTreeLineChars, ...chars };
242
+ if (node.level === 0) return "";
243
+
244
+ const isLast = node.nextSibling === null;
245
+ let prefix = isLast ? c.last : c.middle;
246
+
247
+ let ancestor = node.parent;
248
+ while (ancestor && ancestor.level > 0) {
249
+ const isAncestorLast = ancestor.nextSibling === null;
250
+ prefix = (isAncestorLast ? c.blank : c.pipe) + prefix;
251
+ ancestor = ancestor.parent;
252
+ }
253
+
254
+ return prefix;
255
+ }
256
+
176
257
  export function getInsertParentId(tree: TreeApi<any>) {
177
258
  const focus = tree.focusedNode;
178
259
  if (!focus) return null;