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<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
| answers | search | |
|---|---|---|
HabSelect | which one, from a short list | no |
HabAutocomplete | which one, from a long flat list | yes |
HabTreeSelect | which one, from a hierarchy | yes |
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.
{ 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
| Key | In the box | On a row |
|---|---|---|
| a letter | opens, and searches | back to the box, and searches |
| Down | opens; then to the chosen row, or the first | next row |
| Up | previous row; from the first, back to the box | |
| Right | expand; on an open branch, into its first child | |
| Left | collapse; on a closed row, to its parent | |
| Home / End | first / last row | |
| Enter / Space | pick and close; on an open-only branch, expand or collapse | |
| Escape | closes | closes, back to the box |
Picking the only option
<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
selectablebranch 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.
<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.
<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:
<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
comboboxwhose 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'sComboboxpops up alistbox, and a tree inside a listbox is invalid. - reka-ui's
Treesupplies roving focus,role="tree"/treeitem,aria-expanded,aria-level,aria-selectedandaria-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'saria-label. It needs one oflabel,aria-labeloraria-labelledby, and warns otherwise. - Two wires are drawn here rather than by reka. reka points the sheet's
aria-labelledbyat 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'saria-controlsis bridged from the mounted sheet's own id, asHabAutocompletedoes. Worth raising upstream. - reka's type-ahead is switched off: typing is the search here, and it also counted
ArrowDownandHomeas 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
HabSelectandHabAutocomplete, axe reports nothing against the open sheet — noaria-hidden-focuson reka's focus guards.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
allowClear | boolean | false | A × 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. |
clearLabel | string | undefined | The 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. |
emptyLabel | string | undefined | Shown 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. |
help | string | undefined | Helper text below the box. Announced as the control's description, and rendered in --hab-error-text when state is error. |
label | string | undefined | Visible label. Associated with the input by for/id; also names the tree. |
modelValue | string | undefined | The chosen value, for v-model. |
mono | boolean | false | Sets 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. |
options | HabTreeOption[] | [] | The tree. Branches start collapsed; the chosen option's ancestors open with the sheet. |
pathSeparator | string | undefined | Show 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. |
placeholder | string | undefined | Shown in the box when nothing is chosen, and while the search is empty. |
readonly | boolean | undefined | The 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. |
selectSingle | boolean | false | Pick 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
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: string, option: HabTreeOption | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
empty | any | Replaces emptyLabel when the search matches nothing. |
prependAddon | any | A 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
| Member | Type | Description |
|---|---|---|
focus | (options?: FocusOptions) => void | Moves 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) => void | Focuses 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. |