# Headless Tree Documentation --- # Sandboxes # Example Sandboxes This is a collection of example integrations for the Headless Tree library. You can use them as basis to quickly scaffold an app with Headless Tree, get started with experimenting with the capabilities of HT or to create minimalistic bug-reproductions for issue reports. - Basic example of Headless Tree with React: [Stackblitz](https://stackblitz.com/github/lukasbach/headless-tree/tree/main/examples/basic?preset=node&file=src/main.tsx), [CodeSandbox](https://codesandbox.io/p/devbox/github/lukasbach/headless-tree/tree/main/examples/basic?file=src/main.tsx), [GitHub](https://github.com/lukasbach/headless-tree/tree/main/examples/basic) - Integration of Headless Tree with React with most HT features enabled: [Stackblitz](https://stackblitz.com/github/lukasbach/headless-tree/tree/main/examples/comprehensive?preset=node&file=src/main.tsx), [CodeSandbox](https://codesandbox.io/p/devbox/github/lukasbach/headless-tree/tree/main/examples/comprehensive?file=src/main.tsx), [GitHub](https://github.com/lukasbach/headless-tree/tree/main/examples/comprehensive) - Headless Tree with React Compiler enabled: [Stackblitz](https://stackblitz.com/github/lukasbach/headless-tree/tree/main/examples/react-compiler?preset=node&file=src/main.tsx), [CodeSandbox](https://codesandbox.io/p/devbox/github/lukasbach/headless-tree/tree/main/examples/react-compiler?file=src/main.tsx), [GitHub](https://github.com/lukasbach/headless-tree/tree/main/examples/react-compiler) --- # Get Started import { DemoBox } from "../../src/components/demo/demo-box"; ## Installation To start using Headless Tree, install it to your project as dependency via ```bash npm install @headless-tree/core @headless-tree/react ``` or ```bash yarn add @headless-tree/core @headless-tree/react ``` The current main branch is always published to NPM under the `snapshot` tag, so you can use `npm install @headless-tree/core@snapshot @headless-tree/react@snapshot` to get the latest snapshot deployment if you want to experiment with the latest features. :::tip[Headless Tree is available!] Headless Tree is now available as Beta! If the library provides value to you, leaving [a star on Github](https://github.com/lukasbach/headless-tree) would help a lot with the visibility of the project. ::: ## Quick Start The React Bindings of Headless Tree provide a hook `useTree` that creates a tree instance, which you can use to render your tree however you like. The instance provides methods to get the tree as a flat list of nodes, that you can easily render with custom logic. It provides accessibility props to make sure that, even if the tree is not rendered as hierarchical structure, screen readers still read it as such even if it is just rendered as flat list. You can also use other frameworks than React. Use the `createTree` method from the core package to create a tree without a dependency on React. Documentation on that and bindings for other frameworks are planned for the future. ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/General/Simple Example", tags: ["homepage"], } satisfies Meta; export default meta; // story-start export const SimpleExample = () => { const tree = useTree({ initialState: { expandedItems: ["folder-1"] }, rootItemId: "folder", getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-1item`, `${itemId}-2item`, ], }, indent: 20, features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return (
{tree.getItems().map((item) => ( ))}
); }; ``` The `useTree` hook (or the `createTree` function from the core package, if you are not using the React bindings) return an instance of TreeInstance. The most important method on that instance is `getItems()`, which returns a flat list of all nodes in the tree. Those nodes are of type ItemInstance, which provides methods to interact with each item and get the necessary props to render it. When rendering your tree, don't forget to - Spread the return value of `tree.getContainerProps()` as props into the container component containing your tree items. - Spread the return value of `item.getProps()` as props into your tree item elements. - You probably want to indent your items based on their depth, which you can get via `item.getItemMeta().level`. ## Importing Features The architecture of Headless Tree separates individual features into distinct objects, that need to be manually imported and added to your tree. They are exported from the `@headless-tree/core` package and need to be added to the ```features``` property in your tree config. This is done to design a clean architecture, allow customizability (you can easily write custom plugins that way, or overwrite parts of other plugins), and more importantly, allow you to only import the features you need. This is especially important for tree-shaking, as you can easily remove unused features from your bundle. For example, if you want drag-and-drop support and hotkeys support, import the respective features and add them to your tree config: ```tsx import { useTree, dragAndDropFeature, hotkeysCoreFeature, syncDataLoaderFeature } from "@headless-tree/core"; useTree({ // ... other options features: [ syncDataLoaderFeature, dragAndDropFeature, hotkeysCoreFeature, ] }) ``` :::danger[Make sure to import all features you are using] Which features are imported and which not affects which methods are available on the tree instance and on the item instances, as well as which config options are respected by Headless Tree. However, the TypeScript Types always include all possible functions. If you are trying to use a feature which is not working or where methods are missing during runtime, check if you have included all necessary features in your tree. ::: All available features are documented with a dedicated guide on how to use them. In the sidebar, you can find each feature under its respective name in the "Features" category. The "Tree Core" and "Main Feature" features are special features that are always included and do not need to be imported. ## Types Headless Tree is written in TypeScript and provides full TypeScript support. The most important types to keep in mind are - TreeConfig: The configuration object that you pass to `useTree` or `createTree`. - TreeInstance: The instance of a tree that you get back from `useTree` or `createTree`. - ItemInstance: The instance of an item that you get back from the tree instance. - TreeState: The state of a tree instance, which you can access via `tree.state` (read more in the guide on [managing State](https://headless-tree.lukasbach.com/llm/guides/state.md)). Note that some of the defined functions require that you include the respective feature in your tree config, see the section above. ## Hooking up your data structure As you saw in the previous example, we also added the `syncDataLoaderFeature` feature. Then, you can define how Headless Tree reads your tree with a `dataLoader` property in the tree config. You need either that or the `asyncDataLoaderFeature` feature to hook up your data structure to Headless Tree. Note that, when using the `asyncDataLoaderFeature`, you need to provide a `asyncDataLoader` instead, which has a different interface (async methods instead of sync methods). - In the `dataLoader.getItem` property, you define a method that returns the payload of an item. The form of the payload is up to you, and can be provided as generic type to the `useTree` or `createTree` method. - In the `dataLoader.getChildren` property, you define a method that returns the IDs of the children of an item. In addition to the `dataLoader` property, you also need to provide - the `rootItemId` property - a `getItemName()` method, which returns the display name of an item - a `isItemFolder()` method, which returns whether an item is a folder or not. An item being a folder means that it can be expanded. For the latter two, the parameter provided to you is an item instance, so you can use all methods that you also have available during rendering. Use `item.getItemData()` to get the payload for the item that you provided. ```ts const tree = useTree({ rootItemId: "root-item", getItemName: (item) => item.getItemData().itemName, isItemFolder: (item) => item.isFolder, dataLoader: { getItem: (itemId) => myDataStructure[itemId], getChildren: (itemId) => myDataStructure[itemId].childrenIds, }, features: [ syncDataLoaderFeature ], }); ``` In the prior demos, we mostly used `string` as simple payload type. The following demo shows how to use a more complex payload type, and how to use the `getItemName` and `isItemFolder` methods to get the display name and folder status of an item. ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/General/Item Data Objects", } satisfies Meta; export default meta; // story-start interface Item { name: string; children?: string[]; isFolder?: boolean; isRed?: boolean; } const items: Record = { root: { name: "Root", children: ["folder1", "folder2"], isFolder: true }, folder1: { name: "Folder 1", children: ["item1", "item2"], isFolder: true }, folder2: { name: "Folder 2 (red)", children: ["folder3"], isFolder: true, isRed: true, }, folder3: { name: "Folder 3", children: ["item3"], isFolder: true }, item1: { name: "Item 1 (red)", isRed: true }, item2: { name: "Item 2" }, item3: { name: "Item 3" }, }; export const ItemDataObjects = () => { const tree = useTree({ rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: (item) => Boolean(item.getItemData().isFolder), dataLoader: { getItem: (itemId) => items[itemId], getChildren: (itemId) => items[itemId].children ?? [], }, indent: 20, features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return (
{tree.getItems().map((item) => ( ))}
); }; ``` :::tip If the library provides value to you, leaving [a star on Github](https://github.com/lukasbach/headless-tree) would help a lot with the visibility of the project. If you encounter any bugs or issues, or have feedback about Headless Tree, please don't hesitate to open [an issue on Github](https://github.com/lukasbach/headless-tree/issues/new/choose), or get involved in the [discussion on Discord](https://discord.gg/KuZ6EezzVw). ::: ## llms.txt You can find the documentation as `llm.txt` file available to use with LLM agents: - [llms.txt](https://headless-tree.lukasbach.com/llms.txt) - [llms-full.txt](https://headless-tree.lukasbach.com/llms-full.txt) --- # Managing State import { DemoBox } from "../../src/components/demo/demo-box"; # Managing State With Headless Tree, there are multiple ways how to manage the state of the tree. By default, Headless Tree manages all parts of the state itself, and you just need to provide the data. Alternatively, you can opt into managing the entirety of the tree state yourself, which is useful if you want to change the state from outside, or read from the state from outside. Finally, you can also manage individual parts of the state. Note that the tree state only includes information that relates to how the tree is displayed, not parts that are specific to the data that composes the tree. For example, when you include the drag-and-drop feature, a `dnd` state sub-state is exposed that contains the IDs of dragged items, the item that the user is currently dragging over, and the position of where the item would be dropped. When you choose to manage that state yourself, you can read those pieces of information or control them yourself. However, the data of the tree, as well as its mutation followed by the drop event, is not part of the state. You need to handle these data mutations yourself outside of the tree state. Another important part to keep in mind is that which parts of the state are exposed depends on the features that are included. The aforementioned `dnd` state property will be completely ignored if the `dragAndDropFeature` is not included in the tree configuration, as will be dnd-related update handlers. ## Letting headless-tree manage the state The simplest option is to let Headless Tree manage the entirety of the state. This is done by default, so you do not have to do anything. You can also supply a `initialState` prop in the tree configuration to define the initial state. ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/State/Internal State", tags: ["guides/state"], } satisfies Meta; export default meta; // story-start export const InternalState = () => { const tree = useTree({ rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: () => true, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], }, features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return (
{tree.getItems().map((item) => ( ))}
); }; ``` ## Managing individual feature states If you are interested in specific parts of the state, you can opt into managing those parts of the state while still leaving the rest up to Headless Tree. To manage one part of the state, you need to provide the sub property of the state in the `state` config prop, as well as a state setter for that sub state directly to config. The state setter for a property `stateProperty` is always named `setStateProperty`. For example, if you want to manage the set of selected items and the set of expanded items, you need to provide ```ts useTree({ state: { selectedItems, expandedItems }, setSelectedItems, setExpandedItems, features: [syncDataLoaderFeature, selectionFeature], // ... }); ``` Note that, while expanded items are part of the core functionality, item selections are part of the [selection feature](https://headless-tree.lukasbach.com/llm/features/selection.md), and thus need the feature to be included in the tree configuration. The type of the state depends on the specific state. For a state type `T`, the setter is of type ```ts type SetStateFn = (updaterOrValue: T | ((old: T) => T)) => void; ``` This is exactly the type of state setters that React uses for `useState` calls, so you can use a `useState` hook to let React manage the state property within your own code, and directly pass the specific state and its setter to Headless Tree. ```tsx const [selectedItems, setSelectedItems] = useState([]); useTree({ state: { selectedItems }, setSelectedItems, // ... }); ``` ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/State/Distinct State Handlers", tags: ["guides/state", "homepage"], } satisfies Meta; export default meta; // story-start export const DistinctStateHandlers = () => { const [selectedItems, setSelectedItems] = useState([]); const [expandedItems, setExpandedItems] = useState([]); const [focusedItem, setFocusedItem] = useState(null); const tree = useTree({ state: { selectedItems, expandedItems, focusedItem }, rootItemId: "root", setSelectedItems, setExpandedItems, setFocusedItem, getItemName: (item) => item.getItemData(), isItemFolder: () => true, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], }, features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return ( <>
{tree.getItems().map((item) => ( ))}

Selected Items

{JSON.stringify(selectedItems)}

Expanded Items

{JSON.stringify(expandedItems)}

Focused Item

