Skip to content

brimdata/react-arborist

Repository files navigation

Logo

React Arborist

See the Demos

The tree view is ubiquitous in software applications. This library provides the React ecosystem with a complete solution to build the equivalent of a VSCode sidebar, Mac Finder, Windows Explorer, or Sketch/Figma layers panel.

Here is a Gmail sidebar clone built with react-arborist.

Gmail sidebar clone built with react-arborist

Features

  • Drag and drop sorting
  • Open/close folders
  • Inline renaming
  • Virtualized rendering
  • Custom styling
  • Keyboard navigation
  • Aria attributes
  • Tree filtering
  • Selection synchronization
  • Callbacks (onScroll, onActivate, onSelect)
  • Controlled or uncontrolled trees

Installation

yarn add react-arborist
npm install react-arborist

Examples

Assume our data is this:

const data = [
  { id: "1", name: "Unread" },
  { id: "2", name: "Threads" },
  {
    id: "3",
    name: "Chat Rooms",
    children: [
      { id: "c1", name: "General" },
      { id: "c2", name: "Random" },
      { id: "c3", name: "Open Source Projects" },
    ],
  },
  {
    id: "4",
    name: "Direct Messages",
    children: [
      { id: "d1", name: "Alice" },
      { id: "d2", name: "Bob" },
      { id: "d3", name: "Charlie" },
    ],
  },
];

The Simplest Tree

Use all the defaults. The initialData prop makes the tree an uncontrolled component. Create, move, rename, and delete will be handled internally.

import { Tree } from 'react-arborist';

function App() {
  return <Tree initialData={data} />;
}

image

Demo

Customize the Appearance

We provide our own dimensions and our own Node component.

function App() {
  return (
    <Tree
      initialData={data}
      openByDefault={false}
      width={600}
      height={1000}
      indent={24}
      rowHeight={36}
      overscanCount={1}
      paddingTop={30}
      paddingBottom={10}
      padding={25 /* sets both */}
    >
      {Node}
    </Tree>
  );
}

function Node({ node, style, dragHandle }) {
  /* This node instance can do many things. See the API reference. */
  return (
    <div style={style} ref={dragHandle}>
      {node.isLeaf ? "🍁" : "🗀"}
      {node.data.name}
    </div>
  );
}

image

Demo

Control the Tree data

Here we use the data prop to make the tree a controlled component. We must handle all the data modifications ourselves using the props below.

function App() {
  /* Handle the data modifications outside the tree component */
  const onCreate = ({ parentId, index, type }) => {};
  const onRename = ({ id, name }) => {};
  const onMove = ({ dragIds, parentId, index }) => {};
  const onDelete = ({ ids }) => {};

  return (
    <Tree
      data={data}
      onCreate={onCreate}
      onRename={onRename}
      onMove={onMove}
      onDelete={onDelete}
    />
  );
}

Tree Filtering

Providing a non-empty searchTerm will only show nodes that match. If a child matches, all its parents also match. Internal nodes are opened when filtering. You can provide your own searchMatch function, or use the default.

function App() {
  const term = useSearchTermString()
  <Tree
    data={data}
    searchTerm={term}
    searchMatch={
      (node, term) => node.data.name.toLowerCase().includes(term.toLowerCase())
    }
  />
}

Sync the Selection

It's common to open something elsewhere in the app, but have the tree reflect the new selection.

Passing an id to the selection prop will select and scroll to that node whenever that id changes.

function App() {
  const chatId = useCurrentChatId();

  /* 
    Whenever the currentChatRoomId changes, 
    the tree will automatically select it and scroll to it. 
  */

  return <Tree initialData={data} selection={chatId} />;
}

Use the Tree Api Instance

You can access the Tree Api in the parent component by giving a ref to the tree.

function App() {
  const treeRef = useRef();

  useEffect(() => {
    const tree = treeRef.current;
    tree.selectAll();
    /* See the Tree API reference for all you can do with it. */
  }, []);

  return <Tree initialData={data} ref={treeRef} />;
}

