Tree
Hierarchical tree view with expansion, selection, checkboxes and lazy loading.
c2-tree renders a hierarchy and owns its state: value holds the selected rows, expanded-items the expanded ones, and every c2-tree-item takes its depth, expansion, selection and roving tabindex from the tree. Nest the rows in markup, or hand the tree an items array of { value, label, children } nodes and it renders the same elements for you — both modes produce identical DOM and the same events. selection switches between none, single and multiple; checkbox-selection adds a checkbox per row whose ticks travel to descendants and parents, leaving a partly-selected branch indeterminate. A branch marked has-children with no children yet calls the tree’s loadChildren the first time it is opened, showing a spinner meanwhile. children-outline draws a vertical rule per level of depth, the way a file explorer marks which branch a row belongs to — off by default, and styled with --c2-tree-item__guide--color and --c2-tree-item__guide--width once on. expand-on-click widens the toggle’s job to the whole row — and to Enter and Space — which is what a navigation tree wants, where a branch row is a heading with nothing to select. Only expanded rows render their children slot, so a collapsed subtree is not laid out and stays out of the accessibility tree. It is a tree with a roving tabindex: Arrow Up and Down walk the visible rows, Arrow Right and Left open and close branches or move to a child or parent, Home and End jump to the ends, Enter and Space select, * expands the focused row’s siblings, and typing a few letters jumps to a row.
Import
import '@c2n/components/tree'Usage
Set items to render from data instead of markup. The nodes are { value, label, children?, disabled?, hasChildren?, data? }, and four renderers customize how a row draws: renderItem takes over the whole content, while renderIcon, renderLabel and renderActions replace one part each. Each is called with { node, level, expanded, selected }, and renderItem wins when both are set. The toggle and the selection checkbox are structural and always stay.
const tree = document.querySelector('c2-tree')
tree.items = [{ value: 'src', label: 'src', children: [{ value: 'app.ts', label: 'app.ts' }] }]
// One part of the row…
tree.renderIcon = ({ node }) => (node.children ? folderIcon() : fileIcon())
// …or all of it.
tree.renderItem = ({ node, selected }) => {
const row = document.createElement('span')
row.textContent = node.label
row.append(badge(node.data.count, { strong: selected }))
return row
}
tree.addEventListener('selection-change', (event) => {
console.log(event.detail.value, event.detail.nodes)
})The renderers are handed to Lit, so they return a Lit template or a DOM node — not framework markup such as JSX. When the rows are authored in markup, fill the icon, label and actions slots on c2-tree-item directly instead.
selection-change and expansion-change do not bubble — several components fire selection-change, so listen on the tree itself rather than on an ancestor.
Mark a branch has-children and give the tree a loadChildren to fetch a subtree the first time it opens. The row shows a spinner while the promise is pending, and a rejection fires item-load-error and leaves the branch open to another attempt:
tree.items = [{ value: 'remote', label: 'Remote folder', hasChildren: true }]
tree.loadChildren = async ({ node }) => {
const response = await fetch(`/api/children?id=${node.value}`)
return response.json()
}When the rows are authored in markup instead, listen for item-expand and append the children yourself — the tree notices them and resyncs.
reorderable lets the user rearrange the rows: drag one up or down, and right past the row above to nest it there or left to move it out of its parent. A line shows where the row will land, a collapsed branch opens when a dragged row rests on it, and Escape cancels. From the keyboard, Alt with Arrow Up or Down moves the focused row among its siblings, Alt with Arrow Right nests it under the row above and Alt with Arrow Left moves it out; each move is announced. max-depth caps how deep a row and its subtree may go (0 keeps every row at the root), and a disabled row neither moves nor takes children. Before each move the tree fires a cancelable item-move; it then moves the element itself in markup, or rebuilds items in the data-driven mode, so saving the new order is one listener:
tree.addEventListener('item-move', (event) => {
const { node, parent, index } = event.detail
if (parent?.data?.locked) event.preventDefault() // keep the row where it is
else save(node.value, parent?.value ?? null, index)
})The code for reordering loads the first time reorderable is set, so a tree that only displays a hierarchy does not carry it. --c2-tree__drop-indicator--color and --height style the line, --c2-tree-item__dragging--opacity dims the dragged row and --c2-tree-item__drop-target--background tints the row it would land under.