Skip to content
ShineUI
Install

TreeSelect

Pick from a hierarchy.

<sui-tree-select>SelectionStable

About

Dropdown holding an expandable tree. Single mode highlights the picked row and closes on pick; multiple draws a checkbox per row and keeps the panel open. value holds node keys and the trigger shows their labels. Built for the chart of accounts, where the user picks a parent account from a hierarchy. Form-associated: posts one entry per key.

Usage

<sui-tree-select label="Parent account" name="parent" placeholder="Choose an account"></sui-tree-select> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-tree-select"); el.expanded = ["assets"]; /* SuiKey[] */ el.options = [ { key: "assets", label: "Assets", icon: "fa-regular fa-folder", children: [ { key: "1000", label: "1000 Operating Checking" }, { key: "1200", label: "1200 Accounts Receivable" }, { key: "1400", label: "1400 Undeposited Funds" }, ], }, { key: "liabilities", label: "Liabilities", icon: "fa-regular fa-folder", children: [{ key: "2000", label: "2000 Accounts Payable" }, { key: "2100", label: "2100 Sales Tax Payable" }], }, { key: "income", label: "Income", icon: "fa-regular fa-folder", children: [{ key: "4000", label: "4000 Service Income" }, { key: "4100", label: "4100 Product Sales" }], }, ]; /* SuiTreeSelectNode[] */ el.addEventListener("suiChange", (e) => console.log(e.detail)); </script>
import { SuiTreeSelect } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const expanded = ["assets"]; /* SuiKey[] */ const options = [ { key: "assets", label: "Assets", icon: "fa-regular fa-folder", children: [ { key: "1000", label: "1000 Operating Checking" }, { key: "1200", label: "1200 Accounts Receivable" }, { key: "1400", label: "1400 Undeposited Funds" }, ], }, { key: "liabilities", label: "Liabilities", icon: "fa-regular fa-folder", children: [{ key: "2000", label: "2000 Accounts Payable" }, { key: "2100", label: "2100 Sales Tax Payable" }], }, { key: "income", label: "Income", icon: "fa-regular fa-folder", children: [{ key: "4000", label: "4000 Service Income" }, { key: "4100", label: "4100 Product Sales" }], }, ]; /* SuiTreeSelectNode[] */ return <SuiTreeSelect label="Parent account" name="parent" placeholder="Choose an account" expanded={expanded} options={options} onSuiChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiTreeSelect } from "@shineui/vue"; import "@shineui/core/shineui.css"; const expanded = ["assets"]; // SuiKey[] const options = [ { key: "assets", label: "Assets", icon: "fa-regular fa-folder", children: [ { key: "1000", label: "1000 Operating Checking" }, { key: "1200", label: "1200 Accounts Receivable" }, { key: "1400", label: "1400 Undeposited Funds" }, ], }, { key: "liabilities", label: "Liabilities", icon: "fa-regular fa-folder", children: [{ key: "2000", label: "2000 Accounts Payable" }, { key: "2100", label: "2100 Sales Tax Payable" }], }, { key: "income", label: "Income", icon: "fa-regular fa-folder", children: [{ key: "4000", label: "4000 Service Income" }, { key: "4100", label: "4100 Product Sales" }], }, ]; // SuiTreeSelectNode[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiTreeSelect label="Parent account" name="parent" placeholder="Choose an account" :expanded="expanded" :options="options" @sui-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiTreeSelect } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiTreeSelect], template: `<sui-tree-select label="Parent account" name="parent" placeholder="Choose an account" [(open)]="open" [expanded]="expanded" [options]="options" (suiChange)="onChange($event)"></sui-tree-select>`, }) export class Example { open = false; expanded = ["assets"]; // SuiKey[] options = [ { key: "assets", label: "Assets", icon: "fa-regular fa-folder", children: [ { key: "1000", label: "1000 Operating Checking" }, { key: "1200", label: "1200 Accounts Receivable" }, { key: "1400", label: "1400 Undeposited Funds" }, ], }, { key: "liabilities", label: "Liabilities", icon: "fa-regular fa-folder", children: [{ key: "2000", label: "2000 Accounts Payable" }, { key: "2100", label: "2100 Sales Tax Payable" }], }, { key: "income", label: "Income", icon: "fa-regular fa-folder", children: [{ key: "4000", label: "4000 Service Income" }, { key: "4100", label: "4100 Product Sales" }], }, ]; // SuiTreeSelectNode[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The panel is a role="tree" of treeitem rows with aria-level and aria-expanded, and the trigger keeps focus while pointing at the active row through aria-activedescendant. Rows carry aria-selected in single mode and aria-checked when multiple.

