SvTreeSelect

A single-select dropdown that shows an indented, collapsible tree in its panel - the cascader / tree-select pattern. Pick a node from a hierarchy, optionally showing its full path in the trigger.

SvTreeSelect covers hierarchical choice (a category, a folder, an org unit) without pulling in the full SvTree. It supports bind:value, tracks its own expand/collapse state (seeded from expandedIds), and flattens the visible nodes for a roving-focus keyboard model where ArrowRight / ArrowLeft expand and collapse branches. The panel portals out of any scroll container and colors follow the grid's --sg-* tokens.

Related: SvDropDownList · SvGridSelect · Selection overview

Installation

Add it with the CLI - this drops a ready-to-edit SvTreeSelect starter into your app:

Prefer to see it first? npx @svgrid/ui try tree-select opens it in a throwaway sandbox - no project needed.

Or install the package and import it directly. SvTreeSelect ships free in @svgrid/grid (dependency-free):

import { SvTreeSelect } from '@svgrid/grid'

Example

Open the live example: Tree select (Selection)

<script lang="ts">
  import { SvTreeSelect, type TreeSelectNode } from '@svgrid/grid'
  const nodes: TreeSelectNode[] = [
    { value: 'eng', label: 'Engineering', children: [
      { value: 'fe', label: 'Frontend' },
      { value: 'be', label: 'Backend' },
    ] },
    { value: 'design', label: 'Design' },
  ]
  let value = $state<string | number | null>(null)
</script>

<SvTreeSelect label="Team" {nodes} bind:value showPath />

Props

Prop Type Default Description
nodes ReadonlyArray<TreeSelectNode> - The hierarchy to choose from.
value string | number | null null The selected node's value. Bindable (bind:value).
onChange (value: string | number) => void - Fires with the picked node's value.
onCommit (value: string | number) => void - Fires when a node is committed.
onCancel () => void - Fires when the panel is dismissed.
placeholder string 'Select…' Shown when nothing is selected.
showPath boolean false Show the full node path (A / B / C) in the trigger.
expandedIds ReadonlyArray<string | number> [] Node values expanded on first open.
size sm | md | lg md Trigger height and font size.
disabled boolean false Blocks interaction.
label string - Visible field label, wired to the control.
hint string - Helper text under the control.
error string - Error message; announced and styled when set.
required boolean false Marks the field required.
invalid boolean false Applies the invalid state.
name string - Emits a hidden input carrying the value for form posts.
dir ltr | rtl | auto auto Text direction.
ariaLabel string - Accessible name when there is no visible label.
id string - Root id; label/hint/error ids derive from it.

TreeSelectNode

type TreeSelectNode = {
  value: string | number
  label: string
  children?: TreeSelectNode[]
  disabled?: boolean
}

Examples

Full-path trigger

Turn on showPath so the trigger reads Engineering / Frontend instead of just the leaf label - useful when leaf names repeat across branches.

Pre-expanded branches

Seed the open state so users land deep in the tree without clicking down to it:

<SvTreeSelect {nodes} bind:value expandedIds={['eng']} />

Disabled branches

Set disabled on a node to make it unpickable while still showing (and expanding) its children.

Build the tree from flat rows

Hierarchies usually arrive flat (a parentId column). Fold them into TreeSelectNode[] once, then bind the selected leaf:

<script lang="ts">
  import { SvTreeSelect, type TreeSelectNode } from '@svgrid/grid'
  const flat = [
    { id: 'eng', parent: null, name: 'Engineering' },
    { id: 'fe', parent: 'eng', name: 'Frontend' },
    { id: 'be', parent: 'eng', name: 'Backend' },
    { id: 'design', parent: null, name: 'Design' },
  ]
  function toTree(parent: string | null): TreeSelectNode[] {
    return flat
      .filter((r) => r.parent === parent)
      .map((r) => ({ value: r.id, label: r.name, children: toTree(r.id) }))
  }
  const nodes = toTree(null)
  let value = $state<string | number | null>(null)
</script>

<SvTreeSelect label="Team" {nodes} bind:value showPath />

Tip: expandedIds only seeds the open branches for the first open; after that the component tracks its own expand/collapse state, so you do not have to keep the prop in sync.

Accessibility

Sizes

Every control takes the same three sizes, so a dense toolbar and a roomy form can share components.

<script lang="ts">
  import { SvTreeSelect } from '@svgrid/grid'

  let treeSelect = $state('')
</script>

<SvTreeSelect bind:value={treeSelect} size="sm" />
<SvTreeSelect bind:value={treeSelect} size="md" />
<SvTreeSelect bind:value={treeSelect} size="lg" />

In a form

The shared field props behave the same on every editor: label names it, hint explains it, and error plus invalid mark it - which is why a validated form does not need per-component handling.

<script lang="ts">
  import { SvTreeSelect } from '@svgrid/grid'

  let treeSelect = $state('')
</script>

<SvTreeSelect
  bind:value={treeSelect}
  label="Label"
  hint="A short hint"
  required
/>

<SvTreeSelect
  bind:value={treeSelect}
  label="Label"
  error="Something is wrong"
  invalid
/>

See also

Related articles