Skip to content

HabTreeSelect ​

Choose one value out of a tree — an attribute path, a category, a folder, an org unit, by typing. HabAutocomplete's field over an expandable hierarchy: the box is the search, and the sheet is the tree.

customer.address.street
Three levels, a pickable branch (address) and a disabled one (orders)
vue
<HabTreeSelect
	v-model="attribute"
	label="Attribute"
	:options="attributes"
	path-separator="."
	placeholder="Search attributes…"
	empty-label="No matching attributes"
/>

The box is HabAutocomplete's, which is HabInput's — same height, border and type size — and the sheet and its rows are the popover shell HabSelect, HabAutocomplete and HabMenu share. The chevron turns the way HabAccordion's does.

Which control ​

answerssearch
HabSelectwhich one, from a short listno
HabAutocompletewhich one, from a long flat listyes
HabTreeSelectwhich one, from a hierarchyyes

Reach for it when the options nest, and where an option sits is part of what it means. Flattening a tree into HabAutocomplete with a group per parent loses exactly that: a nested branch becomes a section beside its parent, every heading repeats the full prefix, and past two levels the reader is reading prefixes, not structure. Cascading menus keep the nesting but open sideways, one sheet per level, on hover, with no search.

Two kinds of branch ​

A branch is open-only by default: it is a heading, drawn at medium weight, and the whole row expands it — click, Enter or Space. customer in the demo.

A branch with selectable: true is also an answer. It reads like every other option, its row picks it, and its chevron is a separate target with its own hover wash, so it expands without picking. On the keyboard, Right and Left expand and collapse it like any branch. address in the demo.

ts
{ value: "customer.address", label: "address", selectable: true, children: [...] }

Disabled options ​

disabled shows an option but makes it inert: no pick, no expand, and no chevron even when it has children — a chevron promises something to open, and this one would not. Its children stay out of reach, search included, and the keyboard skips it. Use meta to say why — orders in the demo reads Order[] · not selectable.

To show a branch whose children are reachable but which cannot itself be picked, leave it enabled: that is what an open-only branch is.

Labels and the path in the box ​

Give each option its own name as its label — street, not customer.address.street. Rows then read as a tree, and search matches names: a full path as a label repeats every prefix down the tree, and a search for st would match everything under customer.

The box shows the chosen option's label. Set path-separator to show its whole path instead, joined from the ancestors' labels: "." gives customer.address.street, " › " gives Customer › Address. The value is yours either way — pass whatever v-model should hold.

A modelValue that is not in the tree still shows, as the raw value. A stored path whose attribute has since gone is still a stored path; the placeholder would read as "nothing chosen" and invite an unseen overwrite.

Opening, typing and closing ​

A click in the box, Down, or typing opens the sheet. As in HabAutocomplete, the box is the search while the sheet is open — it starts empty, showing the placeholder — and shows the answer again once it closes. Focus stays in the box.

Branches start collapsed. Opening expands the chosen option's ancestors and scrolls it into view.

Typing filters by label. A match keeps every ancestor, and those ancestors open. A branch with nothing matching under it disappears; a branch that matches itself stays with its children collapsed, since you may want inside it. Clearing the text puts back what was open before. When nothing matches the sheet shows empty-label, or the empty slot.

Down moves into the tree. A letter typed on a row goes back to the box and carries on the search, so you never have to aim for the input to refine what you typed.

Only a pick closes the sheet — not an expand, not a chevron, not a click on the padding. Escape and an outside click dismiss it without picking; after a pick or Escape, focus is back in the box.

The sheet is capped at 260px, the shell's value, and the tree scrolls inside it; with branches collapsed that is rarely needed.

Keyboard ​

KeyIn the boxOn a row
a letteropens, and searchesback to the box, and searches
Downopens; then to the chosen row, or the firstnext row
Upprevious row; from the first, back to the box
Rightexpand; on an open branch, into its first child
Leftcollapse; on a closed row, to its parent
Home / Endfirst / last row
Enter / Spacepick and close; on an open-only branch, expand or collapse
Escapeclosescloses, back to the box

Picking the only option ​

vue
<HabTreeSelect v-model="path" label="Attribute" :options="attributes" select-single />