{JSON.stringify(focusedItem)}
); }; ``` ## Manage the entire state yourself As another alternative, you can also choose to manage the entirety of the state yourself. Instead of passing individual sub properties through the `state` config prop, you can pass the entire state object as that property. Similarly, you do not pass individual state setters named after each state property, but pass one `setState` method to the config that will be called whenever the state changes. The type of the state setter again is the same as the one used by React's `useState` hook. ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { TreeState, hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/State/External State", tags: ["guides/state"], } satisfies Meta; export default meta; // story-start export const ExternalState = () => { const [state, setState] = useState>>({}); const tree = useTree({ state, setState, rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: () => true, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], }, features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return ( <>
{tree.getItems().map((item) => ( ))}
{JSON.stringify(state, null, 2)}
); }; ``` --- # Hotkeys import { DemoBox } from "../../src/components/demo/demo-box"; Headless Tree provides its own implementation of keybindings that can be customized and extended. Further down on this page, you can find a list of all available hotkeys. Note that the availability of hotkeys depends on the features that are included. For any hotkey to work, the `hotkeysCoreFeature` must be included. This enables some basic tree-related hotkeys like moving focus with arrow keys. Other feature-specific hotkeys, like toggling selection or renaming an item, need both the `hotkeysCoreFeature` and the feature that implements the functionality, such as the `selectionFeature` or the `renamingFeature`. The type of a hotkey implementation is shown below. Read on to see how you can customize existing hotkeys with this type or add your own. ```ts export interface HotkeyConfig { hotkey: string; canRepeat?: boolean; allowWhenInputFocused?: boolean; isEnabled?: (tree: TreeInstance) => boolean; preventDefault?: boolean; handler: (e: KeyboardEvent, tree: TreeInstance) => void; } ``` ## Overwriting Hotkeys You can pass an object of partial hotkey implementations in the `hotkeys` config property to your tree. Default hotkeys and your custom hotkeys will be merged together during runtime, with your custom hotkeys overwriting the default hotkeys. This allows you to overwrite the keybindings, parts of the behavior (like being able to repeat pressing the key) certain hotkeys or overwriting their implementation. Use the tables at the bottom of the page for reference of the hotkey names. ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Hotkeys/Overwriting Hotkeys", tags: ["feature/hotkeys", "homepage"], } satisfies Meta; export default meta; // story-start export const OverwritingHotkeys = () => { const tree = useTree({ rootItemId: "folder", getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-1item`, `${itemId}-2item`, ], }, hotkeys: { focusNextItem: { hotkey: "ArrowRight", }, focusPreviousItem: { hotkey: "ArrowLeft", }, }, features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return ( <>

In this example, the hotkeys for moving the focus up and down are bound to "ArrowLeft" and "ArrowRight", overwriting the hotkeys for expanding and collapsing (by default, the hotkeys are ArrowUp and ArrowDown).

