Tree Select

Pro

Hierarchical picker in a body-portalled tree panel: single or multi-select, an ancestor-preserving filter with debounced server search, full keyboard control through aria-activedescendant, ControlValueAccessor support, and the loading / error / empty states inside the panel.

</>sv-tree-select treeselectpickerhierarchyportalmulti-select
import { SvTreeSelectComponent } from '@styloviz-pro/forms';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Multi-select

Pro

Restore expansion

Pro

Server-side search

Pro

Async states

Pro

Reactive forms

Pro

When to use

  • Picking one node out of a hierarchy the user already thinks of as a tree — folders, org units, categories.
  • A taxonomy too deep for a flat select but too shallow to need a full page.
  • For a strictly level-by-level walk (country → state → city), the Cascader reads better; for browsing rather than picking, use the Tree View.

Async states

  • loading
  • empty
  • error
  • retry

Inputs

NameTypeDefaultDescription
optionsreadonly TreeSelectNode[][]The tree to pick from.
loadChildren((node: TreeSelectNode, signal?: AbortSignal) => Promise<readonly TreeSelectNode[]>) | nullnullFetch a branch's children the first time it is expanded. The gap this closes: THIS COMPONENT HAD `loading` AND `error` INPUTS AND NO LOADER AT ALL. A host could say "something is loading" and could say "it failed", and had no way to make anything load — so the only shape it supported was fetching the whole tree up front and feeding it in. For a permissions tree or a file browser, "the whole tree" is the thing you cannot fetch. THE SIGNATURE TAKES AN `AbortSignal` because a branch the user has navigated away from must be cancellable — that is, the same problem on `sv-cascader`, and both use `svKeyedRequestGuard`. Honour it: a tree browsed quickly opens and abandons branches faster than they return. ⚠️ IT IS CALLED ONCE PER BRANCH. A node that has been loaded is not asked again, and a node that FAILED is retried when the user expands it again — a failed branch that could never be retried would be a dead end with no way out but a page reload. MARK AN UNLOADED BRANCH WITH `children: []`. With a loader bound, an empty `children` array is a branch whose contents have not been fetched: it renders a chevron, `aria-expanded`, and expands (rather than selects) on click. A node with no `children` property at all is a leaf. Once loaded, a branch that came back empty is shown as a leaf.
loadingbooleanfalseTrue while `options` are being fetched.
stalebooleanfalseThe results on screen answer an **older** query — a newer request is still in flight. Distinct from `loading`, which cannot separate the two cases it covers: the first fetch (nothing on screen — show a skeleton) and a refinement of results already visible (keep them, and say they are provisional). Blanking the panel on the second is the most jarring thing a picker can do; showing the old list with no indication is a quiet lie. Bind `svSuggestController(...).stale` from `@styloviz/data`, or `createRequestGuard().stale` from `@styloviz/core`. The results dim and report `aria-busy` while staying fully interactive.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse` and get sensible retry behaviour.
emptyMessagestring'No options'Copy for the no-data state.
valuestring | null (two-way)nullSelected value in single mode. `null` when nothing is chosen.
checkedValuesreadonly string[] (two-way)[]Selected values in `multiple` mode.
expandedValuesreadonly string[] (two-way)[]Values of the expanded branches. Bindable, so expansion survives a reload.
searchTermstring (two-way)''Current filter text.
multiplebooleanfalseAllow several nodes to be checked at once.
selectableTreeSelectSelectable'leaf'Which nodes a click can select.
filterablebooleantrueShow the filter box at the top of the panel.
localFilterbooleantrueFilter against the supplied tree in the browser. Turn it off when the host answers `search` with a fresh `options` tree — local filtering would then hide rows the server had just decided were matches.
debounceMsnumber250Debounce applied to the `search` output, in ms.
clearablebooleantrueShow the clear (×) button once something is selected.
disabledbooleanfalseDisable the whole control.
sizeTreeSelectSize'md'Trigger size.
placeholderstring'Select…'Text shown in the trigger when nothing is selected.
filterPlaceholderstring'Filter…'Placeholder for the filter box.
ariaLabelstring''Accessible name for the trigger.
customClassstring''Extra CSS classes applied to the root wrapper.

Outputs

EventPayload typeDescription
retryvoidThe host should re-run the failed load.
nodeSelectTreeSelectNodeEventEmitted when a node is selected or, in `multiple` mode, toggled.
searchstringDebounced filter text, for a host that searches server-side.
openChangebooleanEmitted when the panel opens or closes.
valueChangestring | nullEmits the new `value` whenever it changes — the output half of `[(value)]`.
checkedValuesChangereadonly string[]Emits the new `checkedValues` whenever it changes — the output half of `[(checkedValues)]`.
expandedValuesChangereadonly string[]Emits the new `expandedValues` whenever it changes — the output half of `[(expandedValues)]`.
searchTermChangestringEmits the new `searchTerm` whenever it changes — the output half of `[(searchTerm)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Pro

Bound to the names this component actually exposes.

Host class

Pro

Drive the three async inputs from your own fetch.

  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `options`, `loading` and `error`.
  • `debounceMs` (default 300) collapses a burst of typing into one request. Set it to 0 only when the query is driven by clicks.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Single or multiple selection.
  • Leaf-only or any-node selection.
  • Filter box, local or server-side, with a configurable debounce.
  • Three trigger sizes, clearable, disabled.

Best practices

  • Do: Leave `selectable` at `leaf` for a taxonomy, and switch to `any` for a folder picker where every level is a valid answer.
  • Do: Bind `[(expandedValues)]` when the tree reloads — otherwise the user re-opens the same branches after every refresh.
  • Do: Turn `localFilter` off when you answer `(search)` from the server; filtering locally as well would hide rows the server had just decided were matches.
  • Don't: Do not put thousands of nodes behind it unfiltered — this panel renders every visible row, so use server-side search for a large tree.
  • Tip: The panel is portalled to `<body>`, so it escapes `overflow: hidden` and transformed ancestors and is destroyed on close.
  • Do: Expect tri-state ancestry in `multiple` mode: ticking a branch takes its enabled contents with it, and a branch completes once every child the user could tick is ticked. This control used to tick one node and nothing else, so somebody who ticked "Documents" and pressed Save sent one value where they plainly meant the contents.
  • Don't: Don't expect a tick to reach a `disabled` descendant. It cannot, in either direction — a read-only row the user could not have selected must not arrive in your change event because its parent was ticked, and a parent tick must not clear one either.
  • Do: Use `checkedLeafValues()` when your API wants only leaves. `checkedValues` keeps the branch ids because they are the complete answer to "what is selected" — and because dropping them loses the difference between "the whole folder" and "every file in it, which happens to be all of them", two statements that diverge the moment a file is added.
  • Don't: Don't read the mixed state as a value. `aria-checked="mixed"` and the dash are presentation; `checkedValues` holds strings and there is no third thing to store — the same contract Checkbox documents.
  • Do: Use `loadChildren` for a tree you cannot fetch whole. This component had `loading` and `error` inputs and NO loader — a host could say "something is loading" and could say "it failed", and had no way to make anything load, so the only shape it supported was fetching the whole tree up front.
  • Do: Honour the `AbortSignal` your loader is handed. Collapsing a branch cancels its request: a user who opens a folder, gets bored and collapses it has said they no longer want it, and without cancellation the response still lands and grafts children into a branch that is not on screen.
  • Don't: Don't expect a failed branch to be a dead end. It is marked (`branchFailed`) and retried when the user expands it again — a failure with no way back but a page reload is worse than the failure.

Accessibility

  • The trigger is a `button` with `aria-haspopup="tree"`, `aria-expanded` and `aria-controls`; the panel is a `role="tree"` with `aria-multiselectable` in multi mode.
  • Every row is a `role="treeitem"` carrying `aria-level`, `aria-posinset`, `aria-setsize`, `aria-selected` and — only where it can open — `aria-expanded`.
  • Keyboard: ↑/↓ move, →/← expand, collapse or jump to the parent, Enter/Space select, Escape closes. DOM focus stays in the filter box and the active row is published through `aria-activedescendant`.

Feedback on Tree Select

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.