@sp-treeview/element
v4.1.0
Published
Native Web Components <sp-tree> and <sp-tree-select>: WAI-ARIA tree, checkbox/radio selection with cascade, lazy loading, search, dropdown field with chips. Works in React, Vue, Svelte, Angular, plain HTML.
Maintainers
Readme
@sp-treeview/element
Native Web Components for tree selection, built on Lit over
the framework-agnostic @sp-treeview/core state engine. Two custom
elements:
<sp-tree>— an inline, WAI-ARIA tree with checkbox/radio selection, lazy loading, and a built-in search box.<sp-tree-select>— a form field (dropdown or overlay) whose value is a tree selection, with removable chips.
Because they are standard custom elements, they work in React, Vue, Svelte, Angular, plain HTML/JS — no per-framework wrapper.
Website, live demos & guides: https://srjn45.github.io/sp-treeview-v2/
npm install @sp-treeview/element
# peer/runtime deps: lit, @floating-ui/dom (installed automatically)// Importing the package registers <sp-tree> and <sp-tree-select> as a side effect.
import '@sp-treeview/element';Version
4.1.0. Node ≥ 20. Ships ESM only.
<sp-tree>
An inline tree. Renders store.rows() as a flat, indented list — collapsed
and filtered-out rows are not in the DOM.
Properties & attributes
Object/function inputs (data, loadChildren, renderNode, store) are DOM
properties — set them via JS/framework binding, not string attributes.
Primitive inputs also have a reflected attribute.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
| data | — | TreeNodeData[] | [] | The tree data (see core data model). |
| selection | selection | 'none' \| 'single' \| 'multi' | 'none' | Selection mode. |
| cascade | cascade | boolean | true | Parent↔child propagation (multi). |
| loadOnce | load-once | boolean | true | Cache lazily-loaded children. |
| loadChildren | — | (node) => Promise<TreeNodeData[]> | — | Lazy loader for hasChildren nodes. |
| searchable | searchable | boolean | false | Render the built-in search box. |
| showAllNode | show-all-node | boolean | false | Render a top "All" row wired to setAllChecked. |
| renderNode | — | (node, ctx) => TemplateResult | — | Custom row content (Lit template). |
| store | — | TreeStore | — | Use an existing store instead of building one from data/selection/… |
| editable | editable | boolean | false | Show per-node create/rename/delete actions + an "Add item" root button (see Editable mode). |
| name | name | string | '' | Form field name — when set, the tree JSON is submitted under this name. |
| idFactory | — | () => string | crypto.randomUUID | Id generator for nodes created in editable mode. |
cascadeandloadOncedefault totrue; because they are boolean attributes, set them tofalsethrough the property (el.loadOnce = false/.loadOnce=${false}), not by omitting an attribute.
Events
All events are composed and bubbling (CustomEvent).
| Event | detail | Fires |
|---|---|---|
| sp-change | multi: { checked: TreeNodeData[], allSelected: boolean }; single: { selected: TreeNodeData \| null } | On any selection change. |
| sp-expand | { node: TreeNodeData } | A row expands. |
| sp-collapse | { node: TreeNodeData } | A row collapses. |
| sp-load-error | { node: TreeNodeData, error: Error } | A lazy load rejects. |
| sp-node-create | { node: TreeNodeData, parentId: string \| null } | A node is created (editable mode). |
| sp-node-update | { node: TreeNodeData, previousLabel: string } | A node is renamed (editable mode). |
| sp-node-delete | { node: TreeNodeData } | A node (and its subtree) is deleted (editable mode). |
| sp-tree-change | { data: TreeNodeData[] } | Any structural edit — carries the full current tree. |
The multi-mode sp-change reports the concrete checked nodes plus
allSelected — there is no sentinel "ALL" node in the payload.
::part() names
Style internals from outside the shadow DOM via parts (full list and the CSS custom-property tokens are in the theming guide):
tree, row, all-row, toggle, checkbox, radio, label, match,
spinner, error, retry, search, search-input, search-clear, empty.
Editable mode adds: node-actions, action-edit, action-add,
action-delete, edit-input, edit-submit, edit-cancel, confirm,
confirm-text, confirm-yes, confirm-no, add-root-bar, add-root.
sp-tree::part(row) { padding-block: 2px; }
sp-tree::part(match) { background: gold; }Editable mode
Set editable to turn the tree into a hierarchy editor. Each row reveals three
actions on hover/focus, and an Add item button below the tree creates roots:
- ✎ edit — replaces the label with an inline text field; Enter or the ✓
button commits (
updateNode), Escape or ✕ cancels. - + add child — expands the node and opens an inline field for a new child
(
addNode); the new node's id comes fromidFactory(defaultcrypto.randomUUID). - 🗑 delete — swaps the row actions for an inline "Delete "X" + N items?" confirmation (N = descendant count); only ✓ actually removes the subtree.
Because it's form-associated, an editable tree doubles as a form field that submits the whole hierarchy as JSON:
const tree = document.createElement('sp-tree') as SpTree;
tree.editable = true;
tree.name = 'taxonomy';
tree.data = data;
tree.addEventListener('sp-tree-change', (e) => {
console.log((e as CustomEvent).detail.data); // current TreeNodeData[]
});
tree.value; // '[{"id":"…","label":"…","children":[…]}]'
new FormData(form).get('taxonomy'); // same JSON string on submit
<sp-tree> is formAssociated: its value (getter/setter) is the tree JSON,
setFormValue runs on every change, and formResetCallback restores the data
the tree was first built from. Setting value to a JSON string replaces the tree.
<sp-tree-select>
A form field whose value is a tree selection. The trigger shows selected leaves
as removable chips (wrapping to 2 rows, then a +N overflow chip); activating it
opens a panel that embeds an <sp-tree>. The panel — and every node in it — is
removed from the DOM while closed, so a closed field leaves no hidden
tab/click targets.
Additional properties & attributes
Everything from <sp-tree> is forwarded, plus:
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
| variant | variant | 'dropdown' \| 'overlay' | 'dropdown' | Floating panel vs. full-viewport modal sheet. |
| placeholder | placeholder | string | 'Select…' | Shown when nothing is selected. |
| selection | selection | 'none' \| 'single' \| 'multi' | 'multi' | Note: defaults to multi here (<sp-tree> defaults to none). |
editable and idFactory are also forwarded — set editable to allow
creating/renaming/deleting nodes from inside the dropdown panel.
Methods
open(), close(), toggle() — imperative panel control (the field, Escape,
click-outside, and the panel's Done button also drive it). On open, focus
moves into the panel; on close it returns to the trigger.
getData() — the current tree structure (reflecting any in-panel edits) as
TreeNodeData[].
Events
Emits the same sp-change as <sp-tree> (re-dispatched from the embedded tree);
sp-expand / sp-collapse / sp-load-error, and — in editable mode —
sp-node-create / sp-node-update / sp-node-delete / sp-tree-change from
the inner tree also bubble out.
The select's form value stays a JSON array of checked node ids (the field's purpose is selection). To persist an edited hierarchy, read
getData()or listen forsp-tree-change.
Form participation
static formAssociated = true: the field sets its form value (via
ElementInternals) to a JSON array of checked node ids, so it works in plain
<form>s and framework form libraries.
<form>
<sp-tree-select name="regions"></sp-tree-select>
</form>const fd = new FormData(form);
fd.get('regions'); // e.g. '["mumbai","pune"]'Additional ::part() names
Plus the forwarded <sp-tree> parts above:
field, placeholder, chip, chip-overflow, chip-remove, backdrop,
panel, done.
Usage
The data + lazy loader used below:
import type { TreeNodeData } from '@sp-treeview/core';
const data: TreeNodeData[] = [
{ id: 'india', label: 'India', children: [
{ id: 'mh', label: 'Maharashtra', children: [{ id: 'mumbai', label: 'Mumbai' }] },
] },
{ id: 'usa', label: 'USA', hasChildren: true },
];
const loadChildren = (node: TreeNodeData): Promise<TreeNodeData[]> =>
new Promise((resolve) =>
setTimeout(() => resolve(node.id === 'usa' ? [{ id: 'ca', label: 'California' }] : []), 300),
);
interface MultiChangeDetail { checked: TreeNodeData[]; allSelected: boolean }Plain JS / TypeScript
import { SpTree } from '@sp-treeview/element';
const tree = document.createElement('sp-tree') as SpTree;
// Object/function inputs are DOM *properties*, never attributes.
tree.data = data;
tree.loadChildren = loadChildren;
// Primitive inputs may be attributes or properties.
tree.selection = 'multi';
tree.cascade = true;
tree.searchable = true;
tree.addEventListener('sp-change', (e) => {
const detail = (e as CustomEvent<MultiChangeDetail>).detail;
console.log('checked:', detail.checked.map((n) => n.label), 'all:', detail.allSelected);
});
document.body.append(tree);The dropdown/overlay field is wired the same way:
import { SpTreeSelect } from '@sp-treeview/element';
const select = document.createElement('sp-tree-select') as SpTreeSelect;
select.data = data;
select.loadChildren = loadChildren;
select.variant = 'dropdown'; // or 'overlay'
select.selection = 'multi';
select.searchable = true;
select.placeholder = 'Pick regions…';
select.addEventListener('sp-change', (e) => {
const detail = (e as CustomEvent<MultiChangeDetail>).detail;
console.log('selected leaves:', detail.checked.map((n) => n.label));
});
document.body.append(select);Plain HTML with an import map (no bundler):
<script type="importmap">
{ "imports": {
"lit": "https://esm.sh/lit@3",
"@floating-ui/dom": "https://esm.sh/@floating-ui/dom@1",
"@sp-treeview/core": "https://esm.sh/@sp-treeview/[email protected]",
"@sp-treeview/element": "https://esm.sh/@sp-treeview/[email protected]"
} }
</script>
<sp-tree-select id="picker" selection="multi" searchable></sp-tree-select>
<script type="module">
import '@sp-treeview/element';
document.getElementById('picker').data = [/* … */];
</script>React
<sp-tree> / <sp-tree-select> are custom elements, so React uses them
directly — no wrapper package. Set object/function inputs through a ref;
listen for sp-change with addEventListener.
import { useEffect, useRef, useState } from 'react';
import type { TreeNodeData } from '@sp-treeview/core';
import type { SpTreeSelect } from '@sp-treeview/element';
import '@sp-treeview/element';
// JSX typings for the custom elements (no wrapper package needed).
type CustomElementProps<E> = React.HTMLAttributes<E> & { ref?: React.Ref<E>; key?: React.Key; class?: string };
type SpTreeSelectProps = CustomElementProps<SpTreeSelect> & {
variant?: 'dropdown' | 'overlay';
selection?: 'none' | 'single' | 'multi';
searchable?: boolean;
placeholder?: string;
};
declare module 'react' {
namespace JSX {
interface IntrinsicElements { 'sp-tree-select': SpTreeSelectProps }
}
}
export function RegionPicker() {
const ref = useRef<SpTreeSelect>(null);
const [labels, setLabels] = useState<string[]>([]);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.data = data; // object/function inputs → properties
el.loadChildren = loadChildren;
const onChange = (e: Event) => {
const detail = (e as CustomEvent<{ checked: TreeNodeData[] }>).detail;
setLabels(detail.checked.map((n) => n.label));
};
el.addEventListener('sp-change', onChange);
return () => el.removeEventListener('sp-change', onChange);
}, []);
return (
<div>
<sp-tree-select ref={ref} variant="dropdown" selection="multi" searchable placeholder="Pick regions…" />
<p>Selected: {labels.length ? labels.join(', ') : '(none)'}</p>
</div>
);
}Vue 3
Tell Vue that sp-* are custom elements (in your build config:
compilerOptions.isCustomElement = (tag) => tag.startsWith('sp-'), or the Vite
@vitejs/plugin-vue template.compilerOptions.isCustomElement option). Then
bind object/function inputs imperatively via a template ref.
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import type { TreeNodeData } from '@sp-treeview/core';
import type { SpTreeSelect } from '@sp-treeview/element';
import '@sp-treeview/element';
interface MultiChangeDetail { checked: TreeNodeData[]; allSelected: boolean }
const picker = ref<SpTreeSelect | null>(null);
const labels = ref<string[]>([]);
function onChange(e: Event) {
const detail = (e as CustomEvent<MultiChangeDetail>).detail;
labels.value = detail.checked.map((n) => n.label);
}
onMounted(() => {
const el = picker.value;
if (!el) return;
el.data = data; // object/function inputs → properties
el.loadChildren = loadChildren;
el.addEventListener('sp-change', onChange);
});
onBeforeUnmount(() => {
picker.value?.removeEventListener('sp-change', onChange);
});
</script>
<template>
<sp-tree-select ref="picker" variant="dropdown" selection="multi" searchable placeholder="Pick regions…" />
<p>Selected: {{ labels.length ? labels.join(', ') : '(none)' }}</p>
</template>Theming
Every visual value flows through --sp-tree-* CSS custom properties (light + dark
defaults built in); structural restyling uses ::part(). See
the theming guide for the full token table and a worked
dark-theme override.
Accessibility
Implements the WAI-ARIA tree pattern: role="tree"/treeitem, aria-level,
aria-setsize, aria-posinset, aria-expanded, aria-checked (incl.
"mixed"), aria-disabled, aria-busy, roving tabindex, arrow-key navigation,
Home/End, Enter/Space to toggle, and type-ahead.
License
MIT. Part of sp-treeview v4.