{tree.getItems().map((item) => ( ))}
); }; ``` ## Adding custom Hotkeys You can also very easily add custom hotkeys, with arbitrary keybindings and implementations. While you can technically use any names for the hotkeys, the TypeScript type is implemented to only support the official hotkey names and any names that are prefixed with `custom` to avoid mistakes. ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { expandAllFeature, hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Hotkeys/Custom Hotkeys", tags: ["feature/hotkeys", "homepage"], } satisfies Meta; export default meta; // TODO stopped verifying stories after big revampt at this story // story-start export const CustomHotkeys = () => { const tree = useTree({ rootItemId: "folder", getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => itemId.length < 15 ? [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-1item`, `${itemId}-2item`, ] : [], }, hotkeys: { // Begin the hotkey name with "custom" to satisfy the type checker customExpandAll: { hotkey: "KeyQ", handler: (e, tree) => { tree.expandAll(); }, }, customCollapseAll: { hotkey: "KeyW", handler: (e, tree) => { tree.collapseAll(); }, }, }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, expandAllFeature, ], }); return ( <>

In this example, two additional custom hotkeys are defined: Press "q" while the tree is focused to expand all items, and press "w" to collapse all items.

{tree.getItems().map((item) => ( ))}
); }; ``` The names of keys are based on the [HTML Key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values). The following additional key names are supported: - `LetterOrNumber`: Any letter or number key, `/^[a-z0-9]$/` - `Letter`: Any letter key, `/^[a-z]$/` - `Plus`: The symbol plus, `/^\+$/`. Useful since the "+" symbol is used in keybinding strings to concatenate individual keys. ## Available Hotkeys The [Accessibility Guide](https://headless-tree.lukasbach.com/llm/guides/accessibility.md) shows some demos of which hotkeys are available and how they can be used. ### General Hotkeys The following hotkeys need the `hotkeysCoreFeature` feature added. | Key | Default Keybinding | Description | |---------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------| | `focusNextItem` | `ArrowDown` | Focuses the next item in the list. | | `focusPreviousItem` | `ArrowUp` | Focuses the previous item in the list. | | `expandOrDown` | `ArrowRight` | Expands the focused item if it is collapsed. If the focused item is already expanded, focuses the next item in the list. | | `collapseOrUp` | `ArrowLeft` | Collapses the focused item if it is expanded. If the focused item is already collapsed, focuses the previous item in the list. | | `focusFirstItem` | `Home` | Focuses the first item in the list. | | `focusLastItem` | `End` | Focuses the last item in the list. | ### Selections The following hotkeys need the `hotkeysCoreFeature` and `selectionFeature` features added. | Key | Default Keybinding | Description | |----------------------|--------------------|----------------------------------------------------------------------------------------------------------------------------------------| | `toggleSelectedItem` | `Ctrl+Space` | Toggles the selection of the focused item. | | `selectUpwards` | `Shift+ArrowUp` | Expands the current selection by the item prior to the focused item, and moves focus upwards. Discards non-connected selection blocks. | | `selectDownwards` | `Shift+ArrowDown` | Expands the current selection by the item after the focused item, and moves focus downwards. Discards non-connected selection blocks. | | `selectAll` | `Ctrl+KeyA` | Selects all items in the list. | ### Drag And Drop The following hotkeys need the `hotkeysCoreFeature`, `dragAndDropFeature` and `keyboardDragAndDrop` features added. | Key | Default Keybinding | Description | |----------------|----------------------|------------------------------------------| | `startDrag` | `Control+Shift+KeyD` | Starts dragging the selected items. | | `dragUp` | `ArrowUp` | Moves the drag target one item upwards | | `dragDown` | `ArrowDown` | Moves the drag target one item downwards | | `cancelDrag` | `Escape` | Cancels the drag operation. | | `completeDrag` | `Enter` | Completes the drag operation. | ### Search The following hotkeys need the `hotkeysCoreFeature` and `searchFeature` features added. | Key | Default Keybinding | Description | |----------------------|--------------------|-----------------------------------------------------------------------------------------| | `openSearch` | `LetterOrNumber` | Opens the search input and focuses it. | | `closeSearch` | `Escape` | Closes the search input. | | `submitSearch` | `Enter` | Submits the search input, and focuses and selects the last selected search result item. | | `nextSearchItem` | `ArrowDown` | Focuses the next search result item. | | `previousSearchItem` | `ArrowUp` | Focuses the previous search result item. | ### Renaming The following hotkeys need the `hotkeysCoreFeature` and `renamingFeature` features added. | Key | Default Keybinding | Description | |--------------------|--------------------|---------------------------------------------------------------------------------------------| | `renameItem` | `F2` | Starts renaming the focused item. | | `abortRename` | `Escape` | Close the rename input. | | `completeRenaming` | `Enter` | Close the rename input, and call the `config.onRename(item, newNameValue)` config property. | ### Expand All The following hotkeys need the `hotkeysCoreFeature` and `expandAllFeature` features added. | Key | Default Keybinding | Description | |--------------------|-----------------------|---------------------------------------------------------------------------------------------------------------------------------| | `expandSelected` | `Control+Shift+Plus` | Expand all selected items and their descendants. When running async, pressing Escape afterwards will cancel the expand process. | | `collapseSelected` | `Control+Shift+Minus` | Collapse all selected items and their descendants. | --- # Accessibility Considerations ## Accessibility according to W3 Specs Headless Tree follows accessibility guidelines defined by W3 in the [Navigation Treeview Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/#support-notice-header), specificially, the [Treeview with declared properties](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-1b/). All relevant aria attributes are provided to the tree and its items automatically. ## Hotkeys Headless Tree comes with various hotkeys that make using it easier when relying on keyboard interactions. Hotkeys are enabled if the [Hotkeys Feature](https://headless-tree.lukasbach.com/llm/features/hotkeys.md) is included in the tree configuration, and can be configured as described in the [Hotkeys Guide](https://headless-tree.lukasbach.com/llm/guides/hotkeys.md). The hotkeys are based on the [W3C recommendations on accessible trees](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/#support-notice-header), and can be customized to fit your needs. - Pressing `Shift` while using the arrow keys will extend the selection to the next item in the direction of the arrow key. !Drag and Drop Reparenting Demo - Pressing `Ctrl` + `Space` will toggle the selection of the currently focused item. Pressing `Ctrl` + `A` will select all items in the tree. !Drag and Drop Reparenting Demo - Pressing `Left` will collapse the currently focused item if it is open, or move the focus to the parent item if it is closed. Pressing `Right` will expand the currently focused item if it is closed, or move the focus to the first child item if it is open. !Drag and Drop Reparenting Demo - Pressing `F2` will start renaming the currently focused item. Pressing `Escape` will cancel the renaming process, and pressing `Enter` will confirm the renaming. !Drag and Drop Reparenting Demo - Entering any text while focusing an item will open the search input, and pressing `Escape` will close it. The search input acts as typeahead feature. The Up and Down keys are overwritten to navigate through the search results in this case. !Drag and Drop Reparenting Demo - Pressing the `Home` or `End` button will move the focus to the first or last item in the tree, respectively. !Drag and Drop Reparenting Demo ## Keyboard-controlled Drag and Drop To support moving items via drag-and-drop while staying compliant with accessibility standards, Headless Tree supports keyboard-controlled drag-and-drop via the [Keyboard Drag and Drop Feature](https://headless-tree.lukasbach.com/llm/features/kdnd.md). !Keyboard Drag and Drop Demo --- # Styling import { DemoBox } from "../../src/components/demo/demo-box"; # Styling Headless Tree is designed to be as unopinionated as possible when it comes to styling. This means that both rendering and styling is generally up to you. The tree does not come with any default styles, and you can use whatever styling framework you have already set up in your project. Headless Tree provides several methods on the item instance variable that you can use to derive information about each item and how it should be styled, such as - `isFolder()` - `isExpanded()` - `isSelected()` - `isFocused()` - `isDragTarget()` - `isDraggingOver()` - `isRenaming()` - `isLoading()` - `isMatchingSearch()` In the following example, and default styles that are typically used in other samples are omitted, and just some basic inline styles are used to demonstrate how HT looks without styling: ```ts jsx import type { Meta } from "@storybook/react"; import React, { Fragment } from "react"; import { DragTarget, ItemInstance, createOnDropHandler, dragAndDropFeature, hotkeysCoreFeature, insertItemsAtTarget, keyboardDragAndDropFeature, removeItemsFromParents, renamingFeature, searchFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; import { DemoItem, createDemoData } from "../utils/data"; const meta = { title: "React/General/Basic Styling", tags: ["feature/dnd", "homepage", "guides/styling"], } satisfies Meta; export default meta; const { syncDataLoader, data } = createDemoData(); let newItemId = 0; const insertNewItem = (dataTransfer: DataTransfer) => { const newId = `new-${newItemId++}`; data[newId] = { name: dataTransfer.getData("text/plain"), }; return newId; }; const onDropForeignDragObject = ( dataTransfer: DataTransfer, target: DragTarget, ) => { const newId = insertNewItem(dataTransfer); insertItemsAtTarget([newId], target, (item, newChildrenIds) => { data[item.getId()].children = newChildrenIds; }); }; const onCompleteForeignDrop = (items: ItemInstance[]) => removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }); const onRename = (item: ItemInstance, value: string) => { data[item.getId()].name = value; }; const getCssClass = (item: ItemInstance) => cn("treeitem", { focused: item.isFocused(), expanded: item.isExpanded(), selected: item.isSelected(), folder: item.isFolder(), drop: item.isDragTarget(), searchmatch: item.isMatchingSearch(), }); // story-start export const BasicStyling = () => { const tree = useTree({ initialState: { expandedItems: ["fruit"], selectedItems: ["banana", "orange"], }, rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: (item) => !!item.getItemData().children, canReorder: true, onDrop: createOnDropHandler((item, newChildren) => { data[item.getId()].children = newChildren; }), onRename, onDropForeignDragObject, onCompleteForeignDrop, createForeignDragObject: (items) => ({ format: "text/plain", data: items.map((item) => item.getId()).join(","), }), canDropForeignDragObject: (_, target) => target.item.isFolder(), indent: 20, dataLoader: syncDataLoader, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, renamingFeature, searchFeature, ], }); return ( <> {tree.isSearchOpen() && (
({tree.getSearchMatchingItems().length} matches)
)}
{tree.getItems().map((item) => ( {item.isRenaming() ? (
) : ( )}
))}
{ e.dataTransfer.setData("text/plain", "hello world"); }} > Drag me into the tree!
{ alert(JSON.stringify(e.dataTransfer.getData("text/plain"))); console.log(e.dataTransfer.getData("text/plain")); }} onDragOver={(e) => e.preventDefault()} > Drop items here!
); }; ``` If you want to use the default styles that are used in other samples, you can copy their CSS code from here: - https://github.com/lukasbach/headless-tree/blob/main/packages/sb-react/.storybook/style.css --- # Migrating from react-complex-tree Headless Tree is the official successor of React-Complex-Tree. Migration might not be straight-forward, since React Complex Tree did most of the rendering out of the box, whereas Headless Tree expects you to write the DOM rendering yourself. However, Headless Tree is much more flexible, which means you will likely find an easy solution to most aspects of how you did things in RCT. ## Feature parity Headless Tree has full feature parity to React Complex Tree except for the following features: - Keyboard-based drag and drop is not yet supported (planned, coming soon) ## Benefits of Headless Tree - Headless Tree is compatible with virtualization libraries - Much better performance with large trees, supporting several 100k visible items in virtualized trees - The rendered DOM is easier to customize since you manage it yourself - Headless Tree is more flexible and [easier to expand](https://headless-tree.lukasbach.com/llm/recipe/plugins.md) - Hotkeys can not just be remapped, but also customized in their implementation, and you can [define your additional hotkeys](https://headless-tree.lukasbach.com/llm/guides/hotkeys.md) - Mostly Framework agnostic, support for more frameworks is planned ## Main Differences - Headless Tree exposes functionality with a React Hook, instead of a component. - There are no out-of-the-box renderers anymore. You need to implement the render logic yourself, though that is usually not more than 20-30 LOC. - The concept of [controlled](https://rct.lukasbach.com/docs/guides/controlled-environment) and [uncontrolled trees](https://rct.lukasbach.com/docs/guides/uncontrolled-environment) doesn't exist anymore. Instead, you can control [none](https://headless-tree.lukasbach.com/llm/guides/state.md), [all](https://headless-tree.lukasbach.com/llm/guides/state.md) or [some individual state atoms](https://headless-tree.lukasbach.com/llm/guides/state.md). - Instead of using `StaticTreeDataProvider` or implementing a custom `TreeDataProvider`, you need to implement a `TreeDataLoader`. - Propagating data mutation as consequence of drop events is no longer achieved as part of the `TreeDataProvider`, but can be done in the drop handler with the [`createOnDropHandler`](https://headless-tree.lukasbach.com/llm/dnd/overview.md). - When using asynchronous data loading, you need to use the [async data loader feature](https://headless-tree.lukasbach.com/llm/features/async-dataloader.md) instead of the [sync data loader](https://headless-tree.lukasbach.com/llm/features/sync-dataloader.md). - The old [interaction modes](https://rct.lukasbach.com/docs/guides/interaction-modes), that customize which click behavior expands/selects/focuses items, does not exist anymore. Instead, you can customize this with [a custom feature that overwrites the click behavior](https://headless-tree.lukasbach.com/llm/recipe/click-behavior.md). - [Drag-interactions between multiple trees on the same page](https://rct.lukasbach.com/docs/guides/multiple-trees) is not supported out of the box, the concept of tree environments doesn't exist anymore. You can achieve this by [defining an outgoing DnD interface](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md). - This can also be used to allow your tree to [accept drag events from outside](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md), or [drag your tree items into other components](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md), which wasn't supported before. - Almost all features are not included by default, but are seperate variables that [need to be imported explicitly](https://headless-tree.lukasbach.com/llm/getstarted.md). This reduces the bundle size to only what you need. - [When using the Search Feature, you need to render the search input](https://headless-tree.lukasbach.com/llm/features/search.md). - [When using the Renaming Feature, you need to render the rename input](https://headless-tree.lukasbach.com/llm/features/renaming.md). --- # Overview # Overview of Feature Functionality Headless Tree is composed by a set of individual features, that can each be included or not in the tree configuration. This allows you to reduce the bundle size by including only what you need, while being able to customize the tree by overriding feature behavior or expanding the tree with custom features. Each feature is an object that can be imported from `@headless-tree/core`, and has a TypeScript type declaration that defines the functionality that it provides as well as the configuration options that it accepts. For instance, this is the type declaration for the `selectionFeature`, which provides the ability to select multiple items inside the tree: ```ts export type SelectionFeatureDef = { state: { selectedItems: string[]; }; config: { setSelectedItems?: SetStateFn; }; treeInstance: { setSelectedItems: (selectedItems: string[]) => void; getSelectedItems: () => ItemInstance[]; }; itemInstance: { select: () => void; deselect: () => void; toggleSelect: () => void; isSelected: () => boolean; selectUpTo: (ctrl: boolean) => void; }; hotkeys: | "toggleSelectItem" | "selectUpwards" | "selectDownwards" | "selectAll"; }; ``` - The feature substate interface defined with `SelectionFeatureDef["state"]` gets merged with the interface `TreeState`, and allows you to manage that state in any way defined [Managing State Guide](https://headless-tree.lukasbach.com/llm/guides/state.md). - The configuration options defined with `SelectionFeatureDef["config"]` gets merged with the interface `TreeConfig`, and the feature recognizes any of those config options passed to the tree configuration. - The `treeInstance` and `itemInstance` interfaces define the methods that the feature provides to the tree instances and item instances, respectively. These methods can be called directly on the instances to interact with the feature. - The `hotkeys` type defines the hotkeys that the feature implements and respects as long as the [Hotkey Core Feature](https://headless-tree.lukasbach.com/llm/features/hotkeys.md) is included in the tree configuration. By including the feature, you can interact with it like follows: ```ts const tree = useTree({ // ...remaining tree config state: { selectedItems: ["item-1", "item-2"] }, setSelectedItems: myCustomSetSelectedItems, // alternatively manageable with setState hotkeys: { // Override hotkey definitions for this feature selectAll: { hotkey: "ctrl+q", }, }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, ], }); // Interact with tree instance methods tree.setSelectedItems(["item-3", "item-4"]); // Interact with item instance methods tree.getItemInstance("item-1").select(); ``` ## Implementing custom features Apart from being merged into the Headless Tree core TypeScript interfaces, each feature is a completely standalone object that is defined in isolation. You can implement your own features to expand the behavior of Headless Tree, and merge the types for your feature into the global HT namespace interfaces yourself. ```ts declare module "@headless-tree/core" { export interface ItemInstance { alertChildren: () => void; } } const customFeature: FeatureImplementation = { itemInstance: { alertChildren: ({ item }) => { alert( item .getChildren() .map((child) => child.getItemName()) .join(", "), ); }, }, }; const tree = useTree({ // ...remaining tree config features: [ // ...other features customFeature, ], }); tree.getItemInstance("item-1").alertChildren(); ``` You can find out more about this in the [Guide on writing custom plugins](https://headless-tree.lukasbach.com/llm/recipe/plugins.md). ## Overwriting feature behavior You can also overwrite HT core features, or parts of them to customize them to your use case. In the most extreme case, you can just completely copy the implementation for a single feature (you can find the source for each feature, by following the "View Source" link in one of the feature docs pages) into your project and customizing the implementation. You can also write a feature, that wraps the original feature and modifies its behavior. When multiple features are included in the tree configuration, that define an instance method with the same name, the last feature in the list will be the one called when the method is invoked, and its implementation will be passed a method called `prev` that can be used in the wrapper implementation to call the previous implementation. As example, note the implementation of `itemInstance.getProps()` in the selection feature, that adds new DOM properties and modifies the behavior of the `onClick` handler in cases where Shift or Ctrl is pressed, while still respecting the behavior of other features generating props: ```ts export const selectionFeature: FeatureImplementation = { // ... other feature properties itemInstance: { getProps: ({ tree, item, prev }) => ({ ...prev?.(), // include other props generated by other features "aria-selected": item.isSelected() ? "true" : "false", onClick: (e: MouseEvent) => { // override onClick handler if (e.shiftKey) { item.selectUpTo(e.ctrlKey || e.metaKey); } else if (e.ctrlKey || e.metaKey) { item.toggleSelect(); } else { tree.setSelectedItems([item.getItemMeta().itemId]); } // call the implementation of onClick of the // previous feature that implements this method prev?.()?.onClick?.(e); }, }), } }; ``` Again, this functionality is described in more detail in the [Guide on writing custom plugins](https://headless-tree.lukasbach.com/llm/recipe/plugins.md). --- # Tree Core import { DemoBox } from "../../src/components/demo/demo-box"; import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; The tree core feature is always included by Headless Tree, and does not need to be explicitly imported and added to your tree config. It provides basic tree-related features, like expanding or collapsing items and moving the focus. Its state is composed of a list of expanded items, and the ID of the currently focused item. ## Retrieving React Props Part of what this feature provides is also the default props for both the tree container element, and each of the tree items. You can retrieve these props by calling `tree.getContainerProps()` or `item.getProps()`. These props are meant to be spread into the respective elements in your render function, and will provide both the necessary event handlers and ARIA attributes to make the tree accessible. Note that both will also provide a `ref` property, which will register a reference to the respective element to Headless Tree. Alternatively, you can also manually register or deregister elements with `tree.registerElement(el)` and `item.registerElement(el)`. ```jsx
{tree.getItems().map((item) => ( ))}
``` ## Primary action Headless Tree exposes a concept called "primary action", which is invoked when the user clicks on an item with the intent to perform an action on that item. A handler `onPrimaryAction` can be provided in the tree configuration. By default it will be clicked when the user clicks on an item, or when `item.primaryAction()` is invoked programmatically. By customizing the implementation of `item.getProps().onClick()`, the behavior of calling this function can be customized. ## Managing the focused item By default, Headless Tree will maintain the focused item in its internal state. If a `setState` or `setFocusedItem` function is provided in the tree configuration, you can manage the focused item yourself (see [Managing State](https://headless-tree.lukasbach.com/llm/guides/state.md)). You can also use the `item.setFocused()` method to focus an item programmatically. You likely want to follow this up with a call to `tree.updateDomFocus()` to update the focused item in DOM and scroll to that item. ## Expanding and collapsing folders Similarly to the focused item, Headless Tree will maintain the list of expanded items in its internal state. Provide a `setExpandedItems` function in the tree configuration to manage the expanded items yourself, otherwise Headless Tree will manage the expanded items for you (see [Managing State](https://headless-tree.lukasbach.com/llm/guides/state.md)). Call `item.expand()` or `item.collapse()` to expand or collapse an item, or `item.isExpanded()` to check if an item is expanded. --- # Sync Data Loader import { DemoBox } from "../../src/components/demo/demo-box"; import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; When using Headless Tree, you need to provide an interface to read your data. The most straight-forward way to do this is using the Sync Data Loader Feature. Alternatively, you can use the [Async Data Loader Feature](https://headless-tree.lukasbach.com/llm/features/async-dataloader.md) if you are dealing with asynchronous data sources. In both cases, you need to include the respective feature explicitly. When using the Sync Data Loader Feature, you need to provide a `dataLoader` property in your tree config which implements the `TreeDataLoader` interface. ```ts const tree = useTree({ rootItemId: "root-item", getItemName: (item) => item.getItemData().itemName, isItemFolder: (item) => item.isFolder, dataLoader: { getItem: (itemId) => myDataStructure[itemId], getChildren: (itemId) => myDataStructure[itemId].childrenIds, }, features: [ syncDataLoaderFeature ], }); ``` Note that Headless Tree may call `getItem` and `getChildren` multiple times for the same item, and also during each render. Therefore, you should make sure that these functions are fast and do not perform any expensive operations. Asynchronous data providers on the other hand provide caching out of the box, and only call those functions once per item until their data are explicitly invalidated. If your data source provides an synchronous, yet expensive interface, you can still use the [Async Data Loader](https://headless-tree.lukasbach.com/llm/features/async-dataloader.md) instead. :::warning You should implement the `dataLoader.getItem` and `dataLoader.getChildren` functions so that they return synchronously. If you need to fetch data asynchronously, you should use the [Async Data Loader](https://headless-tree.lukasbach.com/llm/features/async-dataloader.md) instead. ::: --- # Async Data Loader import { DemoBox } from "../../src/components/demo/demo-box"; import {FeaturePageHeader} from "../../src/components/docs-page/feature-page-header"; ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { asyncDataLoaderFeature, hotkeysCoreFeature, selectionFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Async/Async Loading State", tags: ["feature/async-data-loader"], } satisfies Meta; export default meta; // eslint-disable-next-line no-promise-executor-return const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // story-start export const AsyncLoadingState = () => { const [loadingItemData, setLoadingItemData] = useState([]); const [loadingItemChildrens, setLoadingItemChildrens] = useState( [], ); const tree = useTree({ state: { loadingItemData, loadingItemChildrens }, setLoadingItemData, setLoadingItemChildrens, rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: () => true, createLoadingItemData: () => "Loading...", dataLoader: { getItem: (itemId) => wait(800).then(() => itemId), getChildren: (itemId) => wait(800).then(() => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`]), }, indent: 20, features: [asyncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return ( <>
{tree.getItems().map((item) => ( ))}

Press [i1] to invalidate item data, or [i2] to invalidate its children array.

Loading:

        {JSON.stringify({ loadingItemData, loadingItemChildrens }, null, 2)}
      
); }; ``` The Async Data Loader is an alternative to the [Sync Data Loader](https://headless-tree.lukasbach.com/llm/features/sync-dataloader.md) that allows you to define your data interface in an asynchronous manner. If you want to use it, you need to use the `asyncDataLoaderFeature` instead of the `syncDataLoaderFeature` in your tree config. ```ts const tree = useTree({ rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: () => item.getItemData().isFolder, createLoadingItemData: () => "Loading...", dataLoader: { getItem: async (itemId) => await dataSources.getItem(itemId) getChildren: async (itemId) => await dataSources.getChildren(itemId), }, features: [ asyncDataLoaderFeature ], }); ``` You still hook the tree up to your data source with `dataLoader`, but can now use asynchronous methods for retrieving your tree data. ## Loading Items The Async Data Loader provides functionality for marking items as "loading" and displaying them as such. While item data is loading through the `getItem` function, it is considered "loading" and is intended to be displayed as such. The same goes for an items children, while their IDs are being fetched through the `getChildren` function. The Async Data Loader defines a state for loading items: `state.loadingItems` keeps an array of item IDs which are currently loading. For a given item, you can check if it is loading by calling `item.isLoading()`. When an item is loading, its item data will be set to the value returned by `config.createLoadingItemData`. This way, you can customize how loading items are displayed. ## Invalidating Item Data While the Sync Data Loader does not provide a data invalidation concept since it refetches all data during every render anyway, the Async Data Loader caches item data and does not refetch it unless it is invalidated. Methods for invalidating certain items are provided on the item instance: ```ts item.invalidateItemData(); item.invalidateChildrenIds(); ``` The data loader will then refetch the respective data the next render. ## Fetching all children data at once The dataloader also specifies an alternative interface for fetching the payload for all children of an item at once, in case it is more convenient to you that way. This can help to reduce the number of requests of your app when a folder is opened by a user. ```ts const dataLoader = { getItem: (itemId) => { return wait(800).then(() => itemPayload); }, getChildrenWithData: (itemId) => { return wait(800).then(() => [ { id: `child-1`, data: child1Payload }, { id: `child-2`, data: child2Payload }, { id: `child-3`, data: child3Payload }, ]); }, }; ``` ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { asyncDataLoaderFeature, hotkeysCoreFeature, selectionFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Async/Async Get Children With Data", tags: ["feature/async-data-loader"], } satisfies Meta; export default meta; // eslint-disable-next-line no-promise-executor-return const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // story-start export const AsyncGetChildrenWithData = () => { const [loadingItemData, setLoadingItemData] = useState([]); const [loadingItemChildrens, setLoadingItemChildrens] = useState( [], ); const tree = useTree({ state: { loadingItemData, loadingItemChildrens }, setLoadingItemData, setLoadingItemChildrens, rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: () => true, createLoadingItemData: () => "Loading...", dataLoader: { getItem: (itemId) => { console.log(`getItem(${itemId})`); return wait(800).then(() => itemId); }, getChildrenWithData: (itemId) => { console.log(`getChildrenWithData(${itemId})`); return wait(800).then(() => [ { id: `${itemId}-1`, data: `${itemId}-1` }, { id: `${itemId}-2`, data: `${itemId}-2` }, { id: `${itemId}-3`, data: `${itemId}-3` }, ]); }, }, indent: 20, features: [asyncDataLoaderFeature, selectionFeature, hotkeysCoreFeature], }); return ( <>

This example demonstrates an async data interface where the entire data for all children of a node are fetched at once, by using `getChildrenWithData`.

{tree.getItems().map((item) => ( ))}

Press [i1] to invalidate item data, or [i2] to invalidate its children array.

Loading:

        {JSON.stringify({ loadingItemData, loadingItemChildrens }, null, 2)}
      
); }; ``` --- # Selections import { DemoBox } from "../../src/components/demo/demo-box"; import {FeaturePageHeader} from "../../src/components/docs-page/feature-page-header"; The selection feature provides the ability of multiselect, allowing users to select multiple items at once. Without it, Headless Tree just allows users to focus a single item, and act on it through that. This feature is particularly useful in combination with the [drag-and-drop](https://headless-tree.lukasbach.com/llm/features/dnd.md) feature, as it allows users to select multiple items and drag them all at once. By default, Headless Tree will maintain the selected items in its internal state. If a `setState` or `setSelectedItems` function is provided in the tree configuration, you can manage the focused item yourself (see [Managing State](https://headless-tree.lukasbach.com/llm/guides/state.md)). Call `item.select()`, `item.deselect()` or `item.toggleSelection()` to change the selection state of an item. The feature will also add behavior to the `onClick` handler provided as prop for each of the tree items for ctrl-clicking and shift-clicking to select multiple items at once. --- # Drag and Drop import { DemoBox } from "../../src/components/demo/demo-box"; import {FeaturePageHeader} from "../../src/components/docs-page/feature-page-header"; The Drag-And-Drop Feature provides drag-and-drop capabilities. It allows to drag a single tree item (or many of the [selection feature](https://headless-tree.lukasbach.com/llm/features/selection.md) is included in the tree config) and drop it somewhere else in the tree. The feature also allows you to create interactions between the tree and external drag objects, allowing you to drag tree items out of the tree, or foreign data objects from outside the tree inside. As extension of that, this can also be used to implement drag behavior between several trees. Since this feature composes a large part of the functionality of Headless Tree, it is documented in its own section "Drag and Drop" on the left, get started with the [Drag and Drop Overview Page](https://headless-tree.lukasbach.com/llm/dnd/overview.md). --- # Keyboard Drag and Drop import { DemoBox } from "../../src/components/demo/demo-box"; import {FeaturePageHeader} from "../../src/components/docs-page/feature-page-header"; The Keyboard Drag and Drop feature provides a way to use the drag-and-drop functionality of the tree using only the keyboard. This allows you to implement drag-based functionality while staying compliant with accessibility standards, by providing the default drag capability with keyboard-controlled hotkeys and a visually hidden assistive text indicating the drag-process to screen readers. !Dragging items with Keyboard controls Demo ```ts jsx import type { Meta } from "@storybook/react"; import React, { Fragment } from "react"; import { DragTarget, ItemInstance, createOnDropHandler, dragAndDropFeature, hotkeysCoreFeature, insertItemsAtTarget, keyboardDragAndDropFeature, removeItemsFromParents, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; import { DemoItem, createDemoData } from "../utils/data"; const meta = { title: "React/Drag and Drop/Visible Assistive Text", tags: ["feature/dnd", "homepage"], } satisfies Meta; export default meta; const { syncDataLoader, data } = createDemoData(); let newItemId = 0; const insertNewItem = (dataTransfer: DataTransfer) => { const newId = `new-${newItemId++}`; data[newId] = { name: dataTransfer.getData("text/plain"), }; return newId; }; const onDropForeignDragObject = ( dataTransfer: DataTransfer, target: DragTarget, ) => { const newId = insertNewItem(dataTransfer); insertItemsAtTarget([newId], target, (item, newChildrenIds) => { data[item.getId()].children = newChildrenIds; }); }; const onCompleteForeignDrop = (items: ItemInstance[]) => removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }); const getCssClass = (item: ItemInstance) => cn("treeitem", { focused: item.isFocused(), expanded: item.isExpanded(), selected: item.isSelected(), folder: item.isFolder(), drop: item.isDragTarget(), }); // story-start export const VisibleAssistiveText = () => { const tree = useTree({ initialState: { expandedItems: ["fruit"], selectedItems: ["banana", "orange"], }, rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: (item) => !!item.getItemData().children, canReorder: true, onDrop: createOnDropHandler((item, newChildren) => { data[item.getId()].children = newChildren; }), onDropForeignDragObject, onCompleteForeignDrop, createForeignDragObject: (items) => ({ format: "text/plain", data: items.map((item) => item.getId()).join(","), }), canDropForeignDragObject: (_, target) => target.item.isFolder(), indent: 20, dataLoader: syncDataLoader, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); const dndState = tree.getState().dnd; return ( <>