Data with Different Property Names

The idAccessor and childrenAccessor props allow you to specify the children and id fields in your data.

function App() {
  const data = [
    {
      category: "Food",
      subCategories: [{ category: "Restaurants" }, { category: "Groceries" }],
    },
  ];
  return (
    <Tree
      data={data}
      /* An accessor can provide a string property name */
      idAccessor="category"
      /* or a function with the data as the argument */
      childrenAccessor={(d) => d.subCategories}
    />
  );
}

Custom Rendering

Render every single piece of the tree yourself. See the API reference for the props passed to each renderer.

function App() {
  return (
    <Tree
      data={data}
      /* The outer most element in the list */
      renderRow={MyRow}
      /* The "ghost" element that follows the mouse as you drag */
      renderDragPreview={MyDragPreview}
      /* The line that shows where an element will be dropped */
      renderCursor={MyCursor}
    >
      {/* The inner element that shows the indentation and data */}
      {MyNode}
    </Tree>
  );
}

Dynamic sizing

You can add a ref to it with this package ZeeCoder/use-resize-observer

That hook will return the height and width of the parent whenever it changes. You then pass these numbers to the Tree.

const { ref, width, height } = useResizeObserver();
 
<div className="parent" ref={ref}>
  <Tree height={height} width={width} />
</div>

API Reference

Tree Component Props

These are all the props you can pass to the Tree component.

interface TreeProps<T> {
  /* Data Options */
  data?: readonly T[];
  initialData?: readonly T[];

  /* Data Handlers */
  onCreate?: handlers.CreateHandler<T>;
  onMove?: handlers.MoveHandler<T>;
  onRename?: handlers.RenameHandler<T>;
  onDelete?: handlers.DeleteHandler<T>;

  /* Renderers*/
  children?: ElementType<renderers.NodeRendererProps<T>>;
  renderRow?: ElementType<renderers.RowRendererProps<T>>;
  renderDragPreview?: ElementType<renderers.DragPreviewProps>;
  renderCursor?: ElementType<renderers.CursorProps>;
  renderContainer?: ElementType<{}>;

  /* Sizes */
  rowHeight?: number;
  overscanCount?: number;
  width?: number | string;
  height?: number;
  indent?: number;
  paddingTop?: number;
  paddingBottom?: number;
  padding?: number;

  /* Config */
  childrenAccessor?: string | ((d: T) => T[] | null);
  idAccessor?: string | ((d: T) => string);
  openByDefault?: boolean;
  selectionFollowsFocus?: boolean;
  disableMultiSelection?: boolean;
  disableEdit?: string | boolean | BoolFunc<T>;
  disableDrag?: string | boolean | BoolFunc<T>;
  disableDrop?:
    | string
    | boolean
    | ((args: {
        parentNode: NodeApi<T>;
        dragNodes: NodeApi<T>[];
        index: number;
      }) => boolean);

  /* Event Handlers */
  onActivate?: (node: NodeApi<T>) => void;
  onSelect?: (nodes: NodeApi<T>[]) => void;
  onScroll?: (props: ListOnScrollProps) => void;
  onToggle?: (id: string) => void;
  onFocus?: (node: NodeApi<T>) => void;

  /* Selection */
  selection?: string;

  /* Open State */
  initialOpenState?: OpenMap;

  /* Search */
  searchTerm?: string;
  searchMatch?: (node: NodeApi<T>, searchTerm: string) => boolean;

  /* Extra */
  className?: string | undefined;
  rowClassName?: string | undefined;

  dndRootElement?: globalThis.Node | null;
  onClick?: MouseEventHandler;
  onContextMenu?: MouseEventHandler;
  dndManager?: DragDropManager;
}

Row Component Props

