HabSelect
Single-choice dropdown, matched to HabInput's box so a form row of mixed controls lines up.
<HabSelect
v-model="plan"
label="Plan"
:options="['free', 'pro', 'enterprise']"
help="Changeable at any time."
/>Every measurement here is the field's, not this component's own: same label size, same wrap gap, same box border, same box type size. They did not line up until that was enforced — the label was a step smaller and the border a step stronger.
Past roughly a dozen options this is the wrong control. Use HabAutocomplete: scrolling a long list to find a known value is slower than typing it.
Options
Pass plain strings where the value and the label are the same, or objects for a separate label and an icon:
<HabSelect
v-model="status"
:options="[
{ value: 'online', label: 'Online', icon: 'check-circle' },
{ value: 'degraded', label: 'Degraded', icon: 'alert' },
]"
/>Groups
Options carrying a group are banded under a heading:
<HabSelect
v-model="type"
:options="[
{ value: 'string', group: 'Primitives' },
{ value: 'number', group: 'Primitives' },
{ value: 'Status', group: 'Project types' },
]"
/>A run of adjacent options sharing a group becomes one band, so order by group — a list that alternates repeats the heading. Same field and same rule as HabAutocomplete's; they share the implementation, so a list cannot band one way here and another there.
The chosen row carries the accent tint and a trailing check, and keeps that tint on hover — two tinted rows in one list read as two selections. The keyboard cursor is a hairline inset ring rather than a second fill, for the same reason.
Picking the only option
<HabSelect v-model="locale" label="Locale" :options="locales" select-single />selectSingle picks the option by itself when there is exactly one. Group headings are not options and do not count.
Off by default, and opt in only where a change is safe to make unasked. Picking means emitting update:modelValue with no user action. A call site that acts on that emit at once — one that submits an answer to a running process, or writes to the server and broadcasts — would do so just by rendering. The component cannot tell a field in a form with a Save button from one that saves on change; the call site can.
The rules, the same on HabSelect, HabAutocomplete and HabTreeSelect:
- It picks only while the model is empty and the field is not
disabled. - It watches the options, so options that load after mount and turn out to be one are picked when they arrive.
- It never replaces a value that is set, even one that is not among the options: a stored value the options no longer list is still the stored value.
- Once per sole option. If the model has held a value while that one option was the only one, emptying it is taken as deliberate, and the option is not picked straight back. It is picked again only after the options have been something else in between — more, none, or a different one. That is compared by value, so rebuilding the same one-option array on every render does not re-arm it.
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. 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. Put a HabFieldAddon in it; it follows the select's read-only and disabled state without being told.
The trigger is a button, and the addon is a button of its own, which cannot sit inside it. So with the slot filled the box becomes a frame around the two, and draws the border, the hover and the focus the trigger draws alone otherwise. The hover border belongs to the trigger: over the addon, only the addon tints.
There is no appendAddon; that is HabInput's alone. The end of the box is the trigger's chevron, inside the trigger button.
Code values
mono sets the shown value in the mono face — and the placeholder, which stands in its place — for options that are code rather than words. The options in the list stay in the sans, as do the label and help. It is a prop rather than a class for the reason on HabButton: a font-mono from outside competes with the trigger's own sans by stylesheet order.
States and density
error outranks open, so an invalid select stays red while its list is showing. compact matches a compact field at --hab-control-sm.
Read-only
readonly shows the chosen option to be read: its label in full ink on the raised surface, no border to click into, no chevron and no placeholder — an empty read-only select shows an empty box. The list does not open, by pointer, keyboard or typeahead, and nothing is emitted. The trigger stays focusable 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.
<HabSelect v-model="plan" label="Plan" :options="plans" readonly />Error and disabled keep their own boxes when read-only. A read-only select with selectSingle does not pick by itself.
Focusing it from code
focus() moves focus to the trigger — the select's tab stop, which is where Tab lands when there is no prependAddon. With one, the addon comes first in the tab order and focus() skips it: it focuses the value, not the kind. It does not open the list. A disabled select 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 { HabSelect } from "@livereader/habitus-vue";
const field = useTemplateRef<InstanceType<typeof HabSelect>>("field");
// later: field.value?.focus();
</script>open() focuses the trigger and opens the list, as a click on 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 options. It does nothing while disabled or read-only, as no click would open the list then either.
Accessibility
- Wraps reka-ui's Select, so the listbox semantics, typeahead, collision detection, scroll locking and focus management are all the primitive's.
- The keyboard model differs from the design source. The source keeps focus on the trigger and exposes the active row with
aria-activedescendant; reka moves DOM focus into the listbox. Both are valid ARIA patterns. The source's stated reason for its choice was implementation fragility — restoring focus by hand, and stray blurs closing the list — which is exactly what a tested primitive removes. Recorded in design-source.md. - The trigger is a
<button>, which a<label for>cannot name, so the label is associated witharia-labelledbyand the help witharia-describedby. - Opens on Enter, Space, ArrowDown and ArrowUp; closes on Esc and outside click.
- The sheet is portalled, so a select near the bottom of the viewport flips rather than clipping. The design source's version, positioned inside the field, clipped.
- Known limitation: with the list open, axe reports
aria-hidden-focusagainst reka's own focus sentinels (data-reka-focus-guard). It is the primitive's markup, not ours, and not something a wrapper can fix.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
density | "default" | "compact" | "default" | Shrinks the trigger to --hab-control-sm, matching a compact HabInput. |
help | string | undefined | Helper text below the box, or the error message when state is error. |
label | string | undefined | Visible label. |
modelValue | string | undefined | The chosen value, for v-model. |
mono | boolean | false | Sets the shown value — and the placeholder, which stands in its place — in the mono face, for options that are code rather than words. The options in the list stay in the sans, as do the label and help. A mono class from outside would compete with the trigger's own sans by stylesheet order. |
options | (string | HabSelectOption)[] | [] | Options, or plain strings where the value and the label are the same. |
placeholder | string | undefined | Shown when nothing is chosen. No default — omitted means no placeholder. |
readonly | boolean | undefined | The chosen option is shown to be read, not changed: its label in full ink on the raised surface, no border to click into, no chevron and no placeholder, and the list does not open. Still focusable, and announced as read-only — unlike state="disabled", which says "not now" and is dimmed. Unset, it follows a surrounding HabReadonly; false keeps this select editable inside one. |
selectSingle | boolean | false | Pick the option by itself when there is exactly one: with the model empty and the field not disabled, 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. |
state | "default" | "disabled" | "error" | "default" | Validation and availability state. |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: string | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
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. |
Exposed
| Member | Type | Description |
|---|---|---|
focus | (options?: FocusOptions) => void | Moves focus to the trigger — the field's own tab stop, where Tab lands when there is no prependAddon — rather than to an addon in front of it. It does not open the list. A disabled select does not take focus, as a disabled native button does not. |
open | (options?: FocusOptions) => void | Focuses the trigger and opens the list, as a click on 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 options. Nothing happens while disabled or read-only, as no click would open it then either. |