The text below is the default description rendered by{" "} {""}, which explains the drag-process to assistive software. It is normally hidden to the visuals of the webpage, but forced to be visible in this sample for demonstration purposes:

{tree.getItems().map((item) => ( ))}
{ e.dataTransfer.setData("text/plain", "hello world"); }} > Drag me into the tree!
{ alert(JSON.stringify(e.dataTransfer.getData("text/plain"))); console.log(e.dataTransfer.getData("text/plain")); }} onDragOver={(e) => e.preventDefault()} > Drop items here!
{dndState && ( )}
); }; ``` ## Setup The Keyboard Drag and Drop feature mostly builds upon the normal [Drag and Drop](https://headless-tree.lukasbach.com/llm/features/dnd.md) feature, integrating it mostly just means to add it as feature to the tree configuration and it will provide the necessary functionality for keyboard-controlled drag-and-drop. Note that this feature needs both the `dragAndDropFeature` and the `hotkeysCoreFeature` to work properly. ``` features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], ``` One important aspect of the Keyboard Drag and Drop feature is the assistive text, which is used to inform screen readers about the drag-and-drop process. The `@headless-tree/react` package exposes a AssistiveTreeDescription react component, that will automatically render the english assistive text for you in a visually hidden way. It is recommended to put this component inside the tree root container, or otherwise reference it with aria-tags in the tree. ```jsx
{/* ...tree items... */}
```` Alternatively, you can build this text yourself by using the data available in the tree state that you can retrieve with `tree.getState()`. You can use treeState.assistiveDndState of type AssistiveDndState to determine in which state the drag-and-drop process is, and treeState.dnd for details about what is being dragged and where the user is dropping it. ```ts enum AssistiveDndState { None, Started, Dragging, Completed, Aborted, } ``` ## Dragging items out of the tree with keyboard Selecting items inside the tree, and then dragging them out of the tree into a third-party component is a core-functionality of the normal DnD Feature. You can find out more about that in the [Guide on dragging in and out of trees](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md). Since there is no actual drag-event happening in keyboard-bound situations, you need to implement custom logic to accept a keyboard-controlled drag initiated in a Headless Tree instance. You can detect whether the user is dragging items via keyboard by inspecting `tree.getState().assistiveDndState`, if it is either `AssistiveDndState.Started` or `AssistiveDndState.Dragging`. In the logic that accepts such a drag, you can get information about the items that are being dragged by inspecting `tree.getState().dnd?.draggedItems`. You can then handle the dragged items (e.g. by removing them from the tree with `removeItemsFromParents()`) and complete the keyboard drag with `tree.stopKeyboardDrag()`. ```jsx ``` !Keyboard Drag tree items out of tree demo ## Dragging foreign objects inside the tree with keyboard Similarly, moving data from outside the tree into it via a drag event is also a core-functionality of the normal DnD Feature. You can find out more about that in the [Guide on dragging in and out of trees](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md). Again, this functionality works mostly out-of-the-box with headless tree, but the initiation of a keyboard-bound drag event that can be accepted by Headless Tree needs to be done externally. If the user triggers something that you consider a keyboard-bound drag event within the boundaries of your application, you can inform Headless Tree that this is happening by calling `tree.startKeyboardDragOnForeignObject(dataTransfer);` with a custom `dataTranfer` object, that you handle the same way as with normal foreign drag events. You can create this object manually. ```jsx ``` !Keyboard Drag foreign object inside of tree demo --- # Hotkeys import { DemoBox } from "../../src/components/demo/demo-box"; import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; The Hotkeys Core feature is necessary for any keyboard-based interaction via hotkeys to work. Each of the features define their own hotkeys configurations (e.g. the Selection Feature provides the `selectAll` hotkey defaulting to `Ctrl+A`), which can be customized or extended in the tree configuration. However, only the Hotkeys Core feature implements the logic to listen for and handle keyboard events, and is necessary for any hotkeys of other features to work. The feature also makes it very easy to add custom hotkeys with arbitrary keybindings and implementations. Look into the [Guide on Hotkeys](https://headless-tree.lukasbach.com/llm/guides/hotkeys.md) for details on how to use the Hotkeys Core Feature, and how to overwrite or add custom hotkeys. The [Accessibility Guide](https://headless-tree.lukasbach.com/llm/guides/accessibility.md) shows some demos of which hotkeys are available and how they can be used. --- # Tree Search import { DemoBox } from "../../src/components/demo/demo-box"; import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; The Search feature provides the functionality for users to quickly find a certain item by typing a part of its name. This fulfills the accessibility feature of a typeahead search, and is particularly useful in large trees where the user might not be able to find the item they are looking for by scrolling through the tree, while also making its use a bit more obvious by showing the search input field. This is consistent with similar tree implementations such as the file tree in JetBrains IDEs, where typing in the tree will show a search input field. The items will not be shown in a filtered view, but instead search matches will be visually highlighted, the tree will be scrolled and focused to the first search match, and navigating through the items with up or down arrow keys will move the focus only between matched items. The search input field will automatically be opened when the user starts typing while focusing the tree, but it can also be opened programmatically by calling `tree.openSearch()`. Blurring the input field, pressing Escape (if the Hotkeys Core Feature is included), or invoking `tree.closeSearch()` will close the search. `tree.isSearchOpen()` can be used to check if the search input field is currently open and should be rendered. Similar to the remaining tree integration, it is up to the library consumer to render the search input field correctly. Pass `tree.getSearchInputElementProps()` as props to the search input to hook it up with change handlers and register its element with Headless Tree. ```jsx {tree.isSearchOpen() && ( <> ({tree.getSearchMatchingItems().length} matches) )} ``` Then, use `item.isMatchingSearch()` to determine if an item is currently a search match, and style it accordingly. --- # Renaming import { DemoBox } from "../../src/components/demo/demo-box"; import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; ```ts jsx import type { Meta } from "@storybook/react"; import React, { Fragment } from "react"; import { hotkeysCoreFeature, renamingFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Renaming/Basic", tags: ["feature/renaming", "basic", "homepage"], } satisfies Meta; export default meta; // story-start export const Basic = () => { const tree = useTree({ rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: () => true, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [`${itemId}-1`, `${itemId}-2`, `${itemId}-3`], }, onRename: (item, value) => { alert(`Renamed ${item.getItemName()} to ${value}`); }, initialState: { expandedItems: ["root-1", "root-1-1"], renamingItem: "root-1-1-2", renamingValue: "abc", }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, renamingFeature, ], }); return ( <>