selectSingle picks the option by itself when there is exactly one, under the rules on HabSelect. In a tree, "exactly one" means one pickable option in the whole tree: not disabled, not under a disabled branch, and selectable — leaves are by default, branches only when set.

  • A branch holding a single leaf is one option — the leaf — and the leaf is picked.
  • A branch with several children is several options, and nothing is picked.
  • A selectable branch is an option of its own, so with one child under it there are two.

Off by default, because picking emits update:modelValue with no user action; HabSelect's page has the reason in full.

A control fused to the start ​

The prependAddon slot holds a control that belongs to the field — the kind of value it holds, chosen from a menu; an attribute is one of the kinds a source picker chooses between. It sits flush at the start of the box, runs its full height and is divided from the value by a line, as on HabInput. It takes the search glyph's place. Put a HabFieldAddon in it; it follows the field's read-only and disabled state without being told.

There is no appendAddon; that is HabInput's alone. The end of this box is the chevron button, which opens and closes the sheet.

Code values ​

mono sets the box's text in the mono face — what is typed, and the chosen value or path shown when the sheet is closed — for a value that is code, such as an attribute path. The tree stays in the sans, as do the label and help: rows carry meta and headings beside their labels, and the prop is about the field's value. It is a prop rather than a class for the reason on HabButton.

Clearing ​

allowClear puts a × inside the box, before the chevron, while there is a value and the sheet is closed. It empties the field: update:modelValue with "" and no option. Give it a clearLabel, the button's name ("Clear attribute"); Habitus warns without one, and there is no default — the string is your application's to translate. It is not offered while the field is disabled or read-only. The × is in the tab order, after the input: the input cannot clear itself, so the button is the only way to it. The names are HabColorGrid's; the empty value is "" rather than its null, as a tree's value is a path.

Use it only where an empty value is valid. On a required field, the way to change the answer is to pick another one; a × there only offers to break the field.

vue
<HabTreeSelect
	v-model="target"
	:options="attributes"
	label="Writes to"
	allow-clear
	clear-label="Clear attribute"
/>

States and density ​

state takes error and disabled, as the other fields do; density="compact" shrinks the box to --hab-control-sm, matching a compact HabInput and HabAutocomplete.

Read-only ​

readonly shows the answer to be read: the chosen label (or path, with pathSeparator) in full ink on the raised surface, no border to click into, and no chevron or placeholder. The sheet does not open — not on a click, not on Down, not on typing — and selectSingle does not pick. The input stays focusable and selectable and is announced as read-only, unlike state="disabled", which says "not now" and is dimmed. See HabReadonly for the difference, and for making a whole region read-only at once.

vue
<HabTreeSelect v-model="path" label="Attribute" :options="attributes" readonly />

Error and disabled keep their own boxes when read-only.

Focusing it from code ​

focus() moves focus to the input — the combobox, where clicking the label puts it, and where Tab lands when there is no prependAddon. With one, the addon comes first in the tab order and focus() skips it. It does not open the sheet. A disabled field does not take focus. It takes the browser's FocusOptions. Type the template ref with InstanceType:

vue
<script setup lang="ts">
import { useTemplateRef } from "vue";
import { HabTreeSelect } from "@livereader/habitus-vue";

const field = useTemplateRef<InstanceType<typeof HabTreeSelect>>("field");
// later: field.value?.focus();
</script>

open() focuses the input and opens the sheet, as a click in the box does. Reach for it when the field is revealed on demand, so the click that asked for it does not have to be repeated to see the tree. It does nothing while disabled or read-only, as no click would open the sheet then either.

Accessibility ​

  • The box's input is a combobox whose popup is a dialog holding the tree — the ARIA variant for a popup you work inside, as a date picker's is. That is why focus moves into the tree rather than staying in the input: reka's Combobox pops up a listbox, and a tree inside a listbox is invalid.
  • reka-ui's Tree supplies roving focus, role="tree" / treeitem, aria-expanded, aria-level, aria-selected and aria-disabled.
  • The visible label is a real <label for>; the help is the input's description. The tree and the sheet are named from the label, or from the caller's aria-label. It needs one of label, aria-label or aria-labelledby, and warns otherwise.
  • Two wires are drawn here rather than by reka. reka points the sheet's aria-labelledby at a trigger — and a field anchored to its box has none, so the dialog was unnamed (aria-dialog-name, serious); it is renamed from the label when it opens. And the input's aria-controls is bridged from the mounted sheet's own id, as HabAutocomplete does. Worth raising upstream.
  • reka's type-ahead is switched off: typing is the search here, and it also counted ArrowDown and Home as typed text.
  • A focused row shows the browser's own focus ring. The chosen row is marked by its tint and check, and by aria-selected.
  • Unlike HabSelect and HabAutocomplete, axe reports nothing against the open sheet — no aria-hidden-focus on reka's focus guards.

