react-arborist 3.4.2 → 3.5.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,7 +305,7 @@ 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;
@@ -343,7 +345,11 @@ interface TreeProps<T> {
343
345
  dndRootElement?: globalThis.Node | null;
344
346
  onClick?: MouseEventHandler;
345
347
  onContextMenu?: MouseEventHandler;
346
- dndManager?: DragDropManager;
348
+ dndBackend?: Extract<
349
+ DndProviderProps<unknown, unknown>,
350
+ { backend: unknown }
351
+ >["backend"];
352
+ dndManager?: ReturnType<typeof useDragDropManager>;
347
353
  }
348
354
  ```
349
355
 
@@ -648,17 +654,17 @@ _tree_.**isSelected**(_id_) : _boolean_
648
654
 
649
655
  Returns true if the node with _id_ is selected.
650
656
 
651
- _tree_.**select**(_id_)
657
+ _tree_.**select**(_id_, _[opts]_)
652
658
 
653
- Select only the node with _id_.
659
+ 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
660
 
655
661
  _tree_.**deselect**(_id_)
656
662
 
657
663
  Deselect the node with _id_.
658
664
 
659
- _tree_.**selectMulti**(_id_)
665
+ _tree_.**selectMulti**(_id_, _[opts]_)
660
666
 
661
- Add to the selection the node with _id_.
667
+ Add to the selection the node with _id_. Accepts the same options object as `select`.
662
668
 
663
669
  _tree_.**selectContiguous**(_id_)
664
670
 
@@ -740,6 +746,43 @@ _tree_.**root** : _NodeApi_
740
746
 
741
747
  Returns the root _NodeApi_ instance. Its children are the Node representations of the _data_ prop array.
742
748
 
749
+ ## Utilities
750
+
751
+ ### getTreeLinePrefix
752
+
753
+ 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.
754
+
755
+ ```ts
756
+ function getTreeLinePrefix(
757
+ node: NodeApi<any>,
758
+ chars?: Partial<TreeLineChars>
759
+ ): string;
760
+
761
+ type TreeLineChars = {
762
+ last: string; // default: "└ "
763
+ middle: string; // default: "├ "
764
+ pipe: string; // default: "│ "
765
+ blank: string; // default: "\u3000 "
766
+ };
767
+ ```
768
+
769
+ Wrap the prefix in a monospace span so the connectors line up:
770
+
771
+ ```tsx
772
+ import { Tree, getTreeLinePrefix } from "react-arborist";
773
+
774
+ function Node({ node, style }) {
775
+ return (
776
+ <div style={style}>
777
+ <span style={{ fontFamily: "monospace" }}>{getTreeLinePrefix(node)}</span>
778
+ {node.data.name}
779
+ </div>
780
+ );
781
+ }
782
+ ```
783
+
784
+ Pass a partial `chars` object to override any of the default characters (e.g. for an ASCII-only style).
785
+
743
786
  ## Author
744
787
 
745
788
  [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
  }
@@ -68,6 +68,7 @@ exports.RowContainer = react_1.default.memo(function RowContainer({ index, style
68
68
  role: "treeitem",
69
69
  "aria-level": node.level + 1,
70
70
  "aria-selected": node.isSelected,
71
+ "aria-expanded": node.isOpen,
71
72
  style: rowStyle,
72
73
  tabIndex: -1,
73
74
  className: tree.props.rowClassName,
@@ -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; } });
@@ -163,7 +163,10 @@ 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;
@@ -349,17 +349,20 @@ class TreeApi {
349
349
  this.dispatch(selection_slice_1.actions.remove(id));
350
350
  safeRun(this.props.onSelect, this.selectedNodes);
351
351
  }
352
- selectMulti(identity) {
352
+ selectMulti(identity, opts = {}) {
353
353
  const node = this.get(identifyNull(identity));
354
354
  if (!node)
355
355
  return;
356
- this.dispatch((0, focus_slice_1.focus)(node.id));
356
+ const changeFocus = opts.focus !== false;
357
+ if (changeFocus)
358
+ this.dispatch((0, focus_slice_1.focus)(node.id));
357
359
  this.dispatch(selection_slice_1.actions.add(node.id));
358
360
  this.dispatch(selection_slice_1.actions.anchor(node.id));
359
361
  this.dispatch(selection_slice_1.actions.mostRecent(node.id));
360
- this.scrollTo(node);
361
- if (this.focusedNode)
362
+ this.scrollTo(node, opts.align);
363
+ if (this.focusedNode && changeFocus) {
362
364
  safeRun(this.props.onFocus, this.focusedNode);
365
+ }
363
366
  safeRun(this.props.onSelect, this.selectedNodes);
364
367
  }
365
368
  selectContiguous(identity) {
@@ -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[];
@@ -52,5 +52,8 @@ export interface TreeProps<T> {
52
52
  dndRootElement?: globalThis.Node | null;
53
53
  onClick?: MouseEventHandler;
54
54
  onContextMenu?: MouseEventHandler;
55
+ dndBackend?: Extract<DndProviderProps<unknown, unknown>, {
56
+ backend: unknown;
57
+ }>["backend"];
55
58
  dndManager?: ReturnType<typeof useDragDropManager>;
56
59
  }
@@ -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
  }
@@ -42,6 +42,7 @@ export const RowContainer = React.memo(function RowContainer({ index, style, })
42
42
  role: "treeitem",
43
43
  "aria-level": node.level + 1,
44
44
  "aria-selected": node.isSelected,
45
+ "aria-expanded": node.isOpen,
45
46
  style: rowStyle,
46
47
  tabIndex: -1,
47
48
  className: tree.props.rowClassName,
@@ -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";
@@ -163,7 +163,10 @@ 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;
@@ -323,17 +323,20 @@ export class TreeApi {
323
323
  this.dispatch(selection.remove(id));
324
324
  safeRun(this.props.onSelect, this.selectedNodes);
325
325
  }
326
- selectMulti(identity) {
326
+ selectMulti(identity, opts = {}) {
327
327
  const node = this.get(identifyNull(identity));
328
328
  if (!node)
329
329
  return;
330
- this.dispatch(focus(node.id));
330
+ const changeFocus = opts.focus !== false;
331
+ if (changeFocus)
332
+ this.dispatch(focus(node.id));
331
333
  this.dispatch(selection.add(node.id));
332
334
  this.dispatch(selection.anchor(node.id));
333
335
  this.dispatch(selection.mostRecent(node.id));
334
- this.scrollTo(node);
335
- if (this.focusedNode)
336
+ this.scrollTo(node, opts.align);
337
+ if (this.focusedNode && changeFocus) {
336
338
  safeRun(this.props.onFocus, this.focusedNode);
339
+ }
337
340
  safeRun(this.props.onSelect, this.selectedNodes);
338
341
  }
339
342
  selectContiguous(identity) {
@@ -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[];
@@ -52,5 +52,8 @@ export interface TreeProps<T> {
52
52
  dndRootElement?: globalThis.Node | null;
53
53
  onClick?: MouseEventHandler;
54
54
  onContextMenu?: MouseEventHandler;
55
+ dndBackend?: Extract<DndProviderProps<unknown, unknown>, {
56
+ backend: unknown;
57
+ }>["backend"];
55
58
  dndManager?: ReturnType<typeof useDragDropManager>;
56
59
  }
@@ -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.2",
3
+ "version": "3.5.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",
@@ -40,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "react-dnd": "^14.0.3",
42
42
  "react-dnd-html5-backend": "^14.0.3",
43
- "react-window": "^1.8.10",
43
+ "react-window": "^1.8.11",
44
44
  "redux": "^5.0.0",
45
45
  "use-sync-external-store": "^1.2.0"
46
46
  },
@@ -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>
@@ -60,6 +60,7 @@ export const RowContainer = React.memo(function RowContainer<T>({
60
60
  role: "treeitem",
61
61
  "aria-level": node.level + 1,
62
62
  "aria-selected": node.isSelected,
63
+ "aria-expanded": node.isOpen,
63
64
  style: rowStyle,
64
65
  tabIndex: -1,
65
66
  className: tree.props.rowClassName,
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";
@@ -345,15 +345,18 @@ export class TreeApi<T> {
345
345
  safeRun(this.props.onSelect, this.selectedNodes);
346
346
  }
347
347
 
348
- selectMulti(identity: Identity) {
348
+ selectMulti(identity: Identity, opts: { align?: Align; focus?: boolean } = {}) {
349
349
  const node = this.get(identifyNull(identity));
350
350
  if (!node) return;
351
- this.dispatch(focus(node.id));
351
+ const changeFocus = opts.focus !== false;
352
+ if (changeFocus) this.dispatch(focus(node.id));
352
353
  this.dispatch(selection.add(node.id));
353
354
  this.dispatch(selection.anchor(node.id));
354
355
  this.dispatch(selection.mostRecent(node.id));
355
- this.scrollTo(node);
356
- if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode);
356
+ this.scrollTo(node, opts.align);
357
+ if (this.focusedNode && changeFocus) {
358
+ safeRun(this.props.onFocus, this.focusedNode);
359
+ }
357
360
  safeRun(this.props.onSelect, this.selectedNodes);
358
361
  }
359
362
 
@@ -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 */
@@ -76,5 +76,9 @@ export interface TreeProps<T> {
76
76
  dndRootElement?: globalThis.Node | null;
77
77
  onClick?: MouseEventHandler;
78
78
  onContextMenu?: MouseEventHandler;
79
+ dndBackend?: Extract<
80
+ DndProviderProps<unknown, unknown>,
81
+ { backend: unknown }
82
+ >["backend"];
79
83
  dndManager?: ReturnType<typeof useDragDropManager>;
80
84
  }
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;