The <RowRenderer> is responsible for attaching the drop ref, the row style (top, height) and the aria-attributes. The default should work fine for most use cases, but it can be replaced by your own component if you need. See the renderRow prop in the <Tree> component.

type RowRendererProps<T> = {
  node: NodeApi<T>;
  innerRef: (el: HTMLDivElement | null) => void;
  attrs: HTMLAttributes<any>;
  children: ReactElement;
};

Node Component Props

The <NodeRenderer> is responsible for attaching the drag ref, the node style (padding for indentation), the visual look of the node, the edit input of the node, and anything else you can dream up.

There is a default renderer, but it's only there as a placeholder to get started. You'll want to create your own component for this. It is passed as the <Tree> components only child.

export type NodeRendererProps<T> = {
  style: CSSProperties;
  node: NodeApi<T>;
  tree: TreeApi<T>;
  dragHandle?: (el: HTMLDivElement | null) => void;
  preview?: boolean;
};

DragPreview Component Props

The <DragPreview> is responsible for showing a "ghost" version of the node being dragged. The default is a semi-transparent version of the NodeRenderer and should work fine for most people. To customize it, pass your new component to the renderDragPreview prop.

type DragPreviewProps = {
  offset: XYCoord | null;
  mouse: XYCoord | null;
  id: string | null;
  dragIds: string[];
  isDragging: boolean;
};

Cursor Component Props

The <Cursor> is responsible for showing a line that indicates where the node will move to when it's dropped. The default is a blue line with circle on the left side. You may want to customize this. Pass your own component to the renderCursor prop.

export type CursorProps = {
  top: number;
  left: number;
  indent: number;
};

Node API Reference

State Properties

All these properties on the node instance return booleans related to the state of the node.

node.isRoot

Returns true if this is the root node. The root node is added internally by react-arborist and not shown in the UI.

node.isLeaf

Returns true if the children property is not an array.

node.isInternal

Returns true if the children property is an array.

node.isOpen

Returns true if node is internal and in an open state.

node.isEditing

Returns true if this node is currently being edited. Use this property in the NodeRenderer to render the rename form.

node.isSelected

Returns true if node is selected.

node.isSelectedStart

Returns true if node is the first of a contiguous group of selected nodes. Useful for styling.

node.isSelectedEnd

Returns true if node is the last of a contiguous group of selected nodes. Useful for styling.

node.isOnlySelection

Returns true if node is the only node selected in the tree.

node.isFocused

Returns true if node is focused.

node.isDragging

Returns true if node is being dragged.

node.willReceiveDrop

Returns true if node is internal and the user is hovering a dragged node over it.

node.state

Returns an object with all the above properties as keys and boolean values. Useful for adding class names to an element with a library like clsx or classnames.

type NodeState = {
  isEditing: boolean;
  isDragging: boolean;
  isSelected: boolean;
  isSelectedStart: boolean;
  isSelectedEnd: boolean;
  isFocused: boolean;
  isOpen: boolean;
  isClosed: boolean;
  isLeaf: boolean;
  isInternal: boolean;
  willReceiveDrop: boolean;
};

Accessors

node.childIndex

Returns the node's index in relation to its siblings.

node.next

Returns the next visible node. The node directly under this node in the tree component. Returns null if none exist.

node.prev

Returns the previous visible node. The node directly above this node in the tree component. Returns null if none exist.

node.nextSibling

Returns the next sibling in the data of this node. Returns null if none exist.

Selection Methods

node.select()

Select only this node.

node.deselect()

Deselect this node. Other nodes may still be selected.

node.selectMulti()

Select this node while maintaining all other selections.

node.selectContiguous()

Deselect all nodes from the anchor node to the last selected node, the select all nodes from the anchor node to this node. The anchor changes to the focused node after calling select() or selectMulti().

Activation Methods

node.activate()

Runs the Tree props' onActivate callback passing in this node.

node.focus()

Focus this node.

Open/Close Methods

node.open()