<div style="max-width:340px"> <sui-tree-select id="ts-basic" label="Parent account" name="parent" placeholder="Choose an account"></sui-tree-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ts-basic'); el.options = accountTree; el.expanded = ['assets']; el.value = '1200'; }

Open and expanded

expanded holds the keys of the open branches. A single-mode pick closes the panel and writes the picked key.

<div style="max-width:340px;min-height:320px"> <sui-tree-select id="ts-open" label="Parent account" open></sui-tree-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ts-open'); el.options = accountTree; el.expanded = ['assets', 'liabilities']; el.value = '1200'; }

Multiple with checkboxes

multiple draws a checkbox per row and keeps the panel open. Every node toggles on its own, branches included, and value becomes an array of keys.

<div style="max-width:340px;min-height:320px"> <sui-tree-select id="ts-multi" label="Accounts to include" multiple open></sui-tree-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ts-multi'); el.options = accountTree; el.expanded = ['assets']; el.value = ['1000', '1200']; }

Filter

A branch on the path to a match opens itself for as long as the query stands. Filtering never writes expanded, so clearing the box restores the expansion the user had.

<div style="max-width:340px;min-height:280px"> <sui-tree-select id="ts-filter" label="Parent account" filter open empty-message="No account matches that name."></sui-tree-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ts-filter'); el.options = accountTree; }

As a property

variant="quiet" draws the picked labels as text in the surrounding font, with no shell at rest: the fill comes on hover, the focus ring on focus and the shell only while the panel is open, which keeps a 200px floor under a value narrower than that. The label is not drawn but still names the control.

Parent Include
<div style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:12px 10px;align-items:center;max-width:320px;font:550 13.5px/1.5 var(--font-sans);color:var(--ink);min-height:320px;align-content:start"> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Parent</span><sui-tree-select id="ts-prop-parent" variant="quiet" label="Parent account" size="sm" open></sui-tree-select> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Include</span><sui-tree-select id="ts-prop-include" variant="quiet" label="Accounts to include" size="sm" multiple placeholder="All accounts" clearable></sui-tree-select> </div>
// setup(root): properties and events setup(root) { const parent = root.querySelector('#ts-prop-parent'); parent.options = accountTree; parent.expanded = ['assets']; parent.value = '1200'; const include = root.querySelector('#ts-prop-include'); include.options = accountTree; include.value = ['4000', '4100']; }

States

An error replaces the help line, paints the trigger in the danger tone and sets aria-invalid. A disabled control posts nothing and never holds the form.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-tree-select id="ts-help" label="Parent account" help="Sub-accounts inherit the parent detail type."></sui-tree-select> <sui-tree-select id="ts-req" label="Parent account" required placeholder="Choose an account"></sui-tree-select> <sui-tree-select id="ts-err" label="Parent account" required error="Parent account is required."></sui-tree-select> <sui-tree-select id="ts-off" label="Parent account" disabled help="Locked while the period is closed."></sui-tree-select> </div>
// setup(root): properties and events setup(root) { ['#ts-help', '#ts-req', '#ts-err', '#ts-off'].forEach((id) => { root.querySelector(id).options = accountTree; }); root.querySelector('#ts-off').value = '2000'; }

Loading, clear and sizes

