From PD rockets by derekr, where it is pd-sortable-tree, with its names in Starbase's sb- prefix and the patches in patches/pd-rockets. For this tree they open and close a folder with a click on its row, declare its move event for the API table below, skip the move animation when the reader prefers reduced motion, and let it nest inside the other PD rockets surfaces, or they inside it.
Folders and files the server renders and arranges, in compact rows like a file explorer. Drag a row, or focus it and hold Alt while you press the arrow keys, to move it among its siblings, into a folder or out of one. The component shows where the row would land, then emits sb-tree-move with the row, the folder it leaves, the folder it goes to and the row it goes before. It moves nothing itself: the server applies the move and sends the tree back, and the morph puts each row in its place with a short animation.
sb-tree is a different thing: a tree view that renders its items from JSON, selects them and loads children lazily. Here the server writes the whole tree as markup, and the component only moves it around.
Examples
Put the moons back with their planets
The server renders the tree with its arrangement in data-state: each folder's children in parentheses after it. A move sends that arrangement with the event's detail to /demo/arrange/sortable-tree, which answers with the tree after the move. Phobos belongs to Mars and Callisto to Jupiter. Nothing is stored: try it in two tabs.
<style>
.demo-tree { display: block; inline-size: min(100%, 18rem); }
.demo-tree [data-tree-children] { display: grid; gap: 2px; }
.demo-tree [data-tree-children] [data-tree-children] { padding-inline-start: 1.25rem; }
.demo-tree [data-tree-row], [data-drag-preview][data-tree-row] {
padding: 0.3rem 0.6rem;
border: 1px solid transparent;
background: var(--sb-surface-card);
color: var(--sb-text-1);
cursor: grab;
touch-action: none;
user-select: none;
}
.demo-tree [data-tree-row] { position: relative; }
.demo-tree [data-tree-kind="folder"] > [data-tree-row] { font-weight: 600; }
.demo-tree [data-tree-row][aria-expanded="false"] { color: var(--sb-text-2); }
.demo-tree [data-tree-row]:hover { background: var(--sb-surface-hover); }
.demo-tree [data-tree-row]:focus-visible { outline: 2px solid var(--sb-brand-light); outline-offset: -2px; }
.demo-tree [data-dragging] { opacity: 0.35; }
.demo-tree[data-key-staging] [data-tree-row]:focus { border-color: var(--sb-brand); }
.demo-tree [data-tree-into] { border-color: var(--sb-brand); background: var(--sb-brand-subtle); }
.demo-tree [data-tree-before]::before, .demo-tree [data-tree-end]::after {
content: "";
display: block;
block-size: 3px;
background: var(--sb-brand);
}
.demo-tree [data-tree-before]::before { position: absolute; inset: -3px 0 auto; }
[data-drag-preview][data-tree-row] { box-shadow: var(--sb-shadow-overlay, 0 8px 16px rgb(0 0 0 / 0.35)); cursor: grabbing; }
@media (forced-colors: active) {
.demo-tree [data-tree-before]::before, .demo-tree [data-tree-end]::after { forced-color-adjust: none; background: Highlight; }
.demo-tree [data-tree-into], .demo-tree [data-tree-row]:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; }
.demo-tree [data-dragging], [data-drag-preview][data-tree-row] { border-style: dashed; }
}
</style>
<sb-sortable-tree id="solar-moons" data-ignore-morph class="demo-tree" data-state="earth(moon phobos) mars(deimos) jupiter(europa io) callisto"
data-on:sb-tree-move="@get('/demo/arrange/sortable-tree', {payload: {id: el.id, state: el.dataset.state, move: evt.detail}})">
<div data-tree-children data-tree-parent="" aria-label="Solar System">
<div id="solar-moons-earth" data-tree-node="earth" data-tree-kind="folder">
<div data-tree-row tabindex="0" aria-label="folder: Earth" aria-expanded="true">馃獝 Earth</div>
<div data-tree-children data-tree-parent="earth">
<div id="solar-moons-moon" data-tree-node="moon" data-tree-kind="file">
<div data-tree-row tabindex="0" aria-label="file: Moon">馃寫 Moon</div>
</div>
<div id="solar-moons-phobos" data-tree-node="phobos" data-tree-kind="file">
<div data-tree-row tabindex="0" aria-label="file: Phobos">馃寫 Phobos</div>
</div>
</div>
</div>
<div id="solar-moons-mars" data-tree-node="mars" data-tree-kind="folder">
<div data-tree-row tabindex="0" aria-label="folder: Mars" aria-expanded="true">馃獝 Mars</div>
<div data-tree-children data-tree-parent="mars">
<div id="solar-moons-deimos" data-tree-node="deimos" data-tree-kind="file">
<div data-tree-row tabindex="0" aria-label="file: Deimos">馃寫 Deimos</div>
</div>
</div>
</div>
<div id="solar-moons-jupiter" data-tree-node="jupiter" data-tree-kind="folder">
<div data-tree-row tabindex="0" aria-label="folder: Jupiter" aria-expanded="true">馃獝 Jupiter</div>
<div data-tree-children data-tree-parent="jupiter">
<div id="solar-moons-europa" data-tree-node="europa" data-tree-kind="file">
<div data-tree-row tabindex="0" aria-label="file: Europa">馃寫 Europa</div>
</div>
<div id="solar-moons-io" data-tree-node="io" data-tree-kind="file">
<div data-tree-row tabindex="0" aria-label="file: Io">馃寫 Io</div>
</div>
</div>
</div>
<div id="solar-moons-callisto" data-tree-node="callisto" data-tree-kind="file">
<div data-tree-row tabindex="0" aria-label="file: Callisto">馃寫 Callisto</div>
</div>
</div>
</sb-sortable-tree>
Markup and events
The tree is your page's markup, in four kinds of element:
- A list,
data-tree-children: the top level directly inside the tree, and one in each folder, withdata-tree-parent="<the folder's id>". The top level's parent is"". - A node,
data-tree-node="<id>", in a list. Withdata-tree-kind="folder"it is a folder; any other node is a file. - A row,
data-tree-row, directly inside its node: what you see, drag and focus. Rows need atabindexto take the keyboard focus:"0"on every row puts each in the Tab order, as in the demo;"0"on the first and"-1"on the rest makes the tree one Tab stop, and the arrow keys still reach every row. - A folder's list, directly inside the folder's node, after its row.
Give each node a unique id. The morph then moves nodes when the server's order changes, and the component sees the move and closes the folders the reader closed again. Without ids the morph rewrites nodes in their places, and a closed folder can open again after an answer.
Datastar's official release (v1.0.4) crashes on a morph that moves a node with an id when the node holds a Rocket component (#1209, fixed for its next release). The build the install snippets load has the fix, so with it a row may hold one, such as an sb-relative-time for a file's date. On the official release, keep Rocket components out of the rows, or the ids off the nodes.
| Event | Detail | When |
|---|---|---|
sb-tree-move |
{ itemId, fromParent, toParent, before } |
A row was dropped, or a keyboard move committed. fromParent and toParent are folder ids, "" for the top level; before is the id of the node it now precedes, or "" for the end of the folder. |
A drop on the middle of a folder's row puts the node at the end of that folder; on the upper or lower part of a row, before or after it. The component never offers a folder a place inside itself, nor a file as a parent. The event bubbles: when trees are nested, check evt.target in a handler that several of them reach.
A click on a folder's row opens or closes it, and so do Arrow Right and Arrow Left on the focused row; a click on a control inside the row (a link, a button, a field) is left to that control. Folders open and close in the browser alone: the component hides a closed folder's list and keeps aria-expanded on its row, and the server never hears of it. A move into a closed folder opens it once the server has answered.
Keyboard
| Keys | Action |
|---|---|
| Arrow Down / Arrow Up, or j / k | Focus the next / previous visible row |
| Arrow Right, or l | Open a closed folder; on an open one, focus its first row |
| Arrow Left, or h | Close an open folder; elsewhere, focus the folder that holds the row |
| Home / End | Focus the first / last visible row |
| Alt + Arrow Down / Up, or Alt + j / k | Move the row among its siblings; past the first or last, out of the folder, before or after it |
| Alt + Arrow Right, or Alt + l | Move the row into the nearest folder above it, at the end |
| Alt + Arrow Left, or Alt + h | Move the row out of its folder, right after it |
| Escape | Cancel a move before Alt is released |
Moves add up while Alt is held; releasing Alt sends the move. On macOS, Ctrl + n and Ctrl + p also move the focus. Each action takes its keys from a data-key-<action> attribute on the tree, a space-separated list such as data-key-focus-next="ArrowDown n"; an empty value turns the action off. The actions are focus-next, focus-previous, focus-left, focus-right, focus-first, focus-last, move-up, move-down, move-left, move-right and cancel.
On the server
The server owns the tree. A handler checks the move, applies it and renders the tree again; this is the demo's, which keeps the arrangement in the markup instead of a database:
// arrangeSortableTree applies an sb-tree-move ({itemId, fromParent,
// toParent, before}; "" is the top level) to the tree in a state.
func arrangeSortableTree(state string, move json.RawMessage) (string, error) {
root, err := parseTree(state)
if err != nil {
return "", err
}
var m struct {
ItemID string `json:"itemId"`
FromParent string `json:"fromParent"`
ToParent string `json:"toParent"`
Before string `json:"before"`
}
if err := json.Unmarshal(move, &m); err != nil {
return "", err
}
// Every node's list and the folder it is in.
lists := map[string]*[]*treeNode{"": &root}
parent := map[string]string{}
var item *treeNode
var walk func(folder string, list []*treeNode)
walk = func(folder string, list []*treeNode) {
for _, n := range list {
parent[n.id] = folder
if n.id == m.ItemID {
item = n
}
if n.folder {
lists[n.id] = &n.children
walk(n.id, n.children)
}
}
}
walk("", root)
to, ok := lists[m.ToParent]
switch {
case item == nil || parent[m.ItemID] != m.FromParent:
return "", fmt.Errorf("%q is not in %q", m.ItemID, m.FromParent)
case !ok:
return "", fmt.Errorf("%q is not a folder", m.ToParent)
}
for f := m.ToParent; f != ""; f = parent[f] {
if f == m.ItemID {
return "", fmt.Errorf("%q can't go into itself", m.ItemID)
}
}
// Out of its folder, at the end of the other, then before its new neighbour.
from := lists[m.FromParent]
*from = remove(*from, item)
order := make([]string, 0, len(*to)+1)
byID := map[string]*treeNode{item.id: item}
for _, n := range *to {
order = append(order, n.id)
byID[n.id] = n
}
if order, err = moveBefore(append(order, item.id), item.id, m.Before); err != nil {
return "", err
}
*to = (*to)[:0]
for _, id := range order {
*to = append(*to, byID[id])
}
state = formatTree(root)
if _, err := parseTree(state); err != nil {
return "", fmt.Errorf("%q in %q nests the tree too deep", m.ItemID, m.ToParent)
}
return state, nil
}
// renderSortableTree is the tree's markup: the host the morph replaces,
// with the tree in data-state, folders open, and a row per body. Each node
// has an id, so the morph moves it rather than rewriting the node in its
// place, and the component sees the move (it closes its closed folders
// again when the tree's children change).
func renderSortableTree(id, state string) string {
root, _ := parseTree(state)
var b strings.Builder
fmt.Fprintf(&b, "<sb-sortable-tree id=\"%s\" data-ignore-morph class=\"demo-tree\" data-state=\"%s\"\n\tdata-on:sb-tree-move=\"%s\">\n", id, state, arrangeOn("sortable-tree"))
b.WriteString("\t<div data-tree-children data-tree-parent=\"\" aria-label=\"Solar System\">\n")
var nodes func(list []*treeNode, indent string)
nodes = func(list []*treeNode, indent string) {
for _, n := range list {
kind, expanded := "file", ""
if n.folder {
kind, expanded = "folder", ` aria-expanded="true"`
}
name := html.EscapeString(bodies()[n.id].Name)
fmt.Fprintf(&b, "%s<div id=\"%s-%s\" data-tree-node=\"%s\" data-tree-kind=\"%s\">\n", indent, id, n.id, n.id, kind)
fmt.Fprintf(&b, "%s\t<div data-tree-row tabindex=\"0\" aria-label=\"%s: %s\"%s>%s</div>\n", indent, kind, name, expanded, label(n.id))
if n.folder {
fmt.Fprintf(&b, "%s\t<div data-tree-children data-tree-parent=\"%s\">\n", indent, n.id)
nodes(n.children, indent+"\t\t")
fmt.Fprintf(&b, "%s\t</div>\n", indent)
}
fmt.Fprintf(&b, "%s</div>\n", indent)
}
}
nodes(root, "\t\t")
b.WriteString("\t</div>\n</sb-sortable-tree>")
return b.String()
}
Styling
The component adds no styles: the tree is your page's markup, styled by your page's CSS. While a row moves, it marks the elements involved:
data-drag-activeon the tree anddata-draggingon the row while you drag.data-tree-beforeon the row the moving one would land before,data-tree-intoon the row of the folder it would go into, anddata-tree-endon the list it would land at the end of.data-key-stagingon the tree while a keyboard move waits for Alt to be released.hiddenon a closed folder's list, andaria-expandedon every folder's row.- The row under the pointer is a copy of the row in
<body>, markeddata-drag-preview, with the original's size in--sb-source-widthand--sb-source-height. A<template data-sb-preview>that is a direct child of the row replaces the copy: its content goes in a<div>with the template's class. One deeper inside the row is ignored. - A
<template data-sb-target="before">(or"into","end";data-sb-target=""serves every kind without its own) is copied into the marked row or list as a drop marker: a<span data-sb-target-indicator="<kind>">withposition: absolute, which your CSS places. The templates must be direct children of the tree; one inside a list or a row is ignored.
The example above shows one way to draw them.
Accessibility
Rows are focusable elements of your page, so their names are yours: the demo labels each one, "folder: Earth" or "file: Moon". The markup has no tree roles, like PD rockets' own examples, so assistive technology reads the rows as a list of labelled items, and the aria-expanded the component keeps on folder rows only counts on an element whose role supports it (a treeitem or a button). Keyboard moves need no pointer. When the server's answer arrives, the moved row keeps the focus, and a folder it went into opens.
Licence
The code is PD rockets', with Starbase's patches, under its Beer-Ware licence, in LICENSE-pd-rockets.txt next to it. go run ./cmd/vendorpd brings in a newer release.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.
<script type="importmap">
{ "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-sortable-tree id="files" data-on:sb-tree-move="@post('/files/move', {payload: evt.detail})">
<div data-tree-children data-tree-parent="">
<div id="file-docs" data-tree-node="docs" data-tree-kind="folder">
<div data-tree-row tabindex="0">docs</div>
<div data-tree-children data-tree-parent="docs">
<div id="file-readme" data-tree-node="readme"><div data-tree-row tabindex="0">README.md</div></div>
</div>
</div>
<div id="file-license" data-tree-node="license"><div data-tree-row tabindex="0">LICENSE</div></div>
</div>
</sb-sortable-tree>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/sortable-tree.min.js": "sha384-12IMRmMH8bBod7yv7Yc4fSvfiYi+ldU/aWjUYyqyIQjR/kVhvoWBBn/IqtPrZjGY",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/contracts/sortable-tree.min.js": "sha384-9/6IENb76SrydR/MiT4hLS0NvEGWfOB+8JqTqqFrZN3SMSptMzyrgbtZi3PO6hsp",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/drag-state.min.js": "sha384-5Y+afnEzy5l/9zFkAh+LOmom6361otYZ1mfJztIuXD7yOlTGx+TVHk1h9biAKgyX",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/flip.min.js": "sha384-1lsCQwc6EMFn4yzzI9FZbnF3Zbw6G59SB/cMrp533rqQ2gnI5eehw/RCrds3YJfr",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/focus-recovery.min.js": "sha384-JHh8h56l3OTHOT2hMP71pjVnh+AhoEBm41Fi2GetKZB35rHk5hVh0l+HXr92ZFqH",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/keyboard-staging.min.js": "sha384-WnYZ2saImC9lL8d4IfSLm3FT5TEzIPPsMviBcJe5NTuVh/vFpZm6/d0oMDYs8s8E",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/keyboard.min.js": "sha384-5AVhHD8gnbU5fkXCucowCxLCa0MxEQ7d2mQ9dRh+5eXhHEkfV8py2h6jinGH4PDY",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/ownership.min.js": "sha384-HhT3ZO2nxuGm6d66kwqQaE7Q9YADIW0cQqCQVTx5hKVW0D9PNvRBAqDxI26f8xBo",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/pointer-drag.min.js": "sha384-yslRfcHaD24bjpam90FqtgQVDHZXb4QT/GOfuQElOyb8qqgacm6PaesXPD9CbYa6",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/visual-outlets.min.js": "sha384-+AzW6If/d7MsA7+32XnB3SaY6VIbxTTzS8jc6fHLwSem6VyO3eH8ADPyLfTiSc3D"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/sortable-tree.min.js" integrity="sha384-12IMRmMH8bBod7yv7Yc4fSvfiYi+ldU/aWjUYyqyIQjR/kVhvoWBBn/IqtPrZjGY"></script>
<sb-sortable-tree id="files" data-on:sb-tree-move="@post('/files/move', {payload: evt.detail})">
<div data-tree-children data-tree-parent="">
<div id="file-docs" data-tree-node="docs" data-tree-kind="folder">
<div data-tree-row tabindex="0">docs</div>
<div data-tree-children data-tree-parent="docs">
<div id="file-readme" data-tree-node="readme"><div data-tree-row tabindex="0">README.md</div></div>
</div>
</div>
<div id="file-license" data-tree-node="license"><div data-tree-row tabindex="0">LICENSE</div></div>
</div>
</sb-sortable-tree>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/sortable-tree.min.js": "sha384-12IMRmMH8bBod7yv7Yc4fSvfiYi+ldU/aWjUYyqyIQjR/kVhvoWBBn/IqtPrZjGY",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/contracts/sortable-tree.min.js": "sha384-9/6IENb76SrydR/MiT4hLS0NvEGWfOB+8JqTqqFrZN3SMSptMzyrgbtZi3PO6hsp",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/drag-state.min.js": "sha384-5Y+afnEzy5l/9zFkAh+LOmom6361otYZ1mfJztIuXD7yOlTGx+TVHk1h9biAKgyX",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/flip.min.js": "sha384-1lsCQwc6EMFn4yzzI9FZbnF3Zbw6G59SB/cMrp533rqQ2gnI5eehw/RCrds3YJfr",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/focus-recovery.min.js": "sha384-JHh8h56l3OTHOT2hMP71pjVnh+AhoEBm41Fi2GetKZB35rHk5hVh0l+HXr92ZFqH",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/keyboard-staging.min.js": "sha384-WnYZ2saImC9lL8d4IfSLm3FT5TEzIPPsMviBcJe5NTuVh/vFpZm6/d0oMDYs8s8E",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/keyboard.min.js": "sha384-5AVhHD8gnbU5fkXCucowCxLCa0MxEQ7d2mQ9dRh+5eXhHEkfV8py2h6jinGH4PDY",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/ownership.min.js": "sha384-HhT3ZO2nxuGm6d66kwqQaE7Q9YADIW0cQqCQVTx5hKVW0D9PNvRBAqDxI26f8xBo",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/pointer-drag.min.js": "sha384-yslRfcHaD24bjpam90FqtgQVDHZXb4QT/GOfuQElOyb8qqgacm6PaesXPD9CbYa6",
"https://starbase.zweiundeins.gmbh/c/sortable-tree@de00fb0654b2/core/visual-outlets.min.js": "sha384-+AzW6If/d7MsA7+32XnB3SaY6VIbxTTzS8jc6fHLwSem6VyO3eH8ADPyLfTiSc3D"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@a7896c28d7cd/autoloader.js" integrity="sha384-avgqrgW9PG1hl/YoLOqUcsiTNPl4fikGkS1FmaQ01yZWKg402iP0IJLdzzl76811"></script>
<sb-sortable-tree id="files" data-on:sb-tree-move="@post('/files/move', {payload: evt.detail})">
<div data-tree-children data-tree-parent="">
<div id="file-docs" data-tree-node="docs" data-tree-kind="folder">
<div data-tree-row tabindex="0">docs</div>
<div data-tree-children data-tree-parent="docs">
<div id="file-readme" data-tree-node="readme"><div data-tree-row tabindex="0">README.md</div></div>
</div>
</div>
<div id="file-license" data-tree-node="license"><div data-tree-row tabindex="0">LICENSE</div></div>
</div>
</sb-sortable-tree>Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<sb-sortable-tree>sortable-tree.jsminified 2.4 kB brotli 路 readable 16.4 kB, 3.3 kB brotlicontracts/sortable-tree.jsminified 352 B brotli 路 readable 788 B, 409 B brotlicore/drag-state.jsminified 489 B brotli 路 readable 1.4 kB, 530 B brotlicore/flip.jsminified 800 B brotli 路 readable 3.1 kB, 1.1 kB brotlicore/focus-recovery.jsminified 579 B brotli 路 readable 2.2 kB, 733 B brotlicore/keyboard-staging.jsminified 616 B brotli 路 readable 2.8 kB, 822 B brotlicore/keyboard.jsminified 1.1 kB brotli 路 readable 4.3 kB, 1.5 kB brotlicore/ownership.jsminified 403 B brotli 路 readable 1.2 kB, 549 B brotlicore/pointer-drag.jsminified 1.4 kB brotli 路 readable 7.1 kB, 1.8 kB brotlicore/visual-outlets.jsminified 534 B brotli 路 readable 2.0 kB, 715 B brotli
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/sortable-tree/sortable-tree.min.js"></script>
<sb-sortable-tree id="files" data-on:sb-tree-move="@post('/files/move', {payload: evt.detail})">
<div data-tree-children data-tree-parent="">
<div id="file-docs" data-tree-node="docs" data-tree-kind="folder">
<div data-tree-row tabindex="0">docs</div>
<div data-tree-children data-tree-parent="docs">
<div id="file-readme" data-tree-node="readme"><div data-tree-row tabindex="0">README.md</div></div>
</div>
</div>
<div id="file-license" data-tree-node="license"><div data-tree-row tabindex="0">LICENSE</div></div>
</div>
</sb-sortable-tree>Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
sortable-tree.js | 16.4 kB | 3.7 kB | 3.3 kB | 2.4 kB |
contracts/sortable-tree.js | 788 B | 479 B | 409 B | 352 B |
core/drag-state.js | 1.4 kB | 641 B | 530 B | 489 B |
core/flip.js | 3.1 kB | 1.2 kB | 1.1 kB | 800 B |
core/focus-recovery.js | 2.2 kB | 908 B | 733 B | 579 B |
core/keyboard-staging.js | 2.8 kB | 969 B | 822 B | 616 B |
core/keyboard.js | 4.3 kB | 1.7 kB | 1.5 kB | 1.1 kB |
core/ownership.js | 1.2 kB | 677 B | 549 B | 403 B |
core/pointer-drag.js | 7.1 kB | 2.0 kB | 1.8 kB | 1.4 kB |
core/visual-outlets.js | 2.0 kB | 891 B | 715 B | 534 B |
| Total | 41.3 kB | 13.3 kB | 11.4 kB | 8.7 kB |
API reference
Props
This component takes no props.
Events
| Name | Description |
|---|---|
sb-tree-move | A row was dropped, or a keyboard move committed. detail: { itemId, fromParent, toParent, before }: the parents are folder ids, "" for the top level; before is the id of the node it now precedes, "" for the end of the folder. |