Opens the node if it is an internal node.

node.close()

Closes the node if it is an internal node.

node.toggle()

Toggles the open/closed state of the node if it is an internal node.

node.openParents()

Opens all the parents of this node.

node.edit()

Moves this node into the editing state. Calling node.isEditing will return true.

node.submit(newName)

Submits newName string to the onRename handler. Moves this node out of the editing state.

node.reset()

Moves this node out of the editing state without submitting a new name.

Event Handlers

node.handleClick(event)

Useful for using the standard selection methods when a node is clicked. If the meta key is down, call multiSelect(). If the shift key is down, call selectContiguous(). Otherwise, call select() and activate().

Tree API Reference

The tree api reference is stable across re-renders. It always has the most recent state and props.

Node Accessors

tree.get(id) : NodeApi | null

Get node by id from the visibleNodes array.

tree.at(index) : NodeApi | null

Get node by index from the visibleNodes array.

tree.visibleNodes : NodeApi[]

Returns an array of the visible nodes.

tree.firstNode : NodeApi | null

The first node in the visibleNodes array.

tree.lastNode : NodeApi | null

The last node in the visibleNodes array.

tree.focusedNode : NodeApi | null

The currently focused node.

tree.mostRecentNode : NodeApi | null

The most recently selected node.

tree.nextNode : NodeApi | null

The node directly after the focusedNode in the visibleNodes array.

tree.prevNode : NodeApi | null

The node directly before the focusedNode in the visibleNodes array.

Focus Methods

tree.hasFocus : boolean

Returns true if the the tree has focus somewhere within it.

tree.focus(id)

Focus on the node with id.

tree.isFocused(id) : boolean

Check if the node with id is focused.

tree.pageUp()

Move focus up one page.

tree.pageDown()

Move focus down one page.

Selection Methods

tree.selectedIds : Set<string>

Returns a set of ids that are selected.

tree.selectedNodes : NodeApi[]

Returns an array of nodes that are selected.

tree.hasNoSelection : boolean

Returns true if nothing is selected in the tree.

tree.hasSingleSelection : boolean

Returns true if there is only one selection.

tree.hasMultipleSelections : boolean

Returns true if there is more than one selection.

tree.isSelected(id) : boolean

Returns true if the node with id is selected.

tree.select(id)

Select only the node with id.

tree.deselect(id)

Deselect the node with id.

tree.selectMulti(id)

Add to the selection the node with id.

tree.selectContiguous(id)

Deselected nodes between the anchor and the last selected node, then select the nodes between the anchor and the node with id.

tree.deselectAll()

Deselect all nodes.

tree.selectAll()

Select all nodes.

Visibility

tree.open(id)

Open the node with id.

tree.close(id)

Close the node with id.

tree.toggle(id)

Toggle the open state of the node with id.

tree.openParents(id)

Open all parents of the node with id.

tree.openSiblings(id)

Open all siblings of the node with id.

tree.openAll()

Open all internal nodes.

tree.closeAll()

Close all internal nodes.

tree.isOpen(id) : boolean

Returns true if the node with id is open.

Drag and Drop

tree.isDragging(id) : boolean

Returns true if the node with id is being dragged.

tree.willReceiveDrop(id) : boolean

Returns true if the node with id is internal and is under the dragged node.

Scrolling

tree.scrollTo(id, [align])

Scroll to the node with id. If this node is not visible, this method will open all its parents. The align argument can be "auto" | "smart" | "center" | "end" | "start".

Properties

tree.isEditing : boolean

Returns true if the tree is editing a node.

tree.isFiltered : boolean

Returns true if the searchTerm prop is not an empty string when trimmed.

tree.props : TreeProps

Returns all the props that were passed to the <Tree> component.

tree.root : NodeApi

Returns the root NodeApi instance. Its children are the Node representations of the data prop array.

Author

James Kerr at Brim Data for the Zui desktop app.