While loading is set a spinner stands in for the chevron and the panel shows the loading row instead of rows. clearable draws an X that clears the pick, or every pick when multiple, and size is the same sm, md, lg vocabulary as the rest of the control family.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:end"> <sui-tree-select id="ts-loading" label="Parent account" placeholder="Fetching accounts" loading></sui-tree-select> <sui-tree-select id="ts-clear" label="Accounts to include" multiple clearable></sui-tree-select> <sui-tree-select id="ts-sm" label="Small" size="sm"></sui-tree-select> <sui-tree-select id="ts-lg" label="Large" size="lg"></sui-tree-select> </div>
// setup(root): properties and events setup(root) { const clear = root.querySelector('#ts-clear'); clear.options = accountTree; clear.value = ['1000', '1200']; ['#ts-sm', '#ts-lg'].forEach((id) => { const el = root.querySelector(id); el.options = accountTree; el.value = '4000'; }); }

A long tree

Rows are counted after expansion and filtering, so a tree with every branch open windows and the same tree collapsed does not. Rows are one flat list carrying aria-level, aria-posinset and aria-setsize, which is how a window can cut across levels and a screen reader still hears where a row sits.

Open the panel, then press End.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start;min-height:360px"> <div style="max-width:320px;flex:1 1 260px"><sui-tree-select id="ts-long" label="Parent account" filter clearable></sui-tree-select></div> <span id="ts-long-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Open the panel, then press End.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ts-long'); const log = root.querySelector('#ts-long-log'); const tree = longTree(100, 49); el.options = tree; el.expanded = tree.map((n) => n.key); const count = () => { // The open panel renders inside the element. const panel = el; log.textContent = `4,900 rows visible, ${panel.querySelectorAll('[role="treeitem"]').length} in the page.`; }; el.addEventListener('suiOpen', () => requestAnimationFrame(count)); el.addEventListener('suiChange', (e) => { log.textContent = e.detail ? `Picked ${e.detail}.` : 'Cleared.'; }); }

Read-only

readonly keeps the trigger named and in the tab order, opens nothing and clears nothing, and the picks still post.

<div style="display:grid;gap:14px;max-width:320px"> <sui-tree-select id="ro-sui-tree-select" label="Account" name="sui-tree-select" value="1000" readonly required></sui-tree-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#ro-sui-tree-select').options = accountTree; }

Empty and no matches

emptyMessage is the row for a list that has nothing in it and emptyFilterMessage the row for a search that found nothing; they are two states and two sentences, so "Nothing here yet." is never shown over a long list the reader has just narrowed to none.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-tree-select id="ts-empty" label="Account" empty-message="No accounts in this chart yet." open></sui-tree-select> <sui-tree-select id="ts-nomatch" label="Account" filter empty-filter-message="No account matches that." open></sui-tree-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#ts-empty').options = []; const el = root.querySelector('#ts-nomatch'); el.options = accountTree; requestAnimationFrame(() => { const box = el.querySelector('.sui-popup-search-box input'); if (box) { box.value = 'zzz'; box.dispatchEvent(new Event('input')); } }); }

In a form

Single mode posts one entry under the host name. Multiple posts one entry per picked key, the way a native select multiple does.

Submit to see what posts.
<form id="ts-form" style="display:grid;gap:14px;max-width:340px"> <sui-tree-select id="ts-form-parent" label="Parent account" name="parent" required></sui-tree-select> <sui-tree-select id="ts-form-include" label="Accounts to include" name="include" multiple></sui-tree-select> <sui-button type="submit" variant="brand" label="Save account"></sui-button> <output id="ts-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Submit to see what posts.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#ts-form'); const out = root.querySelector('#ts-out'); const parent = root.querySelector('#ts-form-parent'); parent.options = accountTree; parent.expanded = ['assets']; parent.value = '1000'; const include = root.querySelector('#ts-form-include'); include.options = accountTree; include.expanded = ['income']; include.value = ['4000', '4100']; form.addEventListener('submit', (e) => { e.preventDefault(); const entries = [...new FormData(form)].map(([k, v]) => `${k}=${v}`); out.textContent = entries.length ? entries.join(' ') : 'Nothing posted.'; }); }

