Tree ViewATM
A hierarchical tree for browsing nested data like file systems. Uses native <details> for expand/collapse with ARIA tree roles.
On this page (16)
§File Explorer
Nested tree with folders and files.
§Guidelines
data-variant='guides' on the tree root draws a vertical guideline under every open folder, so the depth of nesting reads at a glance. The guideline of the level that holds keyboard focus darkens.
§Selection
Add data-selectable to the tree root: a click, Enter or Space selects the row (aria-selected='true' - a primary-tinted surface and medium weight). A folder toggles AND selects. Every choice fires a bubbling tree-select event with the treeitem in detail.item.
§Checkboxes
data-checkable on the tree: a .tree-check checkbox in each row (after the icons, before the label). Checking a folder checks everything in it; a folder with some children checked shows the indeterminate dash. Space ticks the focused row, a click on a file's name too. The inputs are real form controls - name/value submit - and every change fires tree-check with the checked values.
§Checkboxes with emojis, independent
data-checkable='independent' keeps every box on its own (no cascade) - a permissions list where a group can be granted without its members. Emojis stand in for icons.
§Drag and drop
data-sortable: drag a row onto the upper or lower edge of another to put it before or after, onto the middle of a folder to move it inside (the folder opens). Keyboard: Alt+ArrowUp / Alt+ArrowDown moves the focused row among its siblings. Every move fires tree-reorder with the item, its new parent and index.
§Checkboxes and drag and drop
Both at once: pick items and sort them. Moving an item re-derives the folders' checkbox states.
§Disabled items
aria-disabled='true' on a treeitem dims the row and makes it inert: a disabled folder can't be opened (click, Enter, Space, ArrowRight), a disabled row can't be selected or followed. It stays in the arrow-key path, so screen-reader users still learn it exists.
§Beyond files
Any hierarchy fits: product categories, an org chart, a docs navigation menu. Icons are optional - the chevron is the only required glyph. Navigation leaves are links (a.tree-leaf with href); aria-selected marks the current page.
§Collapsed and expanded
The same tree authored two ways: every <details> closed (only the top level shows) or every <details> open with aria-expanded='true' (the whole hierarchy at once). The open attribute is the whole API; each branch's api.setState('expanded') / ('default') changes it later.
§Deep nesting
Eight levels in a 20rem frame, with guides so each level stays traceable. Every level indents 1.25rem; labels that run out of room truncate with an ellipsis instead of overflowing - keep the full text in title for hover and assistive tech.
§Empty branch
A folder with no children keeps its role='group' list empty; the tree shows a muted placeholder row instead of opening onto nothing. The text defaults to 'Empty' - set data-empty on the .tree-group for your own wording.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§States
Named states via the shared State API, driven per instance through the bound api:
default- the branch's authored open/closed state (restored from the init snapshot)expanded- branch open, children visible (per-branch API;aria-expandedstays in sync via the toggle event)
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/tree-view-{state}.png.
Machine contract - verified against tree-view.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
expanded | boolean | true, false | false | First branch open (native <details open>; default restores the authored state). |
§CSS view file
Styles for the tree-view component. Uses design tokens for colors, spacing, and radius.
@layer components { .tree { list-style: none; margin: 0; padding: 0; font-size: 0.875rem; } .tree-group { list-style: none; margin: 0; padding: 0 0 0 1.25rem; } .tree-item { padding: 0; } .tree-branch { border: none; /* Two gotchas, both required for the branch to glide: 1. `::details-content` must attach to the compound (`&::details-content`, NOT `& ::details-content` - the descendant form never matches, the pseudo's originating element is the subject itself). 2. `block-size: auto` is a keyword - without interpolate-size the 0→auto pair is non-interpolable and the transition silently snaps. */ &::details-content { block-size: 0; overflow-y: clip; interpolate-size: allow-keywords; transition: block-size 200ms ease, content-visibility 200ms allow-discrete; } &[open]::details-content { block-size: auto; } } @starting-style { .tree-branch[open]::details-content { block-size: 0; } } .tree-branch-trigger { display: flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.5rem; border-radius: var(--radius-md); cursor: pointer; color: var(--foreground); list-style: none; &:hover { background-color: var(--accent); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } &::-webkit-details-marker { display: none; } & svg { width: 1rem; height: 1rem; flex-shrink: 0; } & svg:first-child { width: 0.875rem; height: 0.875rem; color: var(--muted-foreground); transition: transform 200ms ease; } } details[open] > .tree-branch-trigger > svg:first-child { transform: rotate(90deg); } .tree-leaf { display: flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.5rem; padding-left: calc(0.5rem + 0.875rem + 0.375rem); border-radius: var(--radius-md); color: var(--foreground); cursor: default; &:hover { background-color: var(--accent); } & svg { width: 1rem; height: 1rem; flex-shrink: 0; color: var(--muted-foreground); } } /* -- Checkboxes (data-checkable) --------------------------------- A .tree-check (the checkbox component) sits after the icons, before the label; tree-view.js cascades and rolls it up (indeterminate). */ .tree-check { flex-shrink: 0; margin: 0; } .tree[data-checkable] .tree-leaf:has(.tree-check) { cursor: pointer; } /* -- Drag & drop (data-sortable) ---------------------------------- A dragged item fades; the row under the pointer shows where it lands: a line above / below (before / after) or a tinted frame (into a folder). */ .tree[data-sortable] :is(.tree-branch-trigger, .tree-leaf)[draggable="true"] { cursor: grab; } .tree[data-sortable] :is(.tree-branch-trigger, .tree-leaf):active { cursor: grabbing; } .tree [data-dragging] > :is(.tree-leaf, details > .tree-branch-trigger) { opacity: 0.4; } :is(.tree-branch-trigger, .tree-leaf) { &[data-drop="before"] { box-shadow: inset 0 2px 0 var(--primary); } &[data-drop="after"] { box-shadow: inset 0 -2px 0 var(--primary); } &[data-drop="inside"] { background-color: color-mix(in oklch, var(--primary) 12%, transparent); box-shadow: inset 0 0 0 1px var(--primary); } } /* -- Labels ----------------------------------------------------- The label is the trigger's / leaf's last <span>. Deep nesting eats the inline space (1.25rem indent per level), so a long label truncates with an ellipsis instead of overflowing the tree - put the full text in title="…" when it matters. Leaves may be links (<a class="tree-leaf">, navigation trees): no underline, pointer cursor. */ :is(.tree-branch-trigger, .tree-leaf) > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } a.tree-leaf { text-decoration: none; cursor: pointer; } /* -- Selection (opt-in: data-selectable on .tree) --------------- aria-selected on the treeitem is the state (tree-view.js writes it on click / Enter / Space; authored markup may preset it). The selected row gets a primary-tinted surface and medium weight - distinct from the neutral accent hover surface, in every theme. The row is the item's leaf OR its branch's <summary> (a grandchild: <li> > <details> > <summary>). */ :is(.tree-item[aria-selected="true"] > .tree-leaf, .tree-item[aria-selected="true"] > .tree-branch > .tree-branch-trigger) { background-color: color-mix(in oklch, var(--primary) 12%, transparent); color: var(--foreground); font-weight: 500; } .tree[data-selectable] :is(.tree-leaf, .tree-branch-trigger) { cursor: pointer; } /* -- Disabled: aria-disabled="true" on the treeitem -------------- Still focusable (APG: disabled items stay in the keyboard path) but not operable - tree-view.js refuses to open or select it. The dimming covers the row only; an open disabled branch's children keep their own state. */ :is(.tree-item[aria-disabled="true"] > .tree-leaf, .tree-item[aria-disabled="true"] > .tree-branch > .tree-branch-trigger) { opacity: 0.5; cursor: not-allowed; &:hover { background-color: transparent; } } /* -- Empty branch ----------------------------------------------- A folder with no children shows a muted placeholder row instead of opening onto nothing. Text: data-empty on the .tree-group ("Empty" by default). :has() rather than :empty - authored whitespace would defeat :empty. Generated content: screen readers announce the group's size. ::after, because the guides variant already draws its line with ::before. */ .tree-group:not(:has(> .tree-item))::after { content: 'Empty'; display: block; padding: 0.25rem 0.5rem; padding-left: calc(0.5rem + 0.875rem + 0.375rem); color: var(--muted-foreground); font-style: italic; } .tree-group[data-empty]:not(:has(> .tree-item))::after { content: attr(data-empty); } /* -- Variant: guides -------------------------------------------- data-variant="guides" on the .tree root draws one vertical guideline per nested .tree-group, so the depth of nesting reads at a glance. The line sits under the parent branch's chevron centre (trigger padding 0.5rem + half the 0.875rem chevron) and spans the whole group; it is a border, not a background, so forced-colors keeps it visible. The line of the innermost group that holds focus darkens - the keyboard user sees which level they are on. */ .tree[data-variant="guides"] .tree-group { position: relative; &::before { content: ''; position: absolute; inset-block: 0; inset-inline-start: calc(0.5rem + 0.4375rem - 0.5px); border-inline-start: 1px solid var(--border); pointer-events: none; } &:focus-within:not(:has(.tree-group:focus-within))::before { border-inline-start-color: var(--muted-foreground); } } /* -- Density ---------------------------------------------------- data-density on the .tree root scales the row padding-block. Indent (padding-left) is structural - untouched. comfortable == the default. */ .tree:where([data-density="compact"]) { & .tree-branch-trigger, & .tree-leaf { padding-block: 0.125rem; } } .tree:where([data-density="comfortable"]) { & .tree-branch-trigger, & .tree-leaf { padding-block: 0.25rem; } } .tree:where([data-density="spacious"]) { & .tree-branch-trigger, & .tree-leaf { padding-block: 0.375rem; } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .tree, .tree *, .tree::before, .tree::after, .tree *::before, .tree *::after, .tree::backdrop, .tree-branch, .tree-branch *, .tree-branch::before, .tree-branch::after, .tree-branch *::before, .tree-branch *::after, .tree-branch::backdrop, .tree-branch::details-content, .tree-branch-trigger, .tree-branch-trigger *, .tree-branch-trigger::before, .tree-branch-trigger::after, .tree-branch-trigger *::before, .tree-branch-trigger *::after, .tree-branch-trigger::backdrop, .tree-group, .tree-group *, .tree-group::before, .tree-group::after, .tree-group *::before, .tree-group *::after, .tree-group::backdrop, .tree-item, .tree-item *, .tree-item::before, .tree-item::after, .tree-item *::before, .tree-item *::after, .tree-item::backdrop, .tree-leaf, .tree-leaf *, .tree-leaf::before, .tree-leaf::after, .tree-leaf *::before, .tree-leaf *::after, .tree-leaf::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}/* Forced colors (Windows High Contrast) drop the tinted surface - the selected row must still read as selected: system Highlight pair, the same signal native listboxes use. */@media (forced-colors: active) { @layer components { :is(.tree-item[aria-selected="true"] > .tree-leaf, .tree-item[aria-selected="true"] > .tree-branch > .tree-branch-trigger) { forced-color-adjust: none; background-color: Highlight; color: HighlightText; } }}§JavaScript view file
Interaction logic for the tree-view component. Uses data attributes for wiring.
// -- Tree View ------------------------------------------------// Keyboard navigation and ARIA state for tree views, plus the named-state// API bound per branch (<details class="tree-branch">), so agents/tests can// expand branches by name (AGENTS.md "State API"). Opt-in on the .tree:// data-selectable (single selection), data-checkable (checkboxes that// cascade to children and roll up to parents - tri-state) and data-sortable// (drag & drop reordering with the native Drag and Drop API, Alt+Arrow keys).// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const treeViewStates = ['default', 'expanded'];/** * UI side of setState (per branch): 'expanded' opens the branch, 'default' * restores the authored open/closed snapshot taken at init. The <details> * toggle event keeps aria-expanded on the treeitem in sync automatically. */function triggerStateChange(details, stateName, _config) { switch (stateName) { case 'default': details.open = details._defaultOpen ?? false; break; case 'expanded': details.open = true; break; }}/** Registry-level API; pass the branch element explicitly. Unknown names throw. */export const treeViewApi = { setState(details, stateName, config = {}) { if (!treeViewStates.includes(stateName)) { throw new Error(`tree-view: unknown state "${stateName}" (supported: ${treeViewStates.join(', ')})`); } triggerStateChange(details, stateName, config); // state lives on the ELEMENT, not the module (many branches per tree) details.dataset.stateName = stateName; details._stateConfig = config; }, getState(details) { // reflect reality: summary clicks and ArrowLeft/Right change it too return { name: details.open ? 'expanded' : 'default', config: details._stateConfig ?? {}, }; },};df$.treeViewApi = treeViewApi;df$.treeViewStates = treeViewStates;/** The treeitem a row (branch trigger or leaf) belongs to. */const itemOf = (row) => row.closest('[role="treeitem"]');/** aria-disabled="true" on the treeitem: focusable, never operable. */const isDisabled = (item) => item?.getAttribute('aria-disabled') === 'true';/** * Single selection (APG tree, opt-in via data-selectable on .tree): the * chosen treeitem gets aria-selected="true", every other selectable item * "false"; disabled items are skipped. Announces the choice as a bubbling * `tree-select` CustomEvent ({ detail: { item } }) - selection is an * interaction on the tree, not a per-branch State API state. */function selectItem(tree, item) { if (!item || isDisabled(item) || item.getAttribute('aria-selected') === 'true') return; tree.querySelectorAll('[role="treeitem"][aria-selected="true"]').forEach((other) => other.setAttribute('aria-selected', 'false')); item.setAttribute('aria-selected', 'true'); tree.dispatchEvent(new CustomEvent('tree-select', { bubbles: true, detail: { item } }));}/* -- Checkboxes (data-checkable) ------------------------------------------ A checkbox in a row (.tree-check, after the icons, before the label). Checking a folder checks everything under it; a folder's own box shows checked / unchecked / indeterminate from its children. data-checkable= "independent" turns the cascade off. The inputs stay real form controls (name / value submit natively). */const checkOf = (item) => item?.querySelector(':scope > .tree-leaf > .tree-check, :scope > details > .tree-branch-trigger > .tree-check');const childItems = (item) => [...(item.querySelector(':scope > details > .tree-group')?.children ?? [])].filter((li) => li.matches('[role="treeitem"]'));const cascades = (tree) => tree.dataset.checkable !== 'independent';/** Down: a folder's state to every descendant box. */function checkDown(item, checked) { for (const child of childItems(item)) { const box = checkOf(child); if (box && !box.disabled) { box.checked = checked; box.indeterminate = false; } checkDown(child, checked); }}/** Up: every ancestor folder derives its box from its children. */function rollUp(tree, item) { let parent = item.parentElement?.closest('[role="treeitem"]'); while (parent && tree.contains(parent)) { const box = checkOf(parent); if (box) { const kids = childItems(parent).map(checkOf).filter(Boolean); const on = kids.filter((k) => k.checked && !k.indeterminate).length; const mixed = kids.some((k) => k.indeterminate); box.checked = kids.length > 0 && on === kids.length; box.indeterminate = mixed || (on > 0 && on < kids.length); } parent = parent.parentElement?.closest('[role="treeitem"]'); }}function syncAria(tree) { tree.querySelectorAll('[role="treeitem"]').forEach((item) => { const box = checkOf(item); if (box) item.setAttribute('aria-checked', box.indeterminate ? 'mixed' : String(box.checked)); });}function checkedValues(tree) { return [...tree.querySelectorAll('.tree-check')] .filter((b) => b.checked && !b.indeterminate) .map((b) => b.value !== 'on' ? b.value : b.closest('[role="treeitem"]').querySelector(':scope > * > span:last-child, :scope > details > summary > span:last-child')?.textContent ?? '');}function onCheck(tree, item) { const box = checkOf(item); if (!box) return; box.indeterminate = false; if (cascades(tree)) { checkDown(item, box.checked); rollUp(tree, item); } syncAria(tree); tree.dispatchEvent(new CustomEvent('tree-check', { bubbles: true, detail: { item, checked: box.checked, values: checkedValues(tree) } }));}function initChecks(tree) { let n = 0; tree.querySelectorAll('.tree-check').forEach((box) => { // the row's label names the checkbox if (!box.hasAttribute('aria-label') && !box.hasAttribute('aria-labelledby')) { const label = box.parentElement.querySelector(':scope > span:last-child'); if (label) { label.id ||= `${tree.id || 'tree'}-lbl-${n++}-${Math.random().toString(36).slice(2, 7)}`; box.setAttribute('aria-labelledby', label.id); } } }); if (cascades(tree)) { // authored checked folders cascade down, then every folder rolls up tree.querySelectorAll('[role="treeitem"]').forEach((item) => { const b = checkOf(item); if (b?.checked) checkDown(item, true); }); const leaves = [...tree.querySelectorAll('[role="treeitem"]')].filter((i) => !childItems(i).length); leaves.forEach((leaf) => rollUp(tree, leaf)); } syncAria(tree); tree.addEventListener('change', (e) => { if (!e.target.matches?.('.tree-check')) return; onCheck(tree, itemOf(e.target)); });}/* -- Drag & drop reordering (data-sortable) ------------------------------- Rows are draggable (native Drag and Drop API). Dropping on the upper / lower quarter of a row puts the item before / after it; dropping on the middle of a folder moves it into that folder (opened). A folder can't move into itself. Alt+ArrowUp / Alt+ArrowDown moves the focused item among its siblings. Every move fires a bubbling tree-reorder event. */function clearDrop(tree) { tree.querySelectorAll('[data-drop]').forEach((r) => r.removeAttribute('data-drop'));}function announceMove(tree, item) { const parentItem = item.parentElement.closest('[role="treeitem"]'); const index = [...item.parentElement.children].indexOf(item); tree.dispatchEvent(new CustomEvent('tree-reorder', { bubbles: true, detail: { item, parent: parentItem ?? tree, index } }));}function initSortable(tree) { let dragged = null; const rows = () => tree.querySelectorAll('.tree-branch-trigger, .tree-leaf'); rows().forEach((row) => { if (!isDisabled(itemOf(row))) row.draggable = true; }); tree.addEventListener('dragstart', (e) => { const row = e.target.closest?.('.tree-branch-trigger, .tree-leaf'); if (!row) return; dragged = itemOf(row); dragged.dataset.dragging = ''; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', row.textContent.trim()); }); tree.addEventListener('dragover', (e) => { const row = e.target.closest?.('.tree-branch-trigger, .tree-leaf'); if (!dragged || !row) return; const target = itemOf(row); if (target === dragged || dragged.contains(target)) return clearDrop(tree); e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const r = row.getBoundingClientRect(); const y = (e.clientY - r.top) / r.height; const isBranch = row.matches('.tree-branch-trigger'); const where = isBranch ? (y < 0.25 ? 'before' : y > 0.75 ? 'after' : 'inside') : y < 0.5 ? 'before' : 'after'; if (row.dataset.drop !== where) { clearDrop(tree); row.dataset.drop = where; } }); tree.addEventListener('dragleave', (e) => { if (!tree.contains(e.relatedTarget)) clearDrop(tree); }); tree.addEventListener('drop', (e) => { const row = tree.querySelector('[data-drop]'); if (!dragged || !row) return; e.preventDefault(); const target = itemOf(row); const where = row.dataset.drop; if (where === 'inside') { const details = target.querySelector(':scope > details'); details.open = true; details.querySelector(':scope > .tree-group').append(dragged); } else { target.parentElement.insertBefore(dragged, where === 'before' ? target : target.nextSibling); } clearDrop(tree); announceMove(tree, dragged); if (tree.hasAttribute('data-checkable') && cascades(tree)) { tree.querySelectorAll('[role="treeitem"]').forEach((i) => { if (!childItems(i).length) rollUp(tree, i); }); syncAria(tree); } }); tree.addEventListener('dragend', () => { if (dragged) delete dragged.dataset.dragging; dragged = null; clearDrop(tree); });}/** Alt+ArrowUp / Alt+ArrowDown: move among siblings, keep focus. */function moveByKey(tree, row, dir) { const item = itemOf(row); const sib = dir < 0 ? item.previousElementSibling : item.nextElementSibling; if (!sib) return; item.parentElement.insertBefore(item, dir < 0 ? sib : sib.nextSibling); row.focus(); announceMove(tree, item);}function init() { document.querySelectorAll('.tree[role="tree"]:not([data-init])').forEach((tree) => { tree.dataset.init = ''; const selectable = tree.hasAttribute('data-selectable'); if (selectable) { // every operable item states its selection explicitly (authored // aria-selected="true" wins); disabled items carry none tree.querySelectorAll('[role="treeitem"]').forEach((item) => { if (!isDisabled(item) && !item.hasAttribute('aria-selected')) item.setAttribute('aria-selected', 'false'); }); } if (tree.hasAttribute('data-checkable')) initChecks(tree); if (tree.hasAttribute('data-sortable')) initSortable(tree); const checkable = tree.hasAttribute('data-checkable'); /* Clicks: disabled rows neither toggle nor navigate; selectable trees select the clicked row (a branch toggles AND selects, like a file explorer). */ tree.addEventListener('click', (e) => { const row = e.target.closest('.tree-branch-trigger, .tree-leaf'); if (!row || !tree.contains(row)) return; const item = itemOf(row); if (isDisabled(item)) { e.preventDefault(); // <summary> would toggle, <a> would navigate return; } if (selectable) selectItem(tree, item); // a checkable leaf: clicking the row (not the box itself) ticks it const box = checkOf(item); if (checkable && !selectable && box && row.matches('.tree-leaf') && e.target !== box && !box.disabled) { box.checked = !box.checked; onCheck(tree, item); } }); /* Keep aria-expanded in sync with <details> open state */ tree.querySelectorAll('.tree-branch').forEach((details) => { const treeitem = details.closest('[role="treeitem"]'); if (!treeitem) return; // snapshot the authored state + bind the api per branch: // `$('#my-branch').api.setState('expanded')` details._defaultOpen = details.open; details.api = { setState: (stateName, config) => treeViewApi.setState(details, stateName, config), getState: () => treeViewApi.getState(details), }; details.addEventListener('toggle', () => { treeitem.setAttribute('aria-expanded', String(details.open)); // user interaction also moves the named state (keeps getState honest) details.dataset.stateName = details.open ? 'expanded' : 'default'; }); }); /* Keyboard navigation */ tree.addEventListener('keydown', (e) => { const target = e.target.closest('.tree-branch-trigger, .tree-leaf'); if (!target) return; const allItems = Array.from(tree.querySelectorAll('.tree-branch-trigger, .tree-leaf')); const visibleItems = allItems.filter((item) => item.checkVisibility()); const index = visibleItems.indexOf(target); // sortable: Alt+ArrowUp / Alt+ArrowDown reorder among siblings if (e.altKey && tree.hasAttribute('data-sortable') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); moveByKey(tree, target, e.key === 'ArrowUp' ? -1 : 1); return; } // checkable: Space ticks the row's box (a <summary> would toggle instead) if (e.key === ' ' && checkable) { const box = checkOf(itemOf(target)); if (box && !box.disabled && !isDisabled(itemOf(target))) { e.preventDefault(); box.checked = !box.checked; onCheck(tree, itemOf(target)); return; } } switch (e.key) { case 'ArrowDown': e.preventDefault(); if (index < visibleItems.length - 1) visibleItems[index + 1].focus(); break; case 'ArrowUp': e.preventDefault(); if (index > 0) visibleItems[index - 1].focus(); break; case 'ArrowRight': e.preventDefault(); { const detailsR = target.closest('details.tree-branch'); if (detailsR && !detailsR.open && !isDisabled(itemOf(target))) detailsR.open = true; } break; case 'Enter': case ' ': { const item = itemOf(target); // disabled: no native <summary> toggle, no link activation if (isDisabled(item)) { e.preventDefault(); break; } if (!selectable) break; // a leaf span has no native activation - keep Space from scrolling if (target.matches('span.tree-leaf')) e.preventDefault(); selectItem(tree, item); break; } case 'ArrowLeft': e.preventDefault(); { const detailsL = target.closest('details.tree-branch'); if (detailsL && detailsL.open) detailsL.open = false; } break; case 'Home': e.preventDefault(); if (visibleItems.length) visibleItems[0].focus(); break; case 'End': e.preventDefault(); if (visibleItems.length) visibleItems[visibleItems.length - 1].focus(); break; } });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub