Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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/core and @headless-tree/react come in as ordinary dependencies of the package, so there is nothing to install, under pnpm strict and Yarn PnP too.
Client
TreeView starts with "use client", and a tree that moves needs state for items and a function for onMove — 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 call moveTreeItems on 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.

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.

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.

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.

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 },
]
  • index counts 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 gives index: 1, not 2. Apply it with anything other than moveTreeItems and an off-by-one is the first thing to check.
  • Only sibling order means anything. moveTreeItems puts 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 with groupTreeChildren when 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 items come 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.

WhenKeysResult
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 shiftKey and altKey off 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.

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.

Accessible name of the focused row: — click a row or Tab into the tree

BranchDone, avgDone, weighted
1 One-time code sign-in41%51%
1.1 Discovery88%89%
1.1.1 Research100%100%
1.2 Build37%42%
1.2.1 Server53%50%
1.2.2 Client20%32%
1.3 Release0%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 with computeTreeRollup, as the weighted mode does, and hand it over through value with no rollup. 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 string header, and formatText whenever format adds a unit: without it the name says “Done: 41.8”, with no per cent sign.
  • numbering is 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 render column 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.

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.

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.

tsx
'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
PropKiểuMặc địnhMô tả
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: —

KeyEnglishTiếng Việt
labelTreeCây
nameHeaderNameTên
rootthe top levelcấp ngoài cùng
instructionsUse 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.

  • labels is Partial<TreeViewLabels>, merged key by key: the component’s prop wins, then the nearest provider, then English. An aria-label on the tree beats labels.label.
  • movedTo speaks when an item changes place under the same parent; movedInto whenever the parent changes or several items move at once, with root standing 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.
tsx
'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

PropKiểuMặc địnhMô tả
itemsbắt buộcT[] (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.
readOnlybooleanfalseLocks every rearrangement even with onMove set. Per item: TreeViewItem.readOnly.
expandedIds · defaultExpandedIds · onExpandedIdsChangestring[] · 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 · onSelectedIdsChangestring[] · 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.
columnsTreeViewColumn<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.
numberingbooleanfalseOutline 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 }) => ReactNodethe bare nameDraws 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.
rowHeightnumber32 · 44 on touchRow height in px. The default follows (pointer: coarse): 44 px rows on a touch screen, for a tappable target.
indentnumber20Indentation per level, in px.
labelsPartial<TreeViewLabels>the treeView namespaceThe package's own strings, merged key by key over the nearest ComitorLocaleProvider's table. See Language.
aria-labelstringlabels.labelName of the tree. Give each tree on a page its own — the default is the same “Tree” for all.
className · …div propsComponentProps<'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

PropKiểuMặc địnhMô tả
idbắt buộcstring—Unique in the list.
parentIdbắt buộcstring | null—null for the top level. A parent missing from the list makes the item an orphan, skipped.
namebắt buộcstring—What the row shows when there is no renderItem — and always the accessible name and the name in announcements.
isFolderbooleanfalseA 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.
readOnlybooleanfalseLocks 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

PropKiểuMặc địnhMô tả
idbắt buộcstring—Unique among the columns.
headerbắt buộcReactNode—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.
widthnumber96Width 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 }) => ReactNodeIntl.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) => stringthe same number formatThe 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

PropKiểuMặc địnhMô tả
idsstring[]—What moves: one id from the keyboard, the whole selection from a drag. They keep their order relative to one another in the array.
parentIdstring | null—The new parent; null for the top level.
indexnumber—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 set isFolder on the items that may receive children. In 3.12.0 every 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.1 restored the rule. An app that relied on it sets isFolder.
  • A selection that holds a branch and its own descendant flattens. moveTreeItems gives 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 InlineEdit or your own search box around the tree through renderItem and a filtered items — remembering that renderItem is 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 tree and treeitem, after the W3C “Treeview with declared properties” pattern: every item carries aria-level, aria-setsize, aria-posinset and aria-selected, and a branch aria-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 tabIndex 0, every other item −1. Focus shows the package’s ring, inset inside the row; a selected row is bg-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 renderItem cell, the column cells and the header row are all aria-hidden.
  • With onMove, the tree’s aria-describedby points at a visually hidden copy of labels.instructions; every move is announced in a polite role="status" region, and focus returns to the moved item.
  • 44 px rows on a touch screen (pointer: coarse) unless you set rowHeight.
  • What the app still has to do. Name each tree (aria-label); put into name everything renderItem shows; give numeric columns a string header and a formatText; offer a pointer alternative to dragging. The tree does not set aria-multiselectable, so if selecting several items matters to your screen, say so in a visible hint next to it.

Related