Events

suiExpandedChange carries every expanded key and suiChange the picked key or keys. suiChange bubbles like any field change; suiExpandedChange reports the panel and does not. suiOpen fires after the element opens itself; the cancelable suiClose fires before it closes, with pick, escape, outside or trigger.

Idle.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start"> <div style="max-width:300px;flex:1 1 260px"><sui-tree-select id="ts-ev" label="Parent account" placeholder="Choose an account"></sui-tree-select></div> <span id="ts-ev-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ts-ev'); const log = root.querySelector('#ts-ev-log'); el.options = accountTree; el.addEventListener('suiOpen', () => { log.textContent = 'Panel open.'; }); el.addEventListener('suiExpandedChange', (e) => { log.textContent = e.detail.length ? `Expanded ${e.detail.join(', ')}.` : 'All branches collapsed.'; }); el.addEventListener('suiChange', (e) => { log.textContent = `Picked ${e.detail}.`; }); el.addEventListener('suiClose', (e) => { log.textContent = `Panel closing (${e.detail}).`; }); }

Keyboard

KeyAction
ArrowDown ArrowUpOpens the panel. Once open, moves the active row through the visible rows.
ArrowRightExpands the active branch, or steps into its first child when it is already open. Under dir="rtl" the pair swaps, so the arrow that points along the reading direction is always the forward one.
ArrowLeftCollapses the active branch, or climbs to the parent row.
Home EndMoves to the first or last visible row. In the search box the caret keeps them.
Enter SpaceOpens the panel, or picks the active row; with multiple, toggles it. In the search box Space types a space.
EscapeCloses the panel and returns focus to the trigger.
TabCloses the panel and moves on.
Delete BackspaceOn the trigger, with clearable set, clears the pick, or every pick when multiple.

API

<sui-tree-select>

Properties

