JavaScript Tree View

@lemonadejs/treeview · ✓ 10 contract checks · framework-agnostic · zero dependencies

.

A hierarchical list rendered by ONE recursive view function: nodeView calls itself for node.children, every repeated <li> is keyed by the node id, and the whole tree hangs off a single live expression reading the data state. Mutate the tree in place + data.touch() (or assign a new array) and the keyed diff moves/keeps existing DOM instead of rebuilding it — including NESTED sibling lists, which the engine compares structurally.

Collapse keeps the child DOM ALIVE: visibility is the aria-expanded attribute + CSS (display: none on the group), never an unmount. Same choice as <Tabs /> panels. Rationale: re-expanding is instant, child DOM identity (and anything the host stuffed into it) survives toggles, the accessibility attribute IS the rendering switch (one source of truth), and toggling never re-runs the keyed diff. For huge lazy trees an unmounting branch would be the alternative; for a block-sized tree keep-alive is the idiomatic v6 answer.

Contract: data TreeNode[]: { id, label, icon?, open?, children? } bind two-way selected node id (bind=”${state}”) draggable opt-in drag-and-drop reordering (default false) onchange (id, node) — fires on user/api selection (parent writes to the bound state never echo back) ontoggle (id, open) — fires on expand/collapse (user or api) onmove (id, parentId, index) — after a drag drops a node into a new position (parentId is null at the root) api { open(id), close(id), select(id), toggle(id) }

Drag-and-drop (draggable only): a pointer gesture (3px threshold, Escape cancels, a drag never fires the click selection) with ZERO LAYOUT SHIFT — a dense list must not reflow under the cursor. The origin row stays in the flow, dimmed; a small chip with the node’s label rides the cursor; the landing slot is an absolutely positioned insertion line (before/after) or a ring on the container row (inside). Hovering a CONTAINER row (a node with a children array — children: [] is an empty folder) splits it in thirds: top → drop BEFORE (sibling), bottom → drop AFTER (sibling), middle → drop INSIDE (becomes a child, opening the target). A LEAF row (no children key) splits in halves, before/after only — a leaf never becomes a parent via drop. The tree array is mutated in place + data.touch(), so the keyed diff MOVES existing DOM instead of rebuilding it. A node can never drop onto itself or into its own subtree.

Keyboard (APG tree pattern, single select): ArrowRight opens a closed parent; on an open parent moves to the first child ArrowLeft closes an open parent; otherwise moves to the parent ArrowUp/Down move focus across VISIBLE nodes Enter selects the focused node

Focus lands on the

  • ITSELF, so the focused element is the one carrying aria-expanded/aria-selected (WCAG 4.1.2), and the tree exposes a ROVING tabindex (WCAG 2.4.3): exactly one visible node — the last focused, else the selected, else the first — is tabindex=“0”; every other node is tabindex=“-1”, so Tab enters and leaves the tree in one stop.

    Example

    live
    import { html } from 'lemonadejs';
    import TreeView from '@lemonadejs/treeview';
    
    const files = [
        { id: 'src', label: 'src', open: true, children: [
            { id: 'index', label: 'index.js' },
            { id: 'style', label: 'style.css' },
            { id: 'utils', label: 'utils', children: [
                { id: 'walk', label: 'walk.js' },
                { id: 'keys', label: 'keys.js' },
            ] },
        ] },
        { id: 'docs', label: 'docs', children: [{ id: 'readme', label: 'README.md' }] },
        { id: 'pkg', label: 'package.json' },
    ];
    
    const App = (props, { state }) => {
        const selected = state('index');
    
        return html`<div>
            <${TreeView} data="${files}" bind="${selected}" draggable />
            <p style="font-size:13px">Selected: <b>${selected}</b> — arrows move, Enter selects, drag to reorder.</p>
        </div>`;
    };

    Installation

    npm install @lemonadejs/treeview
    import Treeview from '@lemonadejs/treeview';
    import '@lemonadejs/treeview/style.css';

    The icons come from Google Material Symbols. Load the font once per page:

    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">

    Three deployment forms, one component:

    html`<${Treeview} />`                       // by value (no registration)
    setComponents({ Treeview });               // then <Treeview /> by name anywhere
    createWebComponent(Treeview);              // <lm-treeview> in plain HTML/any framework

    Props

    Every declared prop arrives as a live state — pass a value for a snapshot or a state for a two-way live wire. Attribute strings are coerced to the declared type.

    PropTypeDefaultDescription
    bindanyTwo-way bound value. .set() fires onchange; plain assignment is silent. two-way selected node id — ‘any’: ids are string
    dataarrayTreeNode[] — the tree
    draggablebooleanfalseopt-in drag-and-drop reordering

    Events

    All event names are lowercase (the platform convention — LJS-305 warns otherwise).

    • onchange — (id, node) on selection
    • ontoggle — (id, open) on expand/collapse
    • onmove — (id, parentId, index) after a drag drop

    API

    import { ref } from 'lemonadejs';
    const treeview = ref();
    html`<${Treeview} ref="${treeview}" />`;
    // treeview.current.open(...)  ·  treeview.current.close(...)  ·  treeview.current.select(...)  ·  treeview.current.toggle(...)
    • open()
    • close()
    • select()
    • toggle()

    Styling

    All classes follow the lm-treeview-* convention; visual variants are data-* attributes on the root. Override freely — there is no styling engine to fight.

    Contract

    The machine-readable schema ships with the package:

    import contract from '@lemonadejs/treeview/contract.json';

    verify.json carries the conformance proof produced by verify(Treeview).

    Looking for the v5 plugin? See the archived v5 documentation.