Tree Select
Dropdown select whose panel is a hierarchical tree: categories, instrument banks, folder pickers. Group nodes expand and collapse; only leaf nodes are selectable.
<arc-tree-select>Overview
Tree Select combines the trigger anatomy of Select with a hierarchical tree panel. Instead of a flat list of options, the dropdown presents expandable groups whose leaves are the actual choices: an instrument bank organized by family, a category taxonomy, a folder structure. The trigger shows the chosen leaf together with a muted breadcrumb of its ancestor path, so "Violin" reads as "Strings / Violin" and never loses its context.
Selection is leaf-only. Nodes with children act as group headers: they expand and collapse but can never be chosen. That keeps single-select semantics clean, since the submitted value is always one unambiguous leaf, never a branch that might mean "everything under it". Branches containing the current value expand automatically when the panel opens, so the selection is always visible without hunting.
Tree Select implements the ARIA combobox pattern with a tree popup. Keyboard users open the panel with Enter, Space, or an arrow key, walk rows with Arrow Up and Down, expand and collapse groups with Arrow Right and Left, confirm a leaf with Enter, and dismiss with Escape. Typing jumps to the row starting with those letters, exactly as in Select. The component participates in native forms through ElementInternals, submitting the selected leaf value under its name.
Guidelines
When to use
- Use Tree Select when the options have a real hierarchy the user thinks in: instrument families, product categories, folder trees
- Give every node a stable value, including group headers. Group values drive expanded-values and appear in the arc-change path detail
- Keep the tree shallow; two or three levels is comfortable inside a dropdown panel
- Pre-expand the branches users need most via expanded-values instead of making them dig
- Always provide a visible label so users understand what they are choosing
- Use disabled nodes for temporarily unavailable choices rather than removing them, so the structure stays recognizable
When not to use
- Do not use Tree Select for a flat list. Use Select, which is simpler for both hands and screen readers
- Do not use it when users need to type to filter a large set. Use Combobox, whose text field owns the keystrokes
- Do not use it for browsing or navigation outside a form. Use Tree View, which is a standalone tree without a trigger or form value
- Do not expect group headers to be selectable. If a branch itself must be a valid choice, add an explicit leaf such as "All Strings" inside it
- Do not nest deeper than three levels. A dropdown panel is the wrong home for a deep tree; consider a dedicated picker dialog instead
Features
- Hierarchical tree panel with expandable, collapsible group headers
- Leaf-only selection keeps single-select semantics unambiguous
- Trigger breadcrumb shows the ancestor path muted beside the leaf label
- Branches containing the selected value auto-expand when the panel opens
- Initially expanded branches via the expanded-values property
- Full keyboard support: arrows navigate and expand, Enter selects, Escape closes
- Type-ahead jumps to rows by their first letters, as in a native select
- Disabled nodes render but are skipped by keyboard and cannot be selected
- Native form participation via ElementInternals, including required validation
- Neutral depth rails mark nesting structure without carrying state
Preview
Usage
This component requires JavaScript. No pure HTML/CSS version is available. Use the Web Component directly or a framework wrapper.
<script type="module" src="@arclux/arc-ui"></script>
<arc-tree-select
label="Instrument"
name="instrument"
placeholder="Pick an instrument..."
></arc-tree-select>
<script>
const treeSelect = document.querySelector('arc-tree-select');
// items is a property, not an attribute: assign the tree from script.
treeSelect.items = [
{ value: 'keys', label: 'Keys', children: [
{ value: 'grand-piano', label: 'Grand Piano' },
{ value: 'rhodes', label: 'Rhodes' },
] },
{ value: 'strings', label: 'Strings', children: [
{ value: 'violin', label: 'Violin' },
{ value: 'cello', label: 'Cello' },
] },
{ value: 'percussion', label: 'Percussion', children: [
{ value: 'timpani', label: 'Timpani' },
] },
];
// Pre-expand a branch, or let auto-expand follow the selected value.
treeSelect.expandedValues = ['strings'];
treeSelect.addEventListener('arc-change', (e) => {
// e.detail.path holds the ancestor group values, root first.
console.log('Selected:', e.detail.value, 'in', e.detail.path.join(' / '));
});
</script>import { TreeSelect } from '@arclux/arc-ui-react';
import { useState } from 'react';
const instruments = [
{ value: 'keys', label: 'Keys', children: [
{ value: 'grand-piano', label: 'Grand Piano' },
{ value: 'rhodes', label: 'Rhodes' },
] },
{ value: 'strings', label: 'Strings', children: [
{ value: 'violin', label: 'Violin' },
{ value: 'cello', label: 'Cello' },
] },
{ value: 'percussion', label: 'Percussion', children: [
{ value: 'timpani', label: 'Timpani' },
] },
];
function InstrumentPicker() {
const [instrument, setInstrument] = useState('');
return (
<TreeSelect
label="Instrument"
placeholder="Pick an instrument..."
items={instruments}
value={instrument}
onArcChange={(e) => setInstrument(e.detail.value)}
/>
);
}<script setup>
import { TreeSelect } from '@arclux/arc-ui-vue';
import { ref } from 'vue';
const instrument = ref('');
const instruments = [
{ value: 'keys', label: 'Keys', children: [
{ value: 'grand-piano', label: 'Grand Piano' },
{ value: 'rhodes', label: 'Rhodes' },
] },
{ value: 'strings', label: 'Strings', children: [
{ value: 'violin', label: 'Violin' },
{ value: 'cello', label: 'Cello' },
] },
];
</script>
<template>
<TreeSelect
label="Instrument"
placeholder="Pick an instrument..."
:items="instruments"
:value="instrument"
@arc-change="instrument = $event.detail.value"
/>
</template><script>
import { TreeSelect } from '@arclux/arc-ui-svelte';
let instrument = '';
const instruments = [
{ value: 'keys', label: 'Keys', children: [
{ value: 'grand-piano', label: 'Grand Piano' },
{ value: 'rhodes', label: 'Rhodes' },
] },
{ value: 'strings', label: 'Strings', children: [
{ value: 'violin', label: 'Violin' },
{ value: 'cello', label: 'Cello' },
] },
];
</script>
<TreeSelect label="Instrument" placeholder="Pick an instrument..."
items={instruments} value={instrument}
on:arc-change={(e) => instrument = e.detail.value} />import { Component } from '@angular/core';
import { TreeSelect } from '@arclux/arc-ui-angular';
@Component({
imports: [TreeSelect],
template: `
<arc-tree-select label="Instrument" placeholder="Pick an instrument..."
[items]="instruments" [value]="instrument"
(arc-change)="instrument = $event.detail.value">
</arc-tree-select>
`,
})
export class InstrumentPickerComponent {
instrument = '';
instruments = [
{ value: 'keys', label: 'Keys', children: [
{ value: 'grand-piano', label: 'Grand Piano' },
{ value: 'rhodes', label: 'Rhodes' },
] },
{ value: 'strings', label: 'Strings', children: [
{ value: 'violin', label: 'Violin' },
{ value: 'cello', label: 'Cello' },
] },
];
}import { TreeSelect } from '@arclux/arc-ui-solid';
import { createSignal } from 'solid-js';
const instruments = [
{ value: 'keys', label: 'Keys', children: [
{ value: 'grand-piano', label: 'Grand Piano' },
{ value: 'rhodes', label: 'Rhodes' },
] },
{ value: 'strings', label: 'Strings', children: [
{ value: 'violin', label: 'Violin' },
{ value: 'cello', label: 'Cello' },
] },
];
function InstrumentPicker() {
const [instrument, setInstrument] = createSignal('');
return (
<TreeSelect label="Instrument" placeholder="Pick an instrument..."
items={instruments} value={instrument()}
onArcChange={(e) => setInstrument(e.detail.value)} />
);
}import { TreeSelect } from '@arclux/arc-ui-preact';
import { useState } from 'preact/hooks';
const instruments = [
{ value: 'keys', label: 'Keys', children: [
{ value: 'grand-piano', label: 'Grand Piano' },
{ value: 'rhodes', label: 'Rhodes' },
] },
{ value: 'strings', label: 'Strings', children: [
{ value: 'violin', label: 'Violin' },
{ value: 'cello', label: 'Cello' },
] },
];
function InstrumentPicker() {
const [instrument, setInstrument] = useState('');
return (
<TreeSelect label="Instrument" placeholder="Pick an instrument..."
items={instruments} value={instrument}
onArcChange={(e) => setInstrument(e.detail.value)} />
);
}API
valuestring''- The selected leaf's value. Setting it programmatically updates the trigger's breadcrumb label, and the branches containing it auto-expand the next time the panel opens.
placeholderstring'Select...'- Hint text displayed inside the trigger when no leaf is selected. It disappears once a value is chosen.
labelstring''- Visible label rendered above the trigger. Also serves as the accessible name. Always provide one for accessibility compliance.
namestring''- Form field name submitted with the selected leaf value via ElementInternals.
disabledbooleanfalse- When true, the trigger becomes non-interactive: it cannot be opened, focused, or clicked, and renders with reduced opacity.
errorstring''- Error message displayed below the trigger. When set, the trigger border turns red.
itemsArray<{value: string, label: string, children?: Array<object>, disabled?: boolean}>[]- Recursive tree of nodes. A node with a non-empty
childrenarray is a group header: it expands and collapses but can never be selected. A node without children is a selectable leaf.disablednodes render but cannot be reached by keyboard or selected, and a disabled group hides its children. expandedValuesstring[][]- Values of group nodes to render initially expanded. Attribute:
expanded-values(JSON array). Branches containing the selected value auto-expand on open regardless of this list. size'sm' | 'md' | 'lg''md'- Controls the trigger size.
openbooleanfalse- Controls whether the tree panel is visible. Automatically set to false when a leaf is selected, Escape is pressed, or the user clicks outside. Held at
falsewhiledisabled. formAssociatedbooleantruepropertiesobject{ // flag(), unlike `disabled`. The exclusion in props.js is specifically // about form-associated *platform* semantics: a `disabled` content // attribute that is merely present makes the element actually disabled // per the HTML spec, and formDisabledCallback assigns the property back, // so no converter can win. Neither of these is platform-mapped: // `required` is enforced by _computeValidity() below and `readonly` by // each component's own interaction handlers, so the stock converter buys // nothing here and costs the usual bug: `required="false"` read as true, // blocking submission of a form the author meant to leave optional. // Finding #48's shape, across all 26 form controls at once. required: flag(false), readonly: flag(false), }- Lit merges static properties up the prototype chain, so every consumer
gets these without declaring them.
requiredparticipates in constraint validation below;readonlyreflects for styling and is enforced by each component's interaction handlers (the mixin can't know which gestures mutate state). autoValidatesbooleantrue- Components that run their own constraint-validation logic (pattern checks, range checks) opt out of the automatic required sync by overriding this to false, and own the whole validity flag set instead.
formvalidityvalidationMessagerequiredbooleanfalsereadonlybooleanfalse
Methods
checkValidity()boolean- Whether the control currently satisfies its constraints, per the native
constraint-validation API. Fires
invalidon the element when it does not, and reports nothing to the user. reportValidity()boolean- As checkValidity(), but also shows the browser's validation message against the control when it fails.
Events
arc-change- Fired when a leaf is selected.
detail.valueis the leaf value,detail.labelits label, anddetail.paththe array of ancestor group values from root to parent.
See Also
- SelectDropdown select with searchable options, keyboard navigation, and full ARIA listbox semantics for accessible form inputs.
- ComboboxSearchable dropdown with type-ahead filtering.
- Tree ViewHierarchical tree structure with expandable/collapsible nodes, selection tracking, keyboard navigation, and indentation guide lines.
- Multi SelectMulti-value select with tag chips, inline search filtering, and keyboard navigation.