PropertyAttributeTypeDefaultDescription
clearableclearablebooleanfalseX control to clear the pick, or every pick when multiple. Delete or Backspace on the trigger does the same.
disableddisabledbooleanfalseDisables the trigger. A disabled control posts nothing and never holds the form.
emptyFilterMessageempty-filter-messagestringstring | undefinedRow shown when the filter matched nothing. An empty list and a search that found nothing are different states and one sentence cannot be right about both: "Nothing here yet." is wrong over a list of two hundred the reader has just narrowed to none.
emptyMessageempty-messagestringstring | undefinedRow shown when the node list is empty.
errorerrorstringstring | undefinedError message; paints the trigger in the danger tone and sets aria-invalid.
expandedproperty onlySuiKey[][]Keys of expanded branches. Mutable; updated before suiExpandedChange fires. Property, not attribute.
filterfilterbooleanfalseSearch box at the top of the panel. Matching rows keep their ancestors, and a branch on the path to a match opens itself for as long as the query stands. Filtering never writes expanded, so clearing the box restores the expansion the user had.
helphelpstringstring | undefinedHelp text under the control. Replaced by error when set.
invalidinvalidbooleanfalseDanger border without a message, for a form that draws its summary elsewhere.
labellabelstringstring | undefinedLabel above the control.
loadingloadingbooleanfalseShows the tree is still on its way: a spinner stands in for the chevron and the panel shows the loading row instead of rows. Set it while fetching, clear it when options land.
loadingMessageloading-messagestringstring | undefinedText of the loading row.
multiplemultiplebooleanfalseCheckbox rows and an array value. Every node toggles on its own, branch or leaf, and picking keeps the panel open.
namenamestringstring | undefinedForm control name. Reflected: the form entry is built from the content attribute.
openopenbooleanfalseOpens the panel. Mutable and the single source of truth: suiOpen announces an open the element performed, and the cancelable suiClose asks before it closes itself on a single-mode pick, outside click, Escape and Tab.
optionsproperty onlySuiTreeSelectNode[][]Node tree (key, label, icon, children). Property, not attribute.
placeholderplaceholderstringstring | undefinedText shown while nothing is picked.
readOnlyreadonlybooleanfalseDisplay only: the trigger keeps its name and its tab stop, opens nothing and clears nothing, and the picks still post. Neither a read-only nor a disabled control holds the form on valueMissing.
requiredrequiredbooleanfalseA pick is required for the surrounding form to be valid.
sizesize"lg" | "md" | "sm"'md'Trigger height: sm 28, md 34 (default), lg 42, from the --control-* tokens. In the quiet variant it is the height of the box that paints on hover and while open; the text keeps the surrounding font.
tonetone"inverse" | undefinedInk for the quiet variant on the brand chrome. inverse takes the inverse inks, the inverse hover wash and the inverse focus colour, as sui-button's tone of the same name does, so the value reads on a tone="brand" toolbar. Opening the field shows its shell in the card's own inks. The default variant ignores it.
valuevalueSuiChoiceValueSuiKey[] | number | string | undefinedPicked node key, or an array of keys when multiple, each as the node gives it (a number stays a number); '' after a single-mode clear. Written as an attribute it is text, and it still picks the node whose key reads the same. Mutable; updated before suiChange fires. The trigger shows the matching labels, joined with commas when multiple.
variantvariant"default" | "quiet"'default'default draws the field shell. quiet draws the picked labels as text in the surrounding font, with no border or fill at rest, for a record's properties column: the fill comes on hover, the focus ring on focus, and the shell only while the panel is open. The label is not drawn but still names the control, so the host prints the property name. Keyboard, events, validation and the form post are the same in both. Anything else falls back to default.
virtualScrollThresholdvirtual-scroll-thresholdnumber200Visible row count at which the panel starts mounting only the rows near the viewport instead of all of them, so a tree showing exactly this many is windowed. Rows are counted after expansion and filtering, so a deep tree with every branch open windows and the same tree collapsed does not. The keyboard, the ids and the counts a screen reader reads still cover every visible row. Set it to 0 to keep every row in the DOM whatever the tree costs, or to 1 to window a short tree under test.

Events

EventDetailReactVueDescription
suiChangeSuiChoiceValueSuiKey[] | number | stringonSuiChange@sui-changeFires when the pick changes; detail is the new value: the node's key (single, '' after a clear) or the array of keys (multiple).
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the element closes the panel itself; detail is 'pick' | 'escape' | 'outside' | 'trigger'. Cancelable: preventDefault() keeps it open, so detail === 'pick' is how a consumer keeps a single-mode panel up after a choice. Tab reports 'outside', because focus left the control and SuiCloseReason has no member for that. Does not bubble: a preventDefault() in a dialog's own suiClose listener cannot veto this one.
suiExpandedChangeSuiKey[]onSuiExpandedChange@sui-expanded-changeFires when a branch in the panel opens or closes; detail is the array of expanded keys. Does not bubble: it reports the panel's state, not the value, like suiOpen and suiClose.
suiOpenvoidonSuiOpen@sui-openFires after the element opens the panel itself (trigger click, ArrowDown, Enter, Space). Does not bubble, so a dialog around the control never takes it for its own.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the trigger, as el.focus() does.

Types

SuiChoiceValue typeWhat a choice element holds: one option's value, or, where it takes several (multiple), the array of the picked values. A value keeps the type its option gave it, so a numeric id comes back a number through value, the events and a framework binding; only the form posts text.
No members.
SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
No members.
SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.
SuiTreeSelectNode interfaceTree node: key is the value identity, label is what the row and the trigger show. The key stays as given: a number stays a number through value, suiChange and a framework binding, and the form posts its text.
keySuiKey
labelstring
icon?stringFont Awesome class drawn before the label (single mode).
children?SuiTreeSelectNode[]

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

Every name above is listed with its value and its role in Foundations.

See also