{" "} or press F2 when an item is focused.

{tree.getItems().map((item) => ( {item.isRenaming() ? (
) : ( )}
))}
); }; ``` The renaming feature allows you to allow users to rename items in the tree. They can either start renaming an item via the `renameItem` hotkey (defaulting to F2), or when the `item.startRenaming()` method is called. You can hook this up to e.g. double-clicking an item-name or to a context menu option. The feature exposes two state variables, `renamingItem` to keep track of the item that is currently being renamed, and `renamingValue` to keep track of the new name that the user is typing in. When [maintaining individual states](https://headless-tree.lukasbach.com/llm/guides/state.md), you can hook into changes to those states via the `setRenamingItem` and `setRenamingValue` config methods. ## Rendering the Rename Input Similar to other Headless Tree Features, the library only provides the functionality, but not the UI. For any tree item, you can determine whether it should render a renaming behavior with `item.isRenaming()`. Make sure to pass `item.getRenameInputProps()` to the input element to hook up the renaming behavior to the input field. ```jsx if (item.isRenaming()) { return ( ); } // otherwise, render the item as usual ``` ## Customizing which items can be renamed You can also customize which items can be renamed by providing a `canRename` function in the tree configuration. ```ts jsx import type { Meta } from "@storybook/react"; import React, { Fragment } from "react"; import { hotkeysCoreFeature, renamingFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Renaming/Can Rename Configurability", tags: ["feature/renaming"], } satisfies Meta; export default meta; // story-start export const CanRenameConfigurability = () => { const tree = useTree({ rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: (item) => item.getId().endsWith("-1") || item.getId().endsWith("-2"), dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-4 (can rename)`, ], }, onRename: (item, value) => { alert(`Renamed ${item.getItemName()} to ${value}`); }, canRename: (item) => item.getId().includes("can rename"), features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, renamingFeature, ], }); return ( <>

{" "} or press F2 when an item is focused.

