@get-set/gs-tree
v1.0.2
Published
Get-Set Tree — a dual-target (vanilla / jQuery / HTMLElement.prototype / React) treeview with expand-collapse, tri-state checkboxes, selection, keyboard navigation, lazy loading, drag-reorder, filtering and theming.
Maintainers
Readme
GSTree
A dependency-free, feature-rich treeview available in two flavours from one codebase:
- Native / vanilla JS — a
window.GSTree(selector, params)factory plus awindow.GSTreeConfigueinstance registry. jQuery andHTMLElement.prototypeadapters are wired automatically. - React — a
<GSTree />component with an imperative ref handle.
Both share the exact same engine (actions/, constants/, helpers/, types/), so behaviour is identical across the two.
Features
- Four ways to use it — vanilla
window.GSTree, jQuery$(...).GSTree(),element.GSTree(), and the React<GSTree>component. - Full editing — add, remove, rename (inline, on double-click /
F2), duplicate, and reorder nodes — via hover action buttons (nodeActions), the keyboard, or a complete imperative CRUD API (addNode/removeNode/renameNode/updateNode/duplicateNode/moveNode/getNodes). Every edit firesonChangewith the new tree. - 8 visual variants —
default,bordered(panel),filled(soft row fills),pills(fully-rounded rows, gradient accent selection),cards(each root branch is an elevated card that lifts on hover),glass(blurred glassmorphism),minimal(accent-bar selection, zero chrome), andrail(app sidebar / nav with accent-tinted selection). - 4 twisty styles — stroked
chevron(default), solidcaret,plusminus(morphs + → −), andarrow. - Colored icon tiles —
iconTileswraps every icon in a rounded, tinted tile (the Notion / macOS / Linear look), tinted by a per-nodecolor. Plus built-indefaultIcons: 'folder'(open/closed folders + file sheets) or'dot'bullets. A node's owniconalways wins. - Badges, subtitles, counts & meta — per-node
badge(+badgeColor), a mutedsubtitlesecond line, a right-alignedmeta(size / time / count), andshowCountchild-count badges on parents. - Data or markup — build the tree from a
nodesarray, or (native) let it enhance existing nested<ul>/<li>markup withdata-*attributes. - Smooth expand/collapse — height animation via the
grid-template-rows: 0fr → 1frtechnique (nopx→autosnap, no transform that would break sticky), with tunable duration/easing andprefers-reduced-motionsupport. Optionalaccordionmode keeps one branch open at a time. - Staggered child entrance — when a branch opens (or lazy children arrive), its children play a
fade/slide/scaleentrance with a per-row stagger — only on the branch that just opened, never on unrelated re-renders. - Tri-state checkboxes — parent/child cascade with an indeterminate (half-checked) state that recomputes as you go; disabled nodes never block a parent from being "fully checked".
checkStrictlyturns cascading off (independent checkboxes). - Selection —
single(default),multiple(Ctrl/⌘-toggle and Shift-range), orfalse. - Lazy loading — a node's
childrenmay be an async loader(node, ctx) => Promise<GSTreeNode[]>; a spinner shows while it resolves and results are cached. A checked parent cascades onto freshly-loaded children. - Full keyboard navigation — WAI-ARIA tree pattern: Up/Down, Left/Right to collapse/expand or step to parent/first child (RTL-aware), Home/End, Enter to activate, Space to toggle a checkbox,
*to expand all siblings, and type-ahead. Roving tabindex keeps exactly one node tabbable. - Sortable-grade drag-reorder — the same tactile engine feel as GSSortable: a floating ghost clone that follows the cursor with a lift (shadow + tilt), a dimmed source row, a polished drop indicator (
line/pill/glow) forbefore/after/inside, a drop-zone highlight, edge auto-scroll, a FLIP drop-settle, and optional drag handles (bar/dots/grip/lines). Per-nodedraggableoverride + anonMoveveto hook. - Filtering —
setFilter(q)hides non-matching nodes, keeps + auto-expands matching ancestors, highlights the match with<mark>, and can show anemptyTextmessage when nothing matches. - Theming —
light/dark/auto(follows OS), a customaccentColortoken,sm/md/lgsize presets, configurableindent, connector guides (showLinesdraws ancestor rails + elbow stubs), and per-nodeicon. - RTL — right-to-left layout with mirrored twisties and inverted arrow-key semantics.
- Accessibility —
role="tree"/role="treeitem"/role="group",aria-expanded/aria-selected/aria-checked(mixedfor indeterminate) /aria-level/aria-setsize/aria-posinset/aria-disabled,aria-multiselectable, and roving focus. - React-only
gsxprop — scoped, per-instance inline styles injected into<head>and cleaned up on unmount.
Installation
npm i @get-set/gs-treeCompatibility
| Target | Requirement |
|---|---|
| React (the <GSTree> component) | React 16.8+ (Hooks are required), and 17 / 18 / 19. React is an optional peer dependency — you only need it for the component. |
| Native / vanilla (window.GSTree) | No framework. Any modern evergreen browser. |
| jQuery | Optional — the $.fn.GSTree adapter is registered only when window.jQuery is present when the bundle loads. |
| TypeScript | First-class — type declarations (.d.ts) ship in the package. |
| SSR / Next.js | Safe to import server-side (no DOM access at module load). Render the component inside a Client Component ('use client'). |
The peer range is
^16.8.0 || ^17.0 || ^18.0 || ^19.0, withreact/react-dommarked optional so the native build has zero peer dependencies.
Package entry points
From package.json:
| Field | Value |
|---|---|
| name | @get-set/gs-tree |
| main | dist/components/GSTree.js |
| module | dist/components/GSTree.js |
| types | dist/components/GSTree.d.ts |
| exports['.'].import | ./dist/components/GSTree.js (React/ESM) |
| exports['.'].require | ./dist-js/bundle.js (native window-global bundle) |
| files | dist, dist-js, styles, example.html |
Project layout
GSTree.ts # native entry (webpack -> dist-js/bundle.js, window global + adapters)
components/GSTree.tsx # React component (tsc -> dist/, npm entry)
actions/ # native lifecycle (init/refresh/destroy/getCurrentParams/animate)
constants/ # defaultParams
helpers/ # model, check, keyboard, layout, parse, render, tree-ops, uihelpers
types/ # Params / Ref / Window augmentation
components/styles/ # SCSS + compiled CSS + CSS-as-TS (runtime injection for React)
styles/ # SCSS + compiled CSS (for <link> use by the native build)Build
npm install
npm run build # builds both targets
npm run build:js # native bundle -> dist-js/bundle.js
npm run build:react # React + types -> dist/Tests
Unit tests use Vitest + jsdom:
npm test # run once
npm run test:watchCoverage spans the pure logic (param resolution, the model walk/flatten/filter helpers, the tri-state check conform/toggleCheck cascade, keyboard action resolution, immutable moveNode reorder) and the native DOM target (factory, registry, markup enhance, expand/collapse, selection, checkbox cascade, keyboard nav). Note: jsdom has no layout engine, so drag geometry is validated via the pure dropPositionFromOffset / moveNode helpers rather than rendered pixels.
Usage — 1. Native / vanilla JS
Load the bundle and call the factory with a selector or an element. The native bundle injects its own stylesheet, so the <link> is optional (include it if you prefer to control the CSS yourself):
<link rel="stylesheet" href="node_modules/@get-set/gs-tree/styles/GSTree.css" />
<script src="node_modules/@get-set/gs-tree/dist-js/bundle.js"></script>
<div id="tree"></div>
<script>
const tree = window.GSTree('#tree', {
reference: 'demo',
theme: 'dark',
checkable: true,
selectable: 'single',
defaultExpanded: ['src'],
onSelect: (keys, node) => console.log('selected', keys, node),
onCheck: (checked, info) => console.log('checked', checked, info.halfCheckedKeys),
nodes: [
{
id: 'src', label: 'src', icon: '📁',
children: [
{ id: 'index.ts', label: 'index.ts', icon: '📄' },
{ id: 'app.ts', label: 'app.ts', icon: '📄' }
]
},
{ id: 'readme', label: 'README.md', icon: '📄' }
]
});
tree.expandAll();
tree.setFilter('app');
</script>The factory accepts either a CSS selector string or a raw HTMLElement (the jQuery + prototype adapters both pass a raw element here). It returns the instance Ref, or undefined if the element was not found or the reference is already taken.
Enhancing existing markup
With no nodes param, the native target reads nested <ul>/<li> markup. Each <li> becomes a node; a nested <ul> becomes its children. data-* attributes map to node fields (data-key/data-id, data-icon (HTML), data-expanded, data-selected, data-checked, data-disabled, data-leaf, data-draggable):
<div id="tree">
<ul>
<li data-key="src" data-expanded="true">src
<ul>
<li data-key="index" data-leaf="true">index.ts</li>
</ul>
</li>
<li data-key="readme" data-leaf="true">README.md</li>
</ul>
</div>
<script>window.GSTree('#tree', { theme: 'dark' });</script>The instance registry — window.GSTreeConfigue
Every instance registers itself under its reference (a supplied string or an auto-generated UUID). Look one up later without keeping the return value around:
const tree = window.GSTreeConfigue.instance('demo');
tree.expand('src');
tree.setChecked(['index.ts']);
console.log(tree.getChecked(), tree.getSelected());
tree.destroy(); // tears down + unregistersUsage — 2. jQuery
When window.jQuery is present as the bundle loads, a $.fn.GSTree adapter is registered. It constructs one instance per matched element:
$('#tree').GSTree({ checkable: true, nodes: [/* … */] });Usage — 3. HTMLElement.prototype
The bundle also augments HTMLElement.prototype, so any element can build itself:
document.getElementById('tree').GSTree({ selectable: 'multiple', nodes: [/* … */] });Usage — 4. React
'use client';
import { useRef } from 'react';
import GSTree, { GSTreeHandle } from '@get-set/gs-tree';
export default function Demo() {
const ref = useRef<GSTreeHandle>(null);
return (
<>
<button onClick={() => ref.current?.expandAll()}>Expand all</button>
<input onChange={(e) => ref.current?.setFilter(e.target.value)} placeholder="Filter…" />
<GSTree
ref={ref}
theme="auto"
checkable
selectable="multiple"
draggable
defaultExpandAll
gsx={{ '.gs-tree-row-selected': { fontWeight: 600 } }}
onSelect={(keys, node) => console.log('selected', keys, node)}
onCheck={(checked, info) => console.log('checked', checked, info.halfCheckedKeys)}
onMove={(dragKey, dropKey, pos) => console.log('move', dragKey, pos, dropKey)}
nodes={[
{ id: 'a', label: 'Folder A', children: [{ id: 'a1', label: 'File A1' }] },
{ id: 'b', label: 'Folder B', children: [{ id: 'b1', label: 'File B1' }] }
]}
/>
</>
);
}The component is forwardRef; unknown DOM props (id, data-*, aria-*, event handlers…) are forwarded to the tree root, while plugin props are stripped so they never leak onto the DOM node.
Node shape — GSTreeNode
| Field | Type | Notes |
|---|---|---|
| id | string \| number | Stable key. key is an accepted alias. |
| label | string (native) · ReactNode (React) | Visible text. Filtering / type-ahead use its plain-text form. |
| icon | string (HTML) · ReactNode (React) | Leading icon. A string is rendered as HTML/emoji. |
| children | GSTreeNode[] · (node, ctx) => Promise<GSTreeNode[]> | Static children or an async lazy loader. |
| isLeaf | boolean | Force a leaf (no twisty, never loads). |
| expanded / selected / checked | boolean | Uncontrolled initial per-node state. |
| disabled | boolean | Non-interactive, dimmed; never toggled by a cascade. |
| checkable / draggable | boolean | Per-node override of the tree-level flag. |
| className | string | Extra class on the node row. |
| badge | string \| number | Trailing badge chip (a count, a status, "new"…). |
| badgeColor | string | Custom badge background (text goes white). |
| subtitle | string | Muted second line under the label. |
| meta | string | Right-aligned muted meta text (a size, a time…). |
| color | string | Per-node accent — tints the icon tile / dot + --gstree-node-color. |
| data | unknown | Arbitrary payload echoed back in callbacks. |
Params
Native params and React props share these names (React additionally accepts gsx and standard DOM attributes).
| Param | Type | Default | Description |
|---|---|---|---|
| nodes | GSTreeNode[] | [] | The tree data. |
| reference | string | auto UUID | Registry key. |
| expandedKeys | GSTreeKey[] | — | Controlled expanded set. |
| defaultExpanded | GSTreeKey[] | — | Uncontrolled initial expanded set. |
| defaultExpandAll | boolean | false | Expand every parent on mount. |
| accordion | boolean | false | Only one branch open per level. |
| checkable | boolean | false | Show checkboxes. |
| checkedKeys | GSTreeKey[] | — | Controlled checked set. |
| defaultChecked | GSTreeKey[] | — | Uncontrolled initial checked set. |
| checkStrictly | boolean | false | Independent checkboxes (no cascade). |
| selectable | false \| 'single' \| 'multiple' | 'single' | Selection mode. |
| selectedKeys | GSTreeKey[] | — | Controlled selected set. |
| defaultSelected | GSTreeKey[] | — | Uncontrolled initial selected set. |
| draggable | boolean | false | Enable drag-reorder. |
| dragHandle | false \| true \| 'bar' \| 'dots' \| 'grip' \| 'lines' | false | Restrict dragging to a rendered grip handle. |
| cancel | string | undefined | CSS selector; a pointer-down on a matching element (or its descendants) in a node does not start a drag — for custom interactive content (buttons/links/inputs) inside node labels. The built-in twisty/checkbox/row-actions/rename-input are already excluded. Works for whole-row + dragHandle modes, mouse + touch. Example: 'button, input, a, [data-no-drag]'. |
| dragGhost | boolean | true | Floating clone that follows the cursor. |
| lift | boolean | true | Lift the ghost (shadow + slight scale/tilt). |
| dropIndicator | 'line' \| 'pill' \| 'glow' | 'pill' | Drop-position indicator style. |
| dropZoneHighlight | boolean | true | Highlight the tree while dragging. |
| autoScroll | boolean | true | Auto-scroll near the tree's top/bottom edge. |
| dragClass / ghostClass | string | — | Extra classes on the grabbed row / ghost + indicator. |
| sortAnimation | number | 200 | Drop-settle (FLIP) reflow duration (ms). |
| sortEasing | string | — | Easing for the drop-settle reflow. |
| filter | string | '' | Filter query. |
| animation | boolean | true | Expand/collapse height slide. |
| animationDuration | number | 200 | Slide duration (ms). |
| animationEasing | string | cubic-bezier(0.16,1,0.3,1) | Slide easing. |
| reducedMotion | 'auto' \| boolean | 'auto' | Honor prefers-reduced-motion. |
| variant | 'default' \| 'bordered' \| 'filled' \| 'pills' \| 'cards' \| 'glass' \| 'minimal' \| 'rail' | 'default' | Visual variant (row/container skin). |
| twisty | 'chevron' \| 'caret' \| 'plusminus' \| 'arrow' | 'chevron' | Expander glyph style. |
| defaultIcons | false \| 'folder' \| 'dot' | false | Built-in icons for nodes without an icon. |
| iconTiles | boolean | false | Wrap icons in rounded tinted tiles (per-node color). |
| childAnimation | 'fade' \| 'slide' \| 'scale' \| false | 'fade' | Staggered child entrance on expand. |
| showCount | boolean | false | Child-count badge on parents. |
| emptyText | string | — | Message when no nodes are visible. |
| editable | boolean | false | Inline-rename on double-click / F2. |
| nodeActions | boolean \| GSTreeAction[] | false | Hover action buttons (true = add + rename + delete). |
| addNodeLabel | string | 'New node' | Label for a node created by the built-in "add" action. |
| confirmDelete | boolean | false | window.confirm before the built-in delete. |
| theme | 'light' \| 'dark' \| 'auto' | 'auto' | Visual theme. |
| accentColor | string | — | --gstree-accent token. |
| rtl | boolean | false | Right-to-left layout. |
| size | 'sm' \| 'md' \| 'lg' | 'md' | Row size preset. |
| indent | number | 20 | Indent per depth level (px). |
| showLines | boolean | false | Draw connector lines. |
| showIcons | boolean | true | Show per-node icons. |
| className | string | — | Extra class on the tree root. |
Callbacks
| Callback | Signature |
|---|---|
| onExpand | (node, key) => void |
| onCollapse | (node, key) => void |
| onSelect | (keys, node) => void |
| onCheck | (checkedKeys, { node, halfCheckedKeys }) => void |
| onActivate | (node, key) => void — Enter / primary click |
| onLoad | (node, children) => void — after a lazy loader resolves |
| onMove | (dragKey, dropKey, position) => boolean \| void — return false to veto |
| onChange | (nodes) => void — after ANY built-in edit, with the new tree |
| onAdd | (node, parentKey) => void |
| onRemove | (node) => void |
| onRename | (node, label) => void |
Imperative API
Native: the object returned by the factory (also via window.GSTreeConfigue.instance(ref)). React: the ref handle (GSTreeHandle).
| Method | Description |
|---|---|
| expand(key) / collapse(key) / toggle(key) | Expand / collapse / toggle a branch. |
| expandAll() / collapseAll() | Expand / collapse every parent. |
| select(key) | Select a node. |
| getSelected() | GSTreeKey[] of selected keys. |
| check(key) | Toggle a node's checkbox (with cascade unless strict). |
| getChecked() | GSTreeKey[] of fully-checked keys. |
| setChecked(keys) | Replace the checked set (re-derives half-checked). |
| scrollTo(key) | Scroll a node into view. |
| setFilter(query) | Apply / clear the filter. |
| addNode(parentKey, node, index?) | Add a child (or a root when parentKey is null); returns the new key. |
| removeNode(key) | Remove a node. |
| updateNode(key, patch) | Shallow-merge a patch into a node. |
| renameNode(key, label) | Set a node's label (fires onRename). |
| duplicateNode(key) | Clone a node with fresh keys next to it; returns the new key. |
| moveNode(dragKey, dropKey, position) | Reorder programmatically ('before' \| 'after' \| 'inside'). |
| beginEdit(key) | Start an inline rename. |
| getNode(key) / getNodes() | Read the current model. |
| refresh() | Rebuild from the current model + state. |
| destroy() | Tear down (native: unregisters too). |
Keyboard
| Key | Action |
|---|---|
| ↓ / ↑ | Move to next / previous visible node. |
| → | Expand a collapsed parent, else move to its first child. (RTL: ←.) |
| ← | Collapse an expanded parent, else move to its parent. (RTL: →.) |
| Home / End | First / last visible node. |
| Enter | Activate (select + onActivate). |
| Space | Toggle the checkbox (when checkable), else activate. |
| * | Expand all sibling parents at the current level. |
| type a letter | Type-ahead to the next node whose label starts with it. |
Theming
Every visual is a CSS custom property on .gs-tree, so you can theme without touching the JS:
.gs-tree {
--gstree-accent: #7c3aed;
--gstree-radius: 10px;
--gstree-row-height: 32px;
--gstree-indent: 24px;
--gstree-folder-color: #f59e0b; /* defaultIcons folders */
--gstree-badge-bg: #eef2f7; /* badge chip */
--gstree-glass-bg: rgba(255, 255, 255, 0.55); /* glass variant */
}theme="dark" (or auto under prefers-color-scheme: dark) swaps the background/border/hover/selected tokens — including the badge, folder/file, row-fill, card and glass tokens, so every variant works in both themes out of the box. accentColor and indent params set --gstree-accent / --gstree-indent inline per instance.
Picking a variant
| Variant | Feels like |
|---|---|
| default | Clean list — hover + tinted selection. |
| bordered | The tree sits in a bordered, softly-shadowed panel. |
| filled | Every row is a soft chip; selection tints with the accent. |
| pills | Fully-rounded rows; selection becomes a solid accent pill. |
| cards | Each root branch is an elevated card — great for grouped settings. |
| glass | Translucent, blurred panel for hero/dashboard surfaces. |
| minimal | Zero chrome; selection is an accent bar + colored label. |
| rail | App sidebar / nav: tall rounded rows, accent-tinted selection + bar. |
Editing
const ref = useRef<GSTreeHandle>(null);
<GSTree
ref={ref}
editable // double-click / F2 to rename
draggable // drag to reorder
nodeActions={['add', 'rename', 'duplicate', 'delete']} // hover buttons
iconTiles
onChange={(nodes) => save(nodes)} // persist the new tree
nodes={data}
/>;
// …or drive it imperatively:
const key = ref.current!.addNode('parent-id', { label: 'New' });
ref.current!.beginEdit(key); // inline-rename the fresh node
ref.current!.duplicateNode('some-id');
ref.current!.moveNode('a', 'b', 'inside');Sortable-grade drag
draggable turns on a drag engine tuned to feel like GSSortable:
<GSTree
draggable
dragHandle="grip" // grab only via a rendered grip (or false = whole row)
dropIndicator="glow" // 'line' | 'pill' | 'glow'
lift // ghost lifts with a shadow + slight tilt
autoScroll // scrolls when you drag near an edge
sortAnimation={200} // FLIP drop-settle
onMove={(dragKey, dropKey, pos) => pos !== 'inside'} // return false to veto
onChange={(nodes) => save(nodes)}
nodes={data}
/>While dragging you get a floating ghost clone, a dimmed source row, a live drop indicator (before / after / inside), a drop-zone highlight, edge auto-scroll, and a FLIP settle when the node lands — all prefers-reduced-motion aware. onMove can veto a drop; onChange fires with the reordered tree.
nodeActions also accepts custom buttons: { key, title, icon, danger?, hidden?, onClick(node, api) } — where api is the same op set (addNode / removeNode / renameNode / duplicateNode / beginEdit / …), so a custom button can mutate the tree too. In native, icon is an HTML/SVG string; in React it's a ReactNode.
License
ISC © Get-Set
