Composites
Tree View
An outline people rearrange — by dragging, or from the keyboard the way a word processor indents a list: Tab pushes an item under the one above it, Shift+Tab pulls it back out. Built for the work breakdown in Comitor Tasks — project, phases, work packages, tasks — with outline numbers and columns that add up their branches. The tree is yours, as a flat list; the component draws it, tells you where an item moved, and says so to a screen reader.
- Import
import { TreeView, moveTreeItems, type TreeViewItem } from '@comitor/ui'— the main entry, with no optional peer.@headless-tree/coreand@headless-tree/reactcome in as ordinary dependencies of the package, so there is nothing to install, under pnpm strict and Yarn PnP too.- Client
TreeViewstarts with"use client", and a tree that moves needs state foritemsand a function foronMove— so it lives in a client component of yours. The four helpers do not: they sit in a pure module the barrel re-exports directly, so a Server Action can callmoveTreeItemson the rows it is about to save.- Origin
- New in
3.12.0, built on Headless Tree^1.8.0(MIT, © 2023 Lukas Bach), used as a library, not copied. Headless Tree keeps focus, expansion and selection, the W3C tree keys and drag and drop with re-parenting; the drawing, the outline keys, numbering, rolled-up columns and virtualisation are the package’s. See the changelog.
A work breakdown
A project in Comitor Tasks, broken down four levels deep, numbered, with estimates that add up and progress that averages. Everything moves. Drag a task onto another phase; or click a task, then press Tab to indent it, Shift+Tab to outdent it, Alt+↑ or Alt+↓ to swap it with a sibling — the numbers and the totals follow at once. Enter or a double-click opens an item. The first line under the tree prints what the live region announced.
Use Up and Down arrows to move between items, Right to expand and Left to collapse. Tab indents the item under the one above it, Shift+Tab outdents it. Alt with Up or Down moves the item among its siblings. Enter opens the item.
Live region: —
onItemOpen: —
- Where a drop lands depends on where in the row you let go. On a branch, the top or bottom 30% of the row places the item above or below it and the middle makes it the branch’s last child; on an open branch everything below the top 30% goes inside. A leaf takes nothing inside it: its top half places the item before it, its bottom half after it. On the last row of a group, moving the pointer left of the row’s indentation drops at a shallower level — that is how an item leaves a branch by mouse.
- A move into a closed branch opens it, so the item you just moved stays in view, and focus comes back to it after every move — also when it changed parent and React made a new element for it.
A flat list in, one move out
items is flat: { id, parentId, name }, with the order among siblings given by the order in the array — what a table with parent_id and position returns, so nothing has to be nested on the way in or flattened on the way out. Every rearrangement leaves through onMove as one TreeViewMove — the same shape for a drag and for a key — and moveTreeItems applies it. Move something below and watch both panels.
Use Up and Down arrows to move between items, Right to expand and Left to collapse. Tab indents the item under the one above it, Shift+Tab outdents it. Alt with Up or Down moves the item among its siblings. Enter opens the item.
The last TreeViewMove
— move something: drag, Tab, Shift+Tab, Alt+↑/↓
items after moveTreeItems
[
{ id: "inbox", parentId: null },
{ id: "reply", parentId: "inbox" },
{ id: "venue", parentId: "inbox" },
{ id: "week", parentId: null },
{ id: "agenda", parentId: "week" },
{ id: "invites", parentId: "week" },
{ id: "lunch", parentId: "week" },
{ id: "later", parentId: null },
]indexcounts after removal. It is the position among the new parent’s children once the moving items have been taken out of that list — Headless Tree’s own convention. Alt+↓ on the first of three siblings givesindex: 1, not 2. Apply it with anything other thanmoveTreeItemsand an off-by-one is the first thing to check.- Only sibling order means anything.
moveTreeItemsputs the moved item in front of its new next sibling in the array and leaves its children where they were — so after a few moves the array is no longer depth first, and it does not need to be. Walk it withgroupTreeChildrenwhen you need that order. - One refusal. A move that would put an item inside its own subtree returns the same array, unchanged —
result === items. Dragging cannot produce one (Headless Tree will not drop an item into itself), but a move that arrives late, from another tab or another person, can. - The tree keeps no copy. Until new
itemscome back, nothing has moved on screen. Save after you apply the move, and on failure pass the old array back — the tree follows.
Keyboard
One tab stop for the whole tree: the focused item. The arrow keys follow the W3C tree pattern through Headless Tree; the four outline keys are the package’s, and they only exist with onMove.
| When | Keys | Result |
|---|---|---|
| Moving | ↑↓ | The visible item above or below. The children of a closed branch are skipped. |
| Moving | → | Opens a closed branch. On an open branch or a leaf, moves down one item. |
| Moving | ← | Closes an open branch. On a closed branch or a leaf, goes up to the parent. |
| Moving | HomeEnd | The first or the last visible item. |
| Selecting | Shift↑ ↓ | Moves and extends the selection — or, back over a selected item, takes it out again. |
| Selecting | CtrlSpace | Selects or deselects the focused item. Arrow keys move focus, never the selection. |
| Selecting | CtrlA | Selects every visible item. Control on a Mac too — Headless Tree matches the Control key, not ⌘. |
| Outline | Tab | Indents: the item becomes the last child of the sibling just above it, and that branch opens. |
| Outline | ShiftTab | Outdents: the item lands right after its old parent, one level up. |
| Outline | Alt↑ ↓ | Swaps the item with the sibling above or below. Option on a Mac. |
| Opening | Enter | Calls onItemOpen, as a double-click does. Without onItemOpen the key does nothing. |
- Tab is not a trap. It is held back only when the item really can be indented. On the first item of a level there is nothing above to go under, so Tab does what Tab always does and moves focus out of the tree; Shift+Tab on a top-level item moves focus back out the same way. A read-only tree or item never holds Tab at all.
- Alt+↑ and Alt+↓ are swallowed at the ends. On the first or last sibling there is nowhere to go, but the key is still consumed: let through, it would become a bare ↑ or ↓, focus would jump to another item and it would look as if the item had moved.
- The outline keys are not Headless Tree hotkeys. Its hotkey matcher counts the keydowns of each key being held, so a Shift or Alt pressed before focus reached the tree is never counted — Shift+Tab arrived as Tab, Alt+↓ as ↓. The package reads
shiftKeyandaltKeyoff the event itself, in the capture phase, before Headless Tree sees it. Anything with Ctrl or ⌘ held goes straight to Headless Tree. - A click on a branch opens or closes it as well as selecting it. On a Mac, Control+Space is usually the shortcut for switching input sources — everyone who types Telex has two — so macOS takes it first; ⌘+click does the same job.
Columns, rollups and numbers
A column reads a number per item with value, and with rollup a branch shows an aggregate of its direct children instead of a number of its own — the way a summary task works in MS Project. Rollups stack from the bottom: a phase sums its work packages, which have already summed their tasks. Switch the mode below and compare: the plain average treats a 3-hour task like a 12-hour one, which is why progress usually wants weighting.
Use Up and Down arrows to move between items, Right to expand and Left to collapse. Tab indents the item under the one above it, Shift+Tab outdents it. Alt with Up or Down moves the item among its siblings. Enter opens the item.
Accessible name of the focused row: — click a row or Tab into the tree
| Branch | Done, avg | Done, weighted |
|---|---|---|
| 1 One-time code sign-in | 41% | 51% |
| 1.1 Discovery | 88% | 89% |
| 1.1.1 Research | 100% | 100% |
| 1.2 Build | 37% | 42% |
| 1.2.1 Server | 53% | 50% |
| 1.2.2 Client | 20% | 32% |
| 1.3 Release | 0% | 0% |
- A rollup function cannot weight. It is called with the children’s values —
(values: number[]) => number | null— and never with the items, so it has nothing to weight them by. Compute the figure yourself withcomputeTreeRollup, as the weighted mode does, and hand it over throughvaluewith norollup. The one visible difference: the package draws only the figures it rolled up itself in medium weight. - The values are part of the name. The tree role has no header row, so the header you see is for the eye only (
aria-hidden), and each row’s accessible name carries its values instead — focus a row and read the line under the tree. That needs a stringheader, andformatTextwheneverformatadds a unit: without it the name says “Done: 41.8”, with no per cent sign. numberingis computed, never stored. Every move renumbers the whole tree, so a number is a position, not an identity — refer to work by its id or its task key, never by “1.2.3”.- A branch with no valued children shows nothing in that column; a
rendercolumn draws whatever you like and never rolls up.
Drawing a row
renderItem replaces the bare name with your own content — here an icon for each phase, the task key, the title and a StatusPill. It receives the item and { level, expanded, folder, number }; the chevron, the outline number and the columns stay the package’s. “Hand-over” has no tasks yet, but isFolder makes it a branch anyway: drag a task onto it.
Use Up and Down arrows to move between items, Right to expand and Left to collapse. Tab indents the item under the one above it, Shift+Tab outdents it. Alt with Up or Down moves the item among its siblings. Enter opens the item.
What renderItem draws, a screen reader does not hear
The cell renderItem fills is aria-hidden: the row’s accessible name is built from item.name, the outline number and the column values, nothing else. So the key and the status in this demo are in name as well — “DS-388 Rate-limit the one-time codes, In progress” — and the announcement after a move uses the same words. Keep anything interactive out of renderItem too: a button in an aria-hidden cell can be clicked but not reached, and the row itself is the drag source.
A tree nobody rearranges
Leave out onMove — or set readOnly — and the order is fixed: no dragging, the outline keys do nothing, and the keyboard instructions are not attached. What is left is still a full tree: arrow keys, open and close, selection, and onItemOpen. Try ⌘- or Ctrl-click and Shift-click, then press Enter on a file.
selectedIds
app/(docs)/composites/tree-view/page.tsx
onItemOpen: —
The helpers run anywhere
The arithmetic the tree uses is exported on its own, from a module with no "use client" — so the server applies a move with the very function the browser used, and an export adds up exactly what the screen showed. Below, a Server Action that re-applies a move before saving it, and a CSV built from the work breakdown above with no component mounted.
'use server'
import { moveTreeItems, type TreeViewMove } from '@comitor/ui' // a pure module — fine on the server
export async function moveWbsItems(projectId: string, move: TreeViewMove) {
await requirePermission(projectId, 'wbs:edit')
const rows = await db.wbsItems.listOrdered(projectId) // ORDER BY position — sibling order = array order
const next = moveTreeItems(rows, move)
if (next === rows) return { ok: false as const, reason: 'cycle' } // an item into its own subtree
// Only the order AMONG SIBLINGS means anything, so one global position column is enough.
await db.wbsItems.savePositions(
projectId,
next.map((row, position) => ({ id: row.id, parentId: row.parentId, position })),
)
return { ok: true as const }
}toCsv(WBS) — computed on this page, at module scope
wbs,name,hours 1,"One-time code sign-in",58 1.1,"Discovery",18 1.1.1,"Research",10 1.1.1.1,"Interview five admins about lockouts",6 1.1.1.2,"Audit the password reset flow",4 1.1.2,"Write the sign-in spec",8 1.2,"Build",33 1.2.1,"Server",18 1.2.1.1,"Rate-limit the one-time codes",10 1.2.1.2,"Expire codes after ten minutes",3 1.2.1.3,"Log every failed attempt",5 1.2.2,"Client",15 1.2.2.1,"Sign in with a one-time code",12 1.2.2.2,"Paste a code from the email",3 1.3,"Release",7 1.3.1,"Translate the sign-in emails",4 1.3.2,"Write the help-centre article",3
| Prop | Type | Default | Description |
|---|---|---|---|
moveTreeItems(items, move) | T[] | — | Applies a TreeViewMove and returns a NEW array — the old one is not touched. Returns the very same array when the move would make an item its own descendant, so result === items means refused. |
computeTreeRollup(items, column) | Map<string, number | null> | — | Every item's value for one column, rolled up from the bottom — the numbers TreeView draws. Takes anything with value and rollup, so a column object works as is. |
computeTreeNumbering(items) | Map<string, string> | — | Every item's outline number — 1, 1.2, 1.2.3 — in sibling order. |
groupTreeChildren(items) | Map<string | null, T[]> | — | Direct children by parent id, in array order; the top level sits under null. Orphans are dropped. The building block for a depth-first walk. |
Language
The package’s strings — the tree’s default name, “Name” over the name column, the keyboard instructions and the two move announcements — live in the treeView namespace and follow the nearest ComitorLocaleProvider: English by default, Vietnamese built in (set the language once, at the root). Numbers in the columns are formatted in the provider’s locale. Item names and column headers are your data: the demo translates them itself.
Use Up and Down arrows to move between items, Right to expand and Left to collapse. Tab indents the item under the one above it, Shift+Tab outdents it. Alt with Up or Down moves the item among its siblings. Enter opens the item.
Name of the tree (labels.label): Tree
Live region: —
| Key | English | Tiếng Việt |
|---|---|---|
label | Tree | Cây |
nameHeader | Name | Tên |
root | the top level | cấp ngoài cùng |
instructions | Use Up and Down arrows to move between items, Right to expand and Left to collapse. Tab indents the item under the one above it, Shift+Tab outdents it. Alt with Up or Down moves the item among its siblings. Enter opens the item. | Dùng phím mũi tên lên, xuống để chuyển mục, mũi tên phải để mở và mũi tên trái để thu nhánh. Tab đưa mục vào làm con của mục phía trên, Shift+Tab đưa mục ra ngoài một cấp. Alt cùng mũi tên lên, xuống đổi chỗ mục với các mục cùng cấp. Enter mở mục. |
movedInto(name, parent, position, total) | Write the sign-in spec moved into Discovery, position 2 of 3. | Đã chuyển Viết đặc tả đăng nhập vào Khảo sát, vị trí 2 trên 3. |
movedTo(name, position, total) | Write the sign-in spec moved to position 2 of 3. | Đã chuyển Viết đặc tả đăng nhập tới vị trí 2 trên 3. |
Read live from comitorMessagesEn.treeView and comitorMessagesVi.treeView, with a task at position 2 of 3.
labelsisPartial<TreeViewLabels>, merged key by key: the component’s prop wins, then the nearest provider, then English. Anaria-labelon the tree beatslabels.label.movedTospeaks when an item changes place under the same parent;movedIntowhenever the parent changes or several items move at once, withrootstanding in for the parent at the top level. Both are functions, so declare them in a client component.- The provider chooses strings; it does not set
lang. Keep<html lang>in step with it, as the demo does for its own region.
'use client' // movedInto and movedTo are functions — they cannot cross from a Server Component
<TreeView
items={items}
onMove={onMove}
aria-label="Work breakdown" // beats labels.label
labels={{
root: 'the project', // "Write the spec moved into the project, position 2 of 4."
movedInto: (name, parent, position, total) => `${name} → ${parent} (${position}/${total})`,
}}
/>Props
TreeView is generic: items fixes T, and onItemOpen, renderItem and every column receive your own item type back, extra fields included.
TreeView
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | T[] (T extends TreeViewItem) | — | The tree as a FLAT list: each item names its parentId, and sibling order is array order — the shape a parent_id + position query returns. An item whose parent is not in the list is an orphan and is skipped, with everything under it. |
onMove | (move: TreeViewMove) => void | — | Every rearrangement — drag and drop, Tab, Shift+Tab, Alt+↑/↓ — through this one callback. The tree writes nothing: apply the move with moveTreeItems (or your API) and pass the new items back. Without it the order is read-only: nothing drags, the outline keys do nothing, and no keyboard instructions are attached. |
readOnly | boolean | false | Locks every rearrangement even with onMove set. Per item: TreeViewItem.readOnly. |
expandedIds · defaultExpandedIds · onExpandedIdsChange | string[] · string[] · (ids: string[]) => void | [] | Open branches, semi-controlled: pass expandedIds and the prop rules; leave it out and the tree keeps its own, starting from defaultExpandedIds. A move into a branch opens that branch — through onExpandedIdsChange, so a controlled list must accept it. |
selectedIds · defaultSelectedIds · onSelectedIdsChange | string[] · string[] · (ids: string[]) => void | [] | Selection, semi-controlled the same way. A click selects one item; Ctrl/⌘+click adds or removes; Shift+click selects a range; Shift+↑/↓, Ctrl+Space and Ctrl+A from the keyboard. Dragging a selected item drags the whole selection; dragging any other item replaces the selection with it. |
onItemOpen | (item: T) => void | — | Enter on an item, or a double-click. Open the task, the file, the record. |
columns | TreeViewColumn<T>[] | — | Extra cells to the right of the name — hours, progress — with an optional rollup on branches. Adds a header row (aria-hidden, labels.nameHeader over the names). See TreeViewColumn below. |
numbering | boolean | false | Outline numbers — 1, 1.2, 1.2.3 — in front of each name, in sibling order. They are part of the accessible name too, and renumber after every move. |
renderItem | (item: T, state: { level, expanded, folder, number }) => ReactNode | the bare name | Draws the name part of a row — an icon, a key, a status. Drawn aria-hidden: the row's accessible name comes from item.name, so name must say what the row shows. level counts from 0 at the top; number is "" when numbering is off. |
rowHeight | number | 32 · 44 on touch | Row height in px. The default follows (pointer: coarse): 44 px rows on a touch screen, for a tappable target. |
indent | number | 20 | Indentation per level, in px. |
labels | Partial<TreeViewLabels> | the treeView namespace | The package's own strings, merged key by key over the nearest ComitorLocaleProvider's table. See Language. |
aria-label | string | labels.label | Name of the tree. Give each tree on a page its own — the default is the same “Tree” for all. |
className · …div props | ComponentProps<'div'> | — | Land on the frame — flex min-h-0 flex-col overflow-hidden rounded-lg border bg-card. Rows are virtualised inside it, so give it a height (h-96, or max-h-[26rem] as here) for a long tree; without one the frame grows to fit and every row renders. |
TreeViewItem
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Unique in the list. |
parentIdrequired | string | null | — | null for the top level. A parent missing from the list makes the item an orphan, skipped. |
namerequired | string | — | What the row shows when there is no renderItem — and always the accessible name and the name in announcements. |
isFolder | boolean | false | A branch even without children — a phase created a minute ago: it gets the open/close chevron and bold text. An item with children is always a branch. |
readOnly | boolean | false | Locks dragging this item and the outline keys on it — Tab then leaves the tree. Other items can still be dropped around it or into it, and it moves along with its parent. |
TreeViewColumn
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Unique among the columns. |
headerrequired | ReactNode | — | The column title, drawn in the aria-hidden header row. Only a STRING header puts the value into the row's accessible name (“Hours: 39”); a node header leaves it out. |
width | number | 96 | Width in px. |
align | 'start' | 'end' | 'end' | Text alignment — end suits numbers. |
value | (item: T) => number | null | undefined | — | This item's own number; null for none. A column without value draws through render. |
rollup | 'sum' | 'min' | 'max' | 'avg' | ((values: number[]) => number | null) | — | On a branch, the column shows this aggregate of its DIRECT children's (already rolled-up) values and its own value is ignored. avg is unweighted. A function receives the children's values only — no items, so no weights. A branch whose children have no value shows nothing. |
format | (value: number, item: T, context: { rolledUp: boolean }) => ReactNode | Intl.NumberFormat(locale) | How the value is drawn. The default formats in the provider's locale with at most one decimal. A rolled-up figure is drawn in medium weight, an item's own one muted. |
formatText | (value: number, item: T) => string | the same number format | The text that goes into the accessible name. Pass it whenever format adds a unit — %, h, ₫. |
render | (item: T) => ReactNode | — | Free drawing in place of value and format — an avatar, a date. Not rolled up, and never part of the accessible name. |
TreeViewMove
| Prop | Type | Default | Description |
|---|---|---|---|
ids | string[] | — | What moves: one id from the keyboard, the whole selection from a drag. They keep their order relative to one another in the array. |
parentId | string | null | — | The new parent; null for the top level. |
index | number | — | Position among the new parent's children AFTER the moving items have been taken out of that list — so moving an item down one place under the same parent is old index + 1, not + 2. Past the end means last. |
kind | 'drag' | 'indent' | 'outdent' | 'move-up' | 'move-down' | — | Where the move came from. moveTreeItems ignores it; it is there for your log, your undo label or your analytics. |
Known limitations
- Dragging is HTML5 drag and drop. Headless Tree uses the browser’s own: it works with a mouse and on current iPadOS and Android, but a pointer that cannot drag has no other way to move an item. The outline keys are a complete alternative for the keyboard; for a pointer, offer a “Move to…” action of your own (WCAG 2.5.7) that calls the same code as
onMove. - A leaf never becomes a branch by drag. Only a branch — an item with children, or one marked
isFolder— accepts a drop inside it; to nest under a task, use Tab, or setisFolderon the items that may receive children. In3.12.0every row took a drop inside it, a leaf too — measured on this page — and the target’s own value then left every roll-up;3.12.1restored the rule. An app that relied on it setsisFolder. - A selection that holds a branch and its own descendant flattens.
moveTreeItemsgives every moving id the new parent, so dragging a phase together with one of its tasks makes the task the phase’s sibling. Drag the phase alone — its tasks travel with it. - No rename in place, no search. Put an
InlineEditor your own search box around the tree throughrenderItemand a filtereditems— remembering thatrenderItemis aria-hidden, so a rename field there needs its own way in, such as the detail view. - The outline keys move one item. Tab, Shift+Tab and Alt+↑/↓ act on the focused item only, even when several are selected; only a drag moves a selection.
Accessibility
- ARIA
treeandtreeitem, after the W3C “Treeview with declared properties” pattern: every item carriesaria-level,aria-setsize,aria-posinsetandaria-selected, and a brancharia-expanded— the reader knows the shape of the tree even though only the visible rows are in the DOM. - One tab stop, roving: the focused item has
tabIndex0, every other item −1. Focus shows the package’s ring, inset inside the row; a selected row isbg-accent. - Each row’s accessible name is the outline number, the name and the column values — “1.2.1 Server, Hours: 18, Done: 53%”. The chevron, the number, the
renderItemcell, the column cells and the header row are allaria-hidden. - With
onMove, the tree’saria-describedbypoints at a visually hidden copy oflabels.instructions; every move is announced in a politerole="status"region, and focus returns to the moved item. - 44 px rows on a touch screen (
pointer: coarse) unless you setrowHeight. - What the app still has to do. Name each tree (
aria-label); put intonameeverythingrenderItemshows; give numeric columns a string header and aformatText; offer a pointer alternative to dragging. The tree does not setaria-multiselectable, so if selecting several items matters to your screen, say so in a visible hint next to it.
Related
Gantt
The same work breakdown on a time axis. It takes a flat list too, already filtered by what is expanded — a TreeView's items and expandedIds feed it.
Kanban
The same tasks by status instead of by structure — drag across columns rather than into branches.
Data Grid
When the rows need typing into — estimates, owners, dates in cells — rather than rearranging.
Inline Edit
TreeView has no rename in place. Put an InlineEdit in renderItem, or rename in the item's detail view.
Tasks pattern
Where a work breakdown lives: a project screen with the board, the list and the detail view around it.