{tree.getItems().map((item) => ( {item.isRenaming() ? (
) : ( )}
))}
); }; ``` --- # Expand all import { DemoBox } from "../../src/components/demo/demo-box"; import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; The expand-all feature exposes methods both on any item instance and the tree instance to expand or collapse all of its items. When called on the tree instance, it will expand or collapse all items in the tree, otherwise it will only affect all children of the item. When used in conjunction with the [async data loader](https://headless-tree.lukasbach.com/llm/features/async-dataloader.md), the expand-all feature will wait for children to load in during each step, before triggering the expanding of the next level. You can also programmatically abort the expanding process at any time when this happens. When calling the `expandAll` method, you can pass an object with a `current` variable. Setting this to `false` during the expanding progress will cancel the expanding process while it is happening. ```ts const cancelToken = { current: false }; const expand = () => { cancelToken.current = false; tree.expandAll(cancelToken); }; const cancelExpanding = () => { cancelToken.current = true; }; ``` --- # Prop Memoization import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; import {DemoBox} from "../../src/components/demo/demo-box"; By default, React props generated by `tree.getContainerProps()` and `item.getProps()` will not be memoized, and might change their reference during each render as they are generated fresh each time. If you rely on stable props, or need them to be memoized, you can simply include the `propMemoizationFeature` and the props will be memoized automatically. The feature doesn't require any configuration or expose any methods. ## Example Consider this sample, where the render method of each item is intentionally slowed down, resulting in a slow usage experience when expanding or collapsing items: ```ts jsx import type { Meta } from "@storybook/react"; import React, { HTMLProps, forwardRef } from "react"; import { hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import { action } from "@storybook/addon-actions"; import cn from "classnames"; const meta = { title: "React/Guides/Render Performance/Slow Item Renderers", tags: ["feature/propmemoization"], } satisfies Meta; export default meta; // story-start const SlowItem = forwardRef>( (props, ref) => { const start = Date.now(); while (Date.now() - start < 20); // force the component to take 20ms to render action("renderItem")(); return ); }); const MemoizedItem = memo(SlowItem); export const MemoizedSlowItemRenderers = () => { const tree = useTree({ rootItemId: "folder", initialState: { expandedItems: ["folder-1", "folder-2", "folder-3"], }, getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-1item`, `${itemId}-2item`, ], }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, propMemoizationFeature, ], }); return (
{tree.getItems().map((item) => ( ))}
); }; ``` --- # Main Feature import { DemoBox } from "../../src/components/demo/demo-box"; import { FeaturePageHeader } from "../../src/components/docs-page/feature-page-header"; The main feature provides the core functionality of Headless Tree, enabling most functions that are required by other features to work. Similar to the [Tree Feature](https://headless-tree.lukasbach.com/llm/features/tree.md), the main feature is included automatically and does not need to be actively imported. --- # Overview import { DemoBox } from "../../src/components/demo/demo-box"; import {FeaturePageHeader} from "../../src/components/docs-page/feature-page-header"; # Overview ```ts jsx import type { Meta } from "@storybook/react"; import React, { Fragment } from "react"; import { createOnDropHandler, dragAndDropFeature, hotkeysCoreFeature, insertItemsAtTarget, keyboardDragAndDropFeature, removeItemsFromParents, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; import { DemoItem, createDemoData } from "../utils/data"; const meta = { title: "React/Drag and Drop/Comprehensive Sample", tags: ["feature/dnd", "homepage"], } satisfies Meta; export default meta; const { syncDataLoader, data } = createDemoData(); let newItemId = 0; const insertNewItem = (dataTransfer: DataTransfer) => { const newId = `new-${newItemId++}`; data[newId] = { name: dataTransfer.getData("text/plain"), }; return newId; }; // story-start export const ComprehensiveSample = () => { const tree = useTree({ initialState: { expandedItems: ["fruit"], selectedItems: ["banana", "orange"], }, rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: (item) => !!item.getItemData().children, canReorder: true, onDrop: createOnDropHandler((item, newChildren) => { data[item.getId()].children = newChildren; }), onDropForeignDragObject: (dataTransfer, target) => { const newId = insertNewItem(dataTransfer); insertItemsAtTarget([newId], target, (item, newChildrenIds) => { data[item.getId()].children = newChildrenIds; }); }, onCompleteForeignDrop: (items) => removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }), createForeignDragObject: (items) => ({ format: "text/plain", data: items.map((item) => item.getId()).join(","), }), canDropForeignDragObject: (_, target) => target.item.isFolder(), indent: 20, dataLoader: syncDataLoader, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return ( <>
{tree.getItems().map((item) => ( ))}
{ e.dataTransfer.setData("text/plain", "hello world"); }} > Drag me into the tree!
{ alert(JSON.stringify(e.dataTransfer.getData("text/plain"))); console.log(e.dataTransfer.getData("text/plain")); }} onDragOver={(e) => e.preventDefault()} > Drop items here!
); }; ``` The Drag-And-Drop Feature provides drag-and-drop capabilities. It allows to drag a single tree item (or many of the [selection feature](https://headless-tree.lukasbach.com/llm/features/selection.md) is included in the tree config) and drop it somewhere else in the tree. The feature also allows you to create interactions between the tree and external drag objects, allowing you to drag tree items out of the tree, or foreign data objects from outside the tree inside. As extension of that, this can also be used to implement drag behavior between several trees. This page explains the general idea of how Dnd works in Headless Tree, and how to get started with dragging items within a tree. The guides following after will go into more depth about - How to [drag external data into your tree](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md), or [tree items out to external drop targets](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md) - How to [handle drag events between multiple trees](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md) on the same page - Customizing [which items can be dragged](https://headless-tree.lukasbach.com/llm/dnd/customizability.md) or [dropped on](https://headless-tree.lukasbach.com/llm/dnd/customizability.md) - More details on the DnD behavior of Headless Tree ## Configuring Drag and Drop The gist of configuring Drag and Drop in Headless Tree, is to - add the `dragAndDropFeature` to the tree config, - define the indent of your tree items in the config variable `indent`, and - handle the `onDrop` event in the tree config. Note that the `indent` property is not required in the core tree configuration, since you can just use a custom indentation when rendering your items. For Drag and Drop it is required however to determine the drop position when the user is trying to [reparent an item](https://headless-tree.lukasbach.com/llm/dnd/behavior.md). The Dnd Feature also exposes a Dnd State that you can [handle yourself if you want to](https://headless-tree.lukasbach.com/llm/guides/state.md). ```jsx typescript import { syncDataLoaderFeature, dragAndDropFeature, selectionFeature, } from "@headless-tree/core"; const tree = useTree({ indent: 20, canReorder: true, onDrop: (items, target) => { // handle drop }, features: [ syncDataLoaderFeature, selectionFeature, dragAndDropFeature, ], }); ``` ## The Drop Event When the user drops tree items onto a target within the tree, the `onDrop` event is called with the list of items that were dragged, and a drop target. This drop target is an object that contains either - the `item` that is dropped to, if the user dragged on the title of an item with the intent to make it a child of that item or, if the user dragged between items while the `canReorder` config option is set to true: - the `item` that should become the new parent - the `childIndex`, describing the index within the `item` where the user dropped - the `insertionIndex`, describing the index within the `item` where the items should be placed (see details below) - `dragLineIndex` and `dragLineLevel` describing the position of the drag line that is rendered while dragging :::info Difference between `childIndex` and `insertionIndex` `childIndex` describes the visual location inside the item that is being dropped into, where the user is dragging the items to. `insertionIndex` on the other hand also respects the special case that the user might drag items within the same folder from further up in a folder, to further down in the same folder. If items are dragged from a different location into a new folder, `childIndex` and `insertionIndex` will be the same. However, if the two top-most items in a folder are dragged two items down, `childIndex` will be 4, but `insertionIndex` will be 2, since removing those two items will shift the items up by two. ::: ## Rendering a Tree with Drag and Drop The only difference between rendering a normal HT Tree, and rendering one with Drag and Drop capabilities, is that you need to render a drag line that shows the user where the items will be dropped. You just need to render an additional div with the style `tree.getDragLineStyle()`, and style it however you want. The computed stylings will automatically add `display: none` if the user is not currently dragging items, and will compute relative positioning based on the item rendered with `tree.getContainerProps()`. Below are examples of how to render a tree with Drag and Drop capabilities, as well as the exemplary CSS stylings used in the demo. ```jsx
{tree.getItems().map((item) => ( ))}
``` ```css .dragline { height: 2px; margin-top: -1px; background-color: #00aff4; } /* Render the circle at the left of the dragline */ .dragline::before { content: ""; position: absolute; left: 0; top: -3px; height: 4px; width: 4px; background: #fff; border: 2px solid #00aff4; border-radius: 99px; } ``` ## Mutating your data after the Drop When the drop event is registered with `onDrop`, you will likely want to mutate your data source to reflect the new order of items after the drag. Headless Tree provides three utility methods that make this very easy: - `removeItemsFromParents(movedItems, onChangeChildren)`: Calls the provided `onChangeChildren` handler to remove the items defined in the first argument from their parents. - `insertItemsAtTarget(itemIds, target, onChangeChildren)`: Calls the provided `onChangeChildren` handler to insert the items defined in the first argument at the target defined in the second argument. - `createOnDropHandler(onChangeChildren)`: Combines the two methods above to create a drop handler that removes the items from their parents and inserts them at the target. This can directly be passed to the `onDrop` config option. `itemIds` and `movedItems` are arrays of item instances, and thus the same type as what the `onDrop` config option provides. Similarly, `target` is the same type as the drop target that is provided in the `onDrop` event. In most situations, it is sufficient to call `createOnDropHandler` with a function that mutates your data source, and use that to handle drop events of items within a single tree. The other methods are useful for interactions between several trees (imagine items being removed from one tree and then inserted in another), handling cases where foreign data is dragged into a tree to create a new tree item or tree items being dragged out of the tree to external drop targets, or handling other more complicated use cases. ```jsx typescript import { createOnDropHandler } from "@headless-tree/core"; const tree = useTree({ indent: 20, canReorder: true, onDrop: createOnDropHandler((item, newChildren) => { myData[item.getId()].children = newChildren; }), features: [ syncDataLoaderFeature, selectionFeature, dragAndDropFeature, ], }); ``` ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { createOnDropHandler, dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Drag and Drop/On Drop Handler", tags: ["feature/dnd"], } satisfies Meta; export default meta; // story-start type Item = { name: string; children?: string[]; }; const data: Record = { root: { name: "Root", children: ["lunch", "dessert"] }, lunch: { name: "Lunch", children: ["sandwich", "salad", "soup"] }, sandwich: { name: "Sandwich" }, salad: { name: "Salad" }, soup: { name: "Soup", children: ["tomato", "chicken"] }, tomato: { name: "Tomato" }, chicken: { name: "Chicken" }, dessert: { name: "Dessert", children: ["icecream", "cake"] }, icecream: { name: "Icecream" }, cake: { name: "Cake" }, }; export const OnDropHandler = () => { const tree = useTree({ initialState: { expandedItems: ["lunch", "dessert", "soup"], selectedItems: ["tomato", "chicken"], }, rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: (item) => !!item.getItemData().children, canReorder: true, onDrop: createOnDropHandler((item, newChildren) => { item.getItemData().children = newChildren; }), indent: 20, dataLoader: { getItem: (id) => data[id], getChildren: (id) => data[id]?.children ?? [], }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return (
{tree.getItems().map((item) => ( ))}
); }; ``` :::info In the Demo above, switch to the sample "Multiple Trees" in the selection box to see a sample of how to use the `removeItemsFromParents` and `insertItemsAtTarget` methods instead of the `createOnDropHandler` method. ::: ## Updated caches in async data loaders :::info You can ignore this when using `removeItemsFromParents`, `removeItemsFromParents` or `createOnDropHandler` to handle the drop event, as those handlers do this for you. ::: When you handle the drop event yourself while using the async data loader, note that the async data loader caches tree data and does not automatically update the cache when you mutate the data source. When you update the childrens assigned to an item as response to a drop event, you can update the cache with `item.updateCachedChildrenIds(children: string[])`: ```typescript // Update your data source myData[parent.getId()].children = newChildren; // Update cache of async data loader parent.updateCachedChildrenIds(newChildren); // Trigger the recomputation of the internal tree structure tree.rebuildTree(); ``` ## Allowing users to drag items via keyboard If you have implemented features that can only be triggered via drag-interactions on your tree, this can be a limitation for users unable to use mouse interactions. Headless Tree supports full keyboard interactions by using the [Keyboard Drag-and-Drop Feature](https://headless-tree.lukasbach.com/llm/features/kdnd.md), which adds all abilities that are usually achieved with mouse-based drag events to keyboard hotkeys and descriptive texts visible only to screen readers. The effort to enable this feature is very low, find out more about this on the [Guide to Keyboard Drag-and-Drop](https://headless-tree.lukasbach.com/llm/features/kdnd.md). --- # Dragging in or out of tree import {DemoBox} from "../../src/components/demo/demo-box"; # Dragging in or out of tree With the `createOnDropHandler` method, it is fairly easy to set up drag-and-drop to allow users to drag items within a tree. With some additional effort, it is also possible to implement the ability to drag items out of the tree, optionally removing them from the tree on the drop event, or dragging foreign objects from outside into the tree, inserting them as newly created tree items. This can also be combined to make the tree interact with other instances of Headless Tree on the same page or different locations in the application. Headless Tree introduces a concept called *foreign drag objects*, which are is data that can be transferred using the [DataTransfer Browser API](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer) to attach information to drag events. Dragging data from outside the tree inside works by attaching your data to the drag event, and letting Headless Tree know how to interpret whether it can handle this data as a tree item, and how. Similarly, dragging data out works by letting Headless Tree know how to attach selected items into the drag event, and then handling the drag event elsewhere in your app. ## Dragging tree items out of tree The gist of allowing users to drag tree items out of the tree, is to just implement the `createForeignDragObject` method in the tree config. When users will start dragging items, this will be called with the items to attach arbitrary information to the [dataTransfer object](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer) of the drag event, that you can then handle elsewhere. Optionally, you can also implement the `onCompleteForeignDrop` to handle the event that the user has dropped the item outside of the tree. If a drop target has accepted the dropped data, this method will be called, making it easy to handle the finalization of the drag within Headless Tree, e.g. by removing the items that where dragged out of the tree. This can easily be achieved using the `removeItemsFromParents(movedItems, onChangeChildren)` method introduced in the previous guide. ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { createOnDropHandler, dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, removeItemsFromParents, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; import { DemoItem, createDemoData } from "../utils/data"; const meta = { title: "React/Drag and Drop/Drag Outside", tags: ["feature/dnd"], } satisfies Meta; export default meta; const { data, syncDataLoader } = createDemoData(); // story-start export const DragOutside = () => { const [state, setState] = useState({}); const tree = useTree({ state, setState, rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: (item) => !!item.getItemData().children, canReorder: true, onDrop: createOnDropHandler((item, newChildren) => { data[item.getId()].children = newChildren; }), createForeignDragObject: (items) => ({ format: "text/plain", data: `custom foreign drag object: ${items .map((item) => item.getId()) .join(",")}`, }), indent: 20, dataLoader: syncDataLoader, onCompleteForeignDrop: (items) => { removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }); }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return ( <>
{tree.getItems().map((item) => ( ))}
alert( `Drop, dataTransfer payload is ${JSON.stringify( e.dataTransfer.getData("text/plain"), )}`, ) } onDragOver={(e) => e.preventDefault()} > Drop items here!
); }; ``` To support this use case with keyboard navigation, you can use the [Keyboard Drag and Drop feature](https://headless-tree.lukasbach.com/llm/features/kdnd.md) and follow the [guide on dragging items out of trees with keyboard controls](https://headless-tree.lukasbach.com/llm/features/kdnd.md). ## Dragging foreign objects inside of tree Allowing users to drag data into the tree from outside consists of two steps: - Implement the `canDropForeignDragObject` method in the tree config, to determine whether the tree can handle the data that is being dragged. It will be called on every viable drop target location with the `dataTransfer` object of the drag event and the target drop location, and should return a boolean indicating whether the data can be dropped there. - Implement the `onDropForeignDragObject` method in the tree config, to handle the drop event when the user has dropped the data. This method will be called with the `dataTransfer` object of the drag event and the target drop location. The `onDropForeignDragObject` method can be used to create new tree items from the data that was dragged in. Again, the previously introduced `insertItemsAtTarget` method can be used to insert items at the target location after you created them based on the data from the `dataTransfer` object. In the sample below, the `onDropForeignDragObject` method is implemented to create a single new tree item, and then insert it at the target location with the `insertItemsAtTarget` method. ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { createOnDropHandler, dragAndDropFeature, hotkeysCoreFeature, insertItemsAtTarget, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; import { DemoItem, createDemoData } from "../utils/data"; const meta = { title: "React/Drag and Drop/Drag Inside", tags: ["feature/dnd"], } satisfies Meta; export default meta; const { syncDataLoader, data } = createDemoData(); let newItemId = 0; // story-start export const DragInside = () => { const [state, setState] = useState({}); const tree = useTree({ state, setState, rootItemId: "root", getItemName: (item) => item.getItemData().name, isItemFolder: (item) => !!item.getItemData().children, canReorder: true, onDrop: createOnDropHandler((item, newChildren) => { data[item.getId()].children = newChildren; }), onDropForeignDragObject: (dataTransfer, target) => { const newId = `new-${newItemId++}`; data[newId] = { name: dataTransfer.getData("text/plain"), }; insertItemsAtTarget([newId], target, (item, newChildrenIds) => { data[item.getId()].children = newChildrenIds; }); alert( `Dropped external data with payload "${JSON.stringify( dataTransfer.getData("text/plain"), )}" on ${JSON.stringify(target)}`, ); }, canDropForeignDragObject: (_, target) => target.item.isFolder(), indent: 20, dataLoader: syncDataLoader, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return ( <>
{tree.getItems().map((item) => ( ))}
{ e.dataTransfer.setData("text/plain", "hello world"); }} > Drag me into the tree!
); }; ``` To support this use case with keyboard navigation, you can use the [Keyboard Drag and Drop feature](https://headless-tree.lukasbach.com/llm/features/kdnd.md) and follow the [guide on starting keyboard-controlled drags from outside with custom drag data](https://headless-tree.lukasbach.com/llm/features/kdnd.md). ## Interactions between multiple instances of Headless Tree The concepts introduced above can be combined to allow interactions between multiple instances of Headless Tree. The following sample demonstrates how to set up two trees that can interact with each other, allowing users to drag items from one tree to the other. ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { dragAndDropFeature, hotkeysCoreFeature, insertItemsAtTarget, keyboardDragAndDropFeature, removeItemsFromParents, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Guides/Multiple Trees", tags: ["feature/dnd", "homepage"], } satisfies Meta; export default meta; // story-start type Item = { name: string; children?: string[]; }; const data: Record = { root1: { name: "Root", children: ["lunch", "dessert"] }, root2: { name: "Root", children: ["solar", "centauri"] }, lunch: { name: "Lunch", children: ["sandwich", "salad", "soup"] }, sandwich: { name: "Sandwich" }, salad: { name: "Salad" }, soup: { name: "Soup", children: ["tomato", "chicken"] }, tomato: { name: "Tomato" }, chicken: { name: "Chicken" }, dessert: { name: "Dessert", children: ["icecream", "cake"] }, icecream: { name: "Icecream" }, cake: { name: "Cake" }, solar: { name: "Solar System", children: ["jupiter", "earth", "mars", "venus"], }, jupiter: { name: "Jupiter", children: ["io", "europa", "ganymede"] }, io: { name: "Io" }, europa: { name: "Europa" }, ganymede: { name: "Ganymede" }, earth: { name: "Earth", children: ["moon"] }, moon: { name: "Moon" }, mars: { name: "Mars" }, venus: { name: "Venus" }, centauri: { name: "Alpha Centauri", children: ["rigilkent", "toliman", "proxima"], }, rigilkent: { name: "Rigel Kentaurus" }, toliman: { name: "Toliman" }, proxima: { name: "Proxima Centauri" }, }; const Tree = (props: { root: string; prefix: string }) => { const tree = useTree({ rootItemId: props.root, dataLoader: { getItem: (id) => data[id], getChildren: (id) => data[id]?.children ?? [], }, getItemName: (item) => item.getItemData().name, isItemFolder: (item) => item.getItemData().children !== undefined, canReorder: true, indent: 20, // onDrop is only called when moving items WITHIN one tree. // This handles the entire move operation. // Normally you can use `createOnDropHandler` for that, the following just // demonstrates how to do with the individual handlers. onDrop: async (items, target) => { const itemIds = items.map((item) => item.getId()); await removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }); await insertItemsAtTarget(itemIds, target, (item, newChildren) => { item.getItemData().children = newChildren; }); }, // When moving items out of the tree, this is used to serialize the // dragged items as foreign drag object createForeignDragObject: (items) => ({ format: "text/plain", data: JSON.stringify(items.map((item) => item.getId())), }), // This is called in the target tree to verify if foreign drag objects // are permitted to be dropped there. canDropForeignDragObject: () => true, // This is called in the target tree when the foreign drag object is // dropped. This handler inserts the moved items onDropForeignDragObject: (dataTransfer, target) => { const newChildrenIds = JSON.parse(dataTransfer.getData("text/plain")); insertItemsAtTarget(newChildrenIds, target, (item, newChildren) => { item.getItemData().children = newChildren; }); }, // This is called in the source tree when the foreign drag is completed. // This handler removes the moved items from the source tree. onCompleteForeignDrop: (items) => { removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }); }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return (
{tree.getItems().map((item) => ( ))}
); }; export const MultipleTrees = () => { return ( <>

In this sample, both trees share a datasource and have different items defined as roots, which makes dragging easy to implement since moving items just means updating children IDs. The Story "Advanced Multiple Trees" shows how to implement multiple trees with different datasources.

); }; ``` The sample above incorporates a simple use case where both trees have the same data source, meaning that it is sufficient to update child IDs of the parents of the moved items to reflect the data after the mutation. In some use cases, several trees make use of distinct data sources, so you will need to make sure to also add the target data source to create the newly moved items in there: ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { TreeInstance, dragAndDropFeature, hotkeysCoreFeature, insertItemsAtTarget, keyboardDragAndDropFeature, removeItemsFromParents, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Guides/Advanced Multiple Trees", tags: ["feature/dnd", "homepage"], } satisfies Meta; export default meta; // story-start type Item = { name: string; children?: string[]; }; const data1: Record = { root: { name: "Root", children: ["lunch", "dessert"] }, lunch: { name: "Lunch", children: ["sandwich", "salad", "soup"] }, sandwich: { name: "Sandwich" }, salad: { name: "Salad" }, soup: { name: "Soup", children: ["tomato", "chicken"] }, tomato: { name: "Tomato" }, chicken: { name: "Chicken" }, dessert: { name: "Dessert", children: ["icecream", "cake"] }, icecream: { name: "Icecream" }, cake: { name: "Cake" }, }; const data2: Record = { root: { name: "Root", children: ["solar", "centauri"] }, solar: { name: "Solar System", children: ["jupiter", "earth", "mars", "venus"], }, jupiter: { name: "Jupiter", children: ["io", "europa", "ganymede"] }, io: { name: "Io" }, europa: { name: "Europa" }, ganymede: { name: "Ganymede" }, earth: { name: "Earth", children: ["moon"] }, moon: { name: "Moon" }, mars: { name: "Mars" }, venus: { name: "Venus" }, centauri: { name: "Alpha Centauri", children: ["rigilkent", "toliman", "proxima"], }, rigilkent: { name: "Rigel Kentaurus" }, toliman: { name: "Toliman" }, proxima: { name: "Proxima Centauri" }, }; /* Return all IDs of items within the given items, even deeply nested ones */ const resolveNestedItems = ( tree: TreeInstance, items: string[], ): string[] => { if (items.length === 0) return []; const immediateChildren = items .map(tree.getConfig().dataLoader.getChildren) .flat() as string[]; const nestedChildren = resolveNestedItems(tree, immediateChildren); return [...items, ...nestedChildren]; }; const Tree = (props: { data: Record; prefix: string }) => { const [data, setData] = useState(props.data); const tree: TreeInstance = useTree({ rootItemId: "root", dataLoader: { getItem: (id) => data[id], getChildren: (id) => data[id]?.children ?? [], }, getItemName: (item) => item.getItemData().name, isItemFolder: (item) => item.getItemData().children !== undefined, canReorder: true, indent: 20, // onDrop is only called when moving items WITHIN one tree. // This handles the entire move operation. // Normally you can use `createOnDropHandler` for that, the following just // demonstrates how to do with the individual handlers. onDrop: async (items, target) => { const itemIds = items.map((item) => item.getId()); await removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }); await insertItemsAtTarget(itemIds, target, (item, newChildren) => { item.getItemData().children = newChildren; }); }, // When moving items out of the tree, this is used to serialize the // dragged items as foreign drag object. // Since the trees have distinct data sources, we provide the necessary // information to move the items to the new data source as well. createForeignDragObject: (items) => { const nestedItems = resolveNestedItems( tree, items.map((item) => item.getId()), ); return { format: "text/plain", data: JSON.stringify({ items: items.map((item) => item.getId()), nestedItems: Object.fromEntries( nestedItems.map((id) => [id, props.data[id]]), ), }), }; }, // This is called in the target tree to verify if foreign drag objects // are permitted to be dropped there. canDropForeignDragObject: () => true, // This is called in the target tree when the foreign drag object is // dropped. This handler inserts the moved items, and also injects // the item data into the new data source. onDropForeignDragObject: (dataTransfer, target) => { const { items, nestedItems } = JSON.parse( dataTransfer.getData("text/plain"), ); setData({ ...data, ...nestedItems }); insertItemsAtTarget(items, target, (item, newChildren) => { item.getItemData().children = newChildren; }); }, // This is called in the source tree when the foreign drag is completed. // This handler removes the moved items from the source tree. onCompleteForeignDrop: (items) => { removeItemsFromParents(items, (item, newChildren) => { item.getItemData().children = newChildren; }); }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return (
{tree.getItems().map((item) => ( ))}
); }; export const AdvancedMultipleTrees = () => { return ( <>

This is a more complicated use case than the normal "Multiple Trees" story. In this case, several trees each have their own dedicated data source. When items are dragged from one tree to the other, all data of every nested item that is part of the selection is sent in the drag event, and attached to the new data source in the target tree.

); }; ``` --- # Customizability import { DemoBox } from "../../src/components/demo/demo-box"; # Customizability The Drag and Drop Feature provides several options to customize the behavior of the drag-and-drop interaction, including several ways to restrict what and when users can drag and drop items. ## No reordering Set the config option `canReorder` to false, to disable users from being able to choose arbitrary drop locations within a specific item. Drag lines will not be rendered, and can be omitted from the render implementation. Dragging an item between several items will either always target the specific item that is hovered over as new parent, or the parent of the item that is currently being hovered over if the direct hover target is not a folder. The [Drop event described earlier](https://headless-tree.lukasbach.com/llm/dnd/overview.md) will always contain only an item as target, never `childIndex`, `insertionIndex`, `dragLineIndex` and `dragLineLevel`. ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { TreeState, dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Drag and Drop/Cannot Drop Inbetween", tags: ["feature/dnd"], } satisfies Meta; export default meta; // story-start export const CannotDropInbetween = () => { const [state, setState] = useState>>({ expandedItems: ["root-1", "root-1-2"], }); const tree = useTree({ state, setState, rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: (item) => item.getItemMeta().level < 2, canReorder: false, onDrop: (items, target) => { alert( `Dropped ${items.map((item) => item.getId(), )} on ${target.item.getId()}, ${JSON.stringify(target)}`, ); }, indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-4`, `${itemId}-5`, `${itemId}-6`, ], }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return (
{tree.getItems().map((item) => ( ))}
); }; ``` ## Limiting which items can be dragged Implement a `canDrag` handler that specifies which items can be dragged. This will be called everytime the user tries to start dragging items, and will pass the items to drag as parameter. Returning false in the handler will prevent the drag from starting. ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Drag and Drop/Can Drag", tags: ["feature/dnd", "basic"], } satisfies Meta; export default meta; // story-start export const CanDrag = () => { const [state, setState] = useState({}); const tree = useTree({ state, setState, rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: () => true, canReorder: true, canDrag: (items) => items.every( (i) => i.getItemName().endsWith("1") || i.getItemName().endsWith("2"), ), onDrop: (items, target) => { alert( `Dropped ${items.map((item) => item.getId(), )} on ${target.item.getId()}, ${JSON.stringify(target)}`, ); }, indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-4`, `${itemId}-5`, `${itemId}-6`, ], }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return ( <>

Only items that end with 1 or 2 can be dragged.

{tree.getItems().map((item) => ( ))}
); }; ``` ## Limiting where users can drop items Similarly, implement a `canDrop` handler that specifies where items can be dropped. This will be called everytime the user drags items over a potential drop target (not on every single mousemove event, just when a different drop target is hovered on), and will pass the items to drop and the target as parameters. Returning false in the handler will prevent the drop from finalizing and calling the `onDrop` method, as well as visually indicating that the drop is not allowed at that location. Note that this doesn't concern [dragging foreign data inside](https://headless-tree.lukasbach.com/llm/dnd/foreign-dnd.md), which can be controlled with `canDropForeignDragObject` instead. ```ts jsx import type { Meta } from "@storybook/react"; import React, { useState } from "react"; import { dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { AssistiveTreeDescription, useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Drag and Drop/Can Drop", tags: ["feature/dnd", "basic"], } satisfies Meta; export default meta; // story-start export const CanDrop = () => { const [state, setState] = useState({}); const tree = useTree({ state, setState, rootItemId: "root", getItemName: (item) => item.getItemData(), isItemFolder: () => true, canReorder: true, // TODO! invert for error, still allows to drop even if drop is not shown canDrop: (items, { item }) => item.getItemName().endsWith("1") || item.getItemName().endsWith("2"), onDrop: (items, target) => { alert( `Dropped ${items.map((item) => item.getId(), )} on ${target.item.getId()}, ${JSON.stringify(target)}`, ); }, indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-4`, `${itemId}-5`, `${itemId}-6`, ], }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return ( <>

Only on items that end with 1 or 2 can be dropped on.

{tree.getItems().map((item) => ( ))}
); }; ``` --- # Details on Dnd Behavior ## Reparenting For items dragged on the lower half of the bottom-most item of a tree, the specific target folder is chosen based on horizontal offset of the dragged item. This is called "Reparenting". !Drag and Drop Reparenting Demo --- # Handling external state updates Generally, Headless Tree loads the tree structure once when it is loaded, and loads subsequent substructures whenever parts of the visual tree change, e.g. because a folder is expanded that was collapsed before. Headless Tree provides ways to mutate the tree via drag events or renaming, and makes sure that it reloads those parts that have changed automatically if one of `removeItemsFromParents()`, `insertItemsAtTarget()` or `createOnDropHandler()` was used (see [the Guide on mutating your data after the Drop](https://headless-tree.lukasbach.com/llm/dnd/overview.md) for details). However, in many cases you will want to update the tree structure of your data based on external events. In this case you need to inform Headless Tree, that the tree structure has changed, so that it can update the rendering of the affected parts. ## Synchronous Trees For Trees using the [`syncDataLoader`](https://headless-tree.lukasbach.com/llm/features/sync-dataloader.md) feature, the gist of data loading is as follows: Headless Tree maintains an internal representation of a flattened view of the tree, that is generated based on the `dataLoader` information that is available when the tree renders. This information is regenerated whenever `tree.rebuildTree()` is called, which is done automatically when items are expanded/collapsed or drag events are fulfilled with one of the aforementioned drag-related update-methods. The react component then needs to re-render for the visual changes to come into affect, which is also done automatically in those cases since `tree.rebuildTree()` triggers an update to the tree state which will also re-render the component. If you mutate the data, you will need to call `tree.rebuildTree()` manually, so that Headless Tree can update the internal representation of the tree. Make sure that this call happens *after* the data loader has access to the mutated data. ```ts const items = { item1: { name: "Item 1", children: ["item2"] }, item2: { name: "Item 2", children: [] }, } const dataLoader = { getItem: (id: string) => items[id], getChildren: (id: string) => items[id].children, }; const addNode = () => { items["item1"].children.push("item3"); items["item3"] = { name: "Item 3", children: [] }; tree.rebuildTree(); }; ``` ## Asynchronous Trees For asynchronous trees, the general gist of how trees are updated is the same, however the `asyncDataLoaderFeature` provides the additional functionality of caching item information of tree data, which means that just triggering `tree.rebuildTree()` will do nothing as it will just keep using the stale information before the update. When using the `asyncDataLoaderFeature`, additional methods are available on item instances to trigger them to invalidate their data: ```ts const item = tree.getItemInstance("item1"); item.invalidateItemData(); item.invalidateChildrenIds(); ``` You will not have to additionally call `tree.rebuildTree()`, as this is done automatically by the async data loader feature once it has refetched the data. --- # Handling expensive Components import { DemoBox } from "../../src/components/demo/demo-box"; Sometimes, rendering tree items can be expensive. This can cause slow usage of the Tree react component, since all items are rendered as part of a single flat list, and mutations to the displayed tree structure, such as expanding or collapsing items or changing the tree will trigger a re-render of all items. ```ts jsx import type { Meta } from "@storybook/react"; import React, { HTMLProps, forwardRef } from "react"; import { hotkeysCoreFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import { action } from "@storybook/addon-actions"; import cn from "classnames"; const meta = { title: "React/Guides/Render Performance/Slow Item Renderers", tags: ["feature/propmemoization"], } satisfies Meta; export default meta; // story-start const SlowItem = forwardRef>( (props, ref) => { const start = Date.now(); while (Date.now() - start < 20); // force the component to take 20ms to render action("renderItem")(); return ); }); const MemoizedItem = memo(SlowItem); export const MemoizedSlowItemRenderers = () => { const tree = useTree({ rootItemId: "folder", initialState: { expandedItems: ["folder-1", "folder-2", "folder-3"], }, getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-1item`, `${itemId}-2item`, ], }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, propMemoizationFeature, ], }); return (
{tree.getItems().map((item) => ( ))}
); }; ``` You can further improve performance on large trees by making use of Virtualization, which is explained in the [Virtualization Guide](https://headless-tree.lukasbach.com/llm/recipe/virtualization.md). --- # Virtualization import { DemoBox } from "../../src/components/demo/demo-box"; Large trees can have a significant impact on render and usage performance, and cause slow interactions. Virtualization is a concept, where the performance hit of a very large list of items is mitigated by only rendering the items that are currently visible in the viewport. While this is not trivial to do with nested structures like trees, Headless Tree makes this easy by flattening the tree structure and providing the tree items as flat list. Virtualization is not a included feature of Headless Tree, but you can easily pass this flat list to any virtualization library of your choice, and use that to create a tree that only renders the visible items. In the sample below, `react-virtual` is used to virtualize the tree and render 100k items while still being performant in rendering and interaction. ```ts jsx import type { Meta } from "@storybook/react"; import React, { forwardRef, useImperativeHandle, useRef, useState, } from "react"; import { TreeState, buildProxiedInstance, buildStaticInstance, dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"; import cn from "classnames"; import { PropsOfArgtype } from "../argtypes"; const meta = { title: "React/Scalability/Basic Virtualization", tags: ["homepage"], argTypes: { itemsPerLevel: { type: "number", }, openLevels: { type: "number", }, useProxyInstances: { type: "boolean", }, }, args: { itemsPerLevel: 10, openLevels: 4, useProxyInstances: true, }, } satisfies Meta; export default meta; // story-start const getInitiallyExpandedItemIds = ( itemsPerLevel: number, openLevels: number, prefix = "folder", ): string[] => { if (openLevels === 0) { return []; } const expandedItems: string[] = []; for (let i = 0; i < itemsPerLevel; i++) { expandedItems.push(`${prefix}-${i}`); } return [ ...expandedItems, ...expandedItems.flatMap((itemId) => getInitiallyExpandedItemIds(itemsPerLevel, openLevels - 1, itemId), ), ]; }; const Inner = forwardRef, any>( ({ tree }, ref) => { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: tree.getItems().length, getScrollElement: () => parentRef.current, estimateSize: () => 27, }); useImperativeHandle(ref, () => virtualizer); return (
{virtualizer.getVirtualItems().map((virtualItem) => { const item = tree.getItems()[virtualItem.index]; return ( ); })}
); }, ); export const BasicVirtualization = ({ itemsPerLevel, openLevels, useProxyInstances, }: PropsOfArgtype) => { const virtualizer = useRef | null>(null); const [state, setState] = useState>>(() => ({ expandedItems: getInitiallyExpandedItemIds(itemsPerLevel, openLevels), })); const tree = useTree({ instanceBuilder: useProxyInstances ? buildProxiedInstance : buildStaticInstance, state, setState, rootItemId: "folder", getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), scrollToItem: (item) => { virtualizer.current?.scrollToIndex(item.getItemMeta().index); }, canReorder: true, indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => { const items: string[] = []; for (let i = 0; i < itemsPerLevel; i++) { items.push(`${itemId}-${i}`); } return items; }, }, features: [ syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, dragAndDropFeature, keyboardDragAndDropFeature, ], }); return ; }; ``` :::warning You likely will want to use proxified item instances instead of static item instances when using trees with many items. Read this guide to learn more about [Proxy Item Instances](https://headless-tree.lukasbach.com/llm/recipe/proxy-instances.md). You can use them by setting the `instanceBuilder` tree config option to `buildProxiedInstance`, a symbol that you can import from `@headless-tree/core`. ::: If you need to need a ref to the virtualized DOM items, keep in mind that `treeItem.getProps()` also returns a ref that needs to be assigned to the DOM element. Also keep in mind that the ref function of a DOM element is called both on mount and unmount, and calling `treeItem.getProps()` will fail if the item is already unloaded in the tree. Calling `treeItem.getProps()` outside the ref, like the following, will work: ```ts jsx const props = item.getProps(); return ( ))}
); }; ``` --- # Custom Click Behavior import {DemoBox} from "../../src/components/demo/demo-box"; The default behavior of Headless Tree resembles the UX behavior of a VSCode Filetree: Clicking on an item will both focus it and make it the sole selection. Control-clicking will add the item to an existing selection. If the clicked item is also a folder, it will toggle its expanded-state. This can be customized very easily by overwriting the generation of the `onClick` (and `onDoubleClick` if you want) handlers for generated props of tree items. This expands on the idea that was introduced in the [Guide on Plugins](https://headless-tree.lukasbach.com/llm/recipe/plugins.md) by allowing you to write a small custom plugin that overwrites the behavior of other Headless Tree core features. ## Expand on Double Click In the following sample, the behavior is changed so that folders are only expanded or collapsed on double-click, and the primary action handler is also bound to double-click instead of the typical single-click. Note that we completely overwrite the `onClick` handler here, so we need to re-implement the selection behavior even if it remains unchanged. We do not need to re-implement other props that are generated for tree items, since we integrate the existing functionality of other features with the `prev?.()` call. If we were to expand the `onClick` behavior instead of overwriting it, we could also expand instead of overwrite the `onClick` handler with `prev?.()?.onClick?.(e)`, as is being done in the [actual implementation of the selection feature](https://github.com/lukasbach/headless-tree/blob/7f50c2e380f3455c4dfd0413e609ba96b1338fe7/packages/core/src/features/selection/feature.ts#L93). ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { FeatureImplementation, dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; const meta = { title: "React/Guides/Click Behavior/Expand On Double Click", tags: ["guide/clickbehavior", "basic", "homepage"], } satisfies Meta; export default meta; // story-start const customClickBehavior: FeatureImplementation = { itemInstance: { getProps: ({ tree, item, prev }) => ({ ...prev?.(), onDoubleClick: (e: MouseEvent) => { item.primaryAction(); if (!item.isFolder()) { return; } if (item.isExpanded()) { item.collapse(); } else { item.expand(); } }, onClick: (e: MouseEvent) => { if (e.shiftKey) { item.selectUpTo(e.ctrlKey || e.metaKey); } else if (e.ctrlKey || e.metaKey) { item.toggleSelect(); } else { tree.setSelectedItems([item.getItemMeta().itemId]); } item.setFocused(); }, }), }, }; export const ExpandOnDoubleClick = () => { const tree = useTree({ rootItemId: "folder", getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-1item`, `${itemId}-2item`, ], }, onDrop: console.log, features: [ syncDataLoaderFeature, selectionFeature, dragAndDropFeature, keyboardDragAndDropFeature, hotkeysCoreFeature, customClickBehavior, ], }); return (
{tree.getItems().map((item) => (
))}
); }; ``` ## Expand on Arrow-Click Similar to the previous sample, in the following code snippet we completely remove the expand/collapse behavior from the `onClick` handler of tree items, and render dedicated arrow buttons that can be clicked to expand or collapse the tree items instead. ```ts jsx import type { Meta } from "@storybook/react"; import React from "react"; import { FeatureImplementation, dragAndDropFeature, hotkeysCoreFeature, keyboardDragAndDropFeature, selectionFeature, syncDataLoaderFeature, } from "@headless-tree/core"; import { useTree } from "@headless-tree/react"; import cn from "classnames"; import "./expand-on-arrow-click.css"; const meta = { title: "React/Guides/Click Behavior/Expand On Arrow Click", tags: ["guide/clickbehavior", "basic"], } satisfies Meta; export default meta; // story-start const customClickBehavior: FeatureImplementation = { itemInstance: { getProps: ({ tree, item, prev }) => ({ ...prev?.(), onClick: (e: MouseEvent) => { if (e.shiftKey) { item.selectUpTo(e.ctrlKey || e.metaKey); } else if (e.ctrlKey || e.metaKey) { item.toggleSelect(); } else { tree.setSelectedItems([item.getItemMeta().itemId]); } item.setFocused(); }, }), }, }; export const ExpandOnArrowClick = () => { const tree = useTree({ rootItemId: "folder", getItemName: (item) => item.getItemData(), isItemFolder: (item) => !item.getItemData().endsWith("item"), indent: 20, dataLoader: { getItem: (itemId) => itemId, getChildren: (itemId) => [ `${itemId}-1`, `${itemId}-2`, `${itemId}-3`, `${itemId}-1item`, `${itemId}-2item`, ], }, onDrop: console.log, features: [ syncDataLoaderFeature, selectionFeature, dragAndDropFeature, keyboardDragAndDropFeature, hotkeysCoreFeature, customClickBehavior, ], }); return (
{tree.getItems().map((item) => (
{item.isFolder() && ( )}
))}
); }; ``` --- # Tests # Tests Headless Tree uses an extensive unit test suite for ensuring stable behavior. You can run all tests in the repo with `yarn test` in the root folder. This is the basic anatomy of a unit test suite for a Headless Tree Feature: ```tsx import { describe, expect, it } from "vitest"; import { TestTree } from "../../test-utils/test-tree"; import { selectionFeature } from "../selection/feature"; const factory = TestTree.default({}).withFeatures( selectionFeature, ); describe("core-feature/search", () => { factory.forSuits((tree) => { it("should make isSelected true", () => { tree.do.selectItem("x111"); tree.do.ctrlSelectItem("x112"); expect(tree.instance.getItemInstance("x111").isSelected()).toBe(true); expect(tree.instance.getItemInstance("x112").isSelected()).toBe(true); }); }); }); ``` When creating new features, make sure to test all variations of behavior in individual tests. You can have a look at existing suites as an orientation for how other features are being tested. The `factory` created at the top creates a definition for a test tree that will be used in all tests of that suite. You can customize it in its parameter, as well as with various chained `.withXXX(...)` calls. The `factory.forSuits` wraps a `describe.for()` call with several variations of trees, such as an sync dataloader tree, async dataloader tree, tree with static item builders and tree with proxified item builders. :::note Since Headless Tree Core is technically independent of DOM, there is also no DOM mock like jsdom loaded in the tests. All interactions is made on the tree instance directly, though generated props on items (and some other elements) can be called with either `testTree.instance.getItemInstance(itemId).getProps().onClick()` or `testTree.do.selectItem(itemId)`. ::: # Issues with tests on async trees The Test Tree with an async data loader doesn't automatically resolve all pending data loader promises that are created during rendering. You can force the test tree to resolve all pending promises by calling `await tree.resolveAsyncVisibleItems()` before making any assertions. This is automatically done before each test for the initially rendered tree. # Visually experimenting with the tree You can see and experiment with the tree variant that is used during tests with this storybook instance: - [Async Tree](https://headless-tree.lukasbach.com/storybook/react/?path=/story/react-misc-async-tree-used-in-unit-tests--unit-test-async) - [Sync Tree](https://headless-tree.lukasbach.com/storybook/react/?path=/story/react-misc-sync-tree-used-in-unit-tests--unit-test-sync) # Debugging tests Since unit tests are wrapped with `factory.forSuits`, IDEs usually do not show test-specific run- and debug-options. You can copy an individual test to the top level and change it as follows to create a temporary standalone version of a test that can be run and debugged individually: ```tsx describe("core-feature/search", () => { factory.forSuits((tree) => { it("should make isSelected true", () => { tree.do.selectItem("x111"); tree.do.ctrlSelectItem("x112"); expect(tree.instance.getItemInstance("x111").isSelected()).toBe(true); expect(tree.instance.getItemInstance("x112").isSelected()).toBe(true); }); }); }); ``` to: ```tsx it("test", async () => { // Replace .sync() with the suite you want to test with const tree = await factory.suits.sync().tree.createDebugTree(); tree.do.selectItem("x111"); tree.do.ctrlSelectItem("x112"); expect(tree.instance.getItemInstance("x111").isSelected()).toBe(true); expect(tree.instance.getItemInstance("x112").isSelected()).toBe(true); }); ``` --- # core # @headless-tree/core ## 1.1.0 ### Minor Changes - 64d8e2a: add getChildrenWithData method to data loader to support fetching all children of an item at once - 35260e3: fixed hotkey issues where releasing modifier keys (like shift) before normal keys can cause issues with subsequent keydown events ### Patch Changes - 29b2c64: improved key-handling behavior for hotkeys while input elements are focused (#98) - da1e757: fixed a bug where alt-tabbing out of browser will break hotkeys feature - c283f52: add feature to allow async data invalidation without triggering rerenders with `invalidateItemData(optimistic: true)` (#95) - 29b2c64: added option to completely ignore hotkey events while input elements are focused (`ignoreHotkeysOnInput`) (#98) - cd5b27c: add position:absolute to default styles of getDragLineStyle() ## 1.0.1 ### Patch Changes - c9f9932: fixed tree.focusNextItem() and tree.focusPreviousItem() throwing if no item is currently focused - 6ed84b4: recursive item references are filtered out when rendering (#89) - 4bef2f2: fixed a bug where hotkeys involving shift may not work properly depending on the order of shift and other key inputs (#98) ## 1.0.0 ### Minor Changes - 9e5027b: The propMemoization feature now memoizes all prop-generation related functions, including searchinput and renameinput related props ## 0.0.15 ### Patch Changes - 2af5668: Bug fix: Mutations to expanded tree items from outside will now trigger a rebuild of the tree structure (#65) - 617faea: Support for keyboard-controlled drag-and-drop events ## 0.0.14 ### Patch Changes - 7e702fb: dev release ## 0.0.13 ### Patch Changes - fdaefbc: dev release ## 0.0.12 ### Patch Changes - 7236907: dev release ## 0.0.11 ### Patch Changes - 7ed33ac: dev release ## 0.0.10 ### Patch Changes - 520ec27: test release ## 0.0.9 ### Patch Changes - ab2a124: dev release ## 0.0.8 ### Patch Changes - 076dfc5: dev release ## 0.0.7 ### Patch Changes - 6ec53b3: dev release ## 0.0.6 ### Patch Changes - bc9c446: dev release ## 0.0.5 ### Patch Changes - 751682a: dev release ## 0.0.4 ### Patch Changes - dc6d813: tag pushing ## 0.0.3 ### Patch Changes - 6460368: tree shaking ## 0.0.2 ### Patch Changes - 05e24de: release test --- # react # @headless-tree/react ## 1.1.0 ## 1.0.1 ### Patch Changes - Updated dependencies [c9f9932] - Updated dependencies [6ed84b4] - Updated dependencies [4bef2f2] - @headless-tree/core@1.0.1 ## 1.0.0 ### Patch Changes - Updated dependencies [9e5027b] - @headless-tree/core@1.0.0 ## 0.0.15 ### Patch Changes - 617faea: Support for keyboard-controlled drag-and-drop events - Updated dependencies [2af5668] - Updated dependencies [617faea] - @headless-tree/core@0.0.15 ## 0.0.14 ### Patch Changes - Updated dependencies [7e702fb] - @headless-tree/core@0.0.14 ## 0.0.13 ### Patch Changes - Updated dependencies [fdaefbc] - @headless-tree/core@0.0.13 ## 0.0.12 ### Patch Changes - Updated dependencies [7236907] - @headless-tree/core@0.0.12 ## 0.0.11 ### Patch Changes - 7ed33ac: dev release - Updated dependencies [7ed33ac] - @headless-tree/core@0.0.11 ## 0.0.9 ### Patch Changes - 520ec27: test release - Updated dependencies [520ec27] - @headless-tree/core@0.0.10 ## 0.0.8 ### Patch Changes - ab2a124: dev release - Updated dependencies [ab2a124] - @headless-tree/core@0.0.9 ## 0.0.7 ### Patch Changes - 076dfc5: dev release - Updated dependencies [076dfc5] - @headless-tree/core@0.0.8 ## 0.0.6 ### Patch Changes - 6ec53b3: dev release - Updated dependencies [6ec53b3] - @headless-tree/core@0.0.7 ## 0.0.5 ### Patch Changes - bc9c446: dev release - Updated dependencies [bc9c446] - @headless-tree/core@0.0.6 ## 0.0.4 ### Patch Changes - 751682a: dev release - Updated dependencies [751682a] - @headless-tree/core@0.0.5 ## 0.0.3 ### Patch Changes - 6460368: tree shaking - Updated dependencies [6460368] - @headless-tree/core@0.0.3 ## 0.0.2 ### Patch Changes - 05e24de: release test - Updated dependencies [05e24de] - @headless-tree/core@0.0.2 --- # Demos import { DemoBox } from "../../src/components/demo/demo-box";