API ​

Props ​

PropTypeDefaultDescription
allowClearbooleanfalseA × inside the box, before the chevron, that empties the field: update:modelValue with "" and no option. Only for a value that may be empty — on a required one, the way to change it is to pick another. Shown while there is a value, the sheet is closed and the field is neither disabled nor read-only. Needs clearLabel, its accessible name. Named as on HabColorGrid; the empty value is "" rather than its null, as a path is a string.
clearLabelstringundefinedThe clear button's accessible name and tooltip, e.g. "Clear attribute".
density"default" | "compact""default"Shrinks the box to --hab-control-sm, matching a compact HabInput.
emptyLabelstringundefinedShown in the sheet when the search matches nothing. No default: pass it, or fill the empty slot, or an empty sheet renders a blank band.
helpstringundefinedHelper text below the box. Announced as the control's description, and rendered in --hab-error-text when state is error.
labelstringundefinedVisible label. Associated with the input by for/id; also names the tree.
modelValuestringundefinedThe chosen value, for v-model.
monobooleanfalseSets the box's text — what is typed, and the chosen value or path shown when closed — in the mono face, for a value that is code, such as an attribute path. The tree in the sheet stays in the sans, as do the label and help. A mono class reaching in from outside would compete with the field's own sans by stylesheet order.
optionsHabTreeOption[][]The tree. Branches start collapsed; the chosen option's ancestors open with the sheet.
pathSeparatorstringundefinedShow the chosen option in the box as its whole path — every ancestor's label and its own, joined by this — while each row shows only its own label. "." gives customer.address.street; " › " gives Customer › Address. Omitted, the box shows the option's own label. Either way the search matches row labels, so give options their own name rather than their full path: a full path as a label repeats every prefix down the tree, and a search for st then matches everything under customer.
placeholderstringundefinedShown in the box when nothing is chosen, and while the search is empty.
readonlybooleanundefinedThe answer is shown to be read, not changed: full ink on the raised surface, no border to click into, no chevron or placeholder, and the sheet does not open. Still focusable and selectable, and announced as read-only — unlike state="disabled", which says "not now" and is dimmed. Unset, it follows a surrounding HabReadonly; false keeps this field editable inside one. A native readonly attribute counts too.
selectSinglebooleanfalsePick the option by itself when there is exactly one: with the model empty and the field neither disabled nor read-only, it emits update:modelValue with that option's value — on mount, or whenever the options change to a single one, as they do when they load after mount. Off by default, because it emits with no user action. A call site that acts on the emit at once — one that submits an answer, or writes to the server and broadcasts — would do so just by rendering. Only the call site knows whether a change is that kind, so it opts in. It never replaces a value that is set, even one that is not among the options. Once the model has held a value under that one option, emptying it is taken as deliberate: the option is picked again only after the options have been something else in between. "Exactly one" is one PICKABLE option in the whole tree: an option that is not disabled, not under a disabled branch, and selectable — leaves are by default, branches only when set. A branch with a single leaf under it is one option, the leaf; a branch with several children is several.
state"default" | "disabled" | "error""default"Validation and availability state. error also sets aria-invalid.

Events ​

EventPayloadDescription
update:modelValuevalue: string, option: HabTreeOption—

Slots ​

SlotBindingsDescription
emptyanyReplaces emptyLabel when the search matches nothing.
prependAddonanyA control fused to the start of the box — usually a HabFieldAddon as a menu trigger, the kind of value the field holds. Flush, full height and divided from the value, as on HabInput. It takes the search glyph's place at the start.

Exposed ​

MemberTypeDescription
focus(options?: FocusOptions) => voidMoves focus to the input — the combobox, which is where clicking the label puts it, rather than to a prependAddon that Tab would reach first. It does not open the sheet. A disabled field does not take focus, as a disabled native input does not.
open(options?: FocusOptions) => voidFocuses the input and opens the sheet, as a click in the box does — for a choice revealed on demand, where the click that asked for it should not have to be repeated to see the tree. Nothing happens while disabled or read-only, as no click would open it then either.

Proprietary — internal use only.