TreeSelect

Picks one item from a tree.

<script setup lang="ts">
  import { ref } from 'vue'
  import { TreeSelect } from '@hina-ui/vue'
  import { regions } from './data'

  const region = ref<string | null>('kyoto')
</script>

<template>
  <TreeSelect v-model="region" :items="regions" aria-label="Region" class="w-64" />
</template>
vue

Usage

import { TreeSelect } from '@hina-ui/vue'
ts

The tree select shares its trigger with Select and opens a tree that expands level by level. items supplies the nodes, each { value, label }, and a node with children can be expanded; v-model binds the chosen node’s value, and a node at any level can be chosen.

Current value: none

<script setup lang="ts">
  import { ref } from 'vue'
  import { Stack, Text, TreeSelect } from '@hina-ui/vue'
  import { regions } from './data'

  const region = ref<string | null>(null)
</script>

<template>
  <Stack class="w-64">
    <TreeSelect
      v-model="region"
      :items="regions"
      placeholder="Choose a region"
      aria-label="Region"
    />
    <Text tone="muted">Current value: {{ region ?? 'none' }}</Text>
  </Stack>
</template>
vue

Examples

Expanded by default

defaultExpanded lists the nodes expanded when the tree opens. The path to the chosen node is always expanded as well.

<script setup lang="ts">
  import { ref } from 'vue'
  import { TreeSelect } from '@hina-ui/vue'
  import { regions } from './data'

  const region = ref<string | null>(null)
</script>

<template>
  <TreeSelect
    v-model="region"
    :items="regions"
    :default-expanded="['jp', 'kanto']"
    placeholder="Choose a region"
    aria-label="Region"
    class="w-64"
  />
</template>
vue

Custom content

The node slot customises each node.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Inline, Text, TreeSelect, type TreeSelectNode } from '@hina-ui/vue'

  const dept = ref<string | null>(null)
  const departments: TreeSelectNode[] = [
    {
      value: 'product',
      label: 'Product',
      description: '12 people',
      children: [
        { value: 'design', label: 'Design', description: '5 people' },
        { value: 'research', label: 'Research', description: '3 people' },
      ],
    },
    {
      value: 'engineering',
      label: 'Engineering',
      description: '28 people',
      children: [
        { value: 'web', label: 'Web', description: '9 people' },
        { value: 'api', label: 'API', description: '11 people' },
      ],
    },
  ]
</script>

<template>
  <TreeSelect
    v-model="dept"
    :items="departments"
    placeholder="Choose a department"
    aria-label="Department"
    class="w-72"
  >
    <template #node="{ node }">
      <Inline as="span" gap="sm" :wrap="false" justify="between">
        <Text as="span">{{ node.label }}</Text>
        <Text as="span" size="xs" tone="muted">{{ node.description }}</Text>
      </Inline>
    </template>
  </TreeSelect>
</template>
vue

Sizes

Three sizes, matching the input.

<script setup lang="ts">
  import { Stack, TreeSelect } from '@hina-ui/vue'
  import { regions } from './data'
</script>

<template>
  <Stack class="w-64">
    <TreeSelect size="sm" :items="regions" model-value="tokyo" aria-label="Small" />
    <TreeSelect size="md" :items="regions" model-value="tokyo" aria-label="Medium" />
    <TreeSelect size="lg" :items="regions" model-value="tokyo" aria-label="Large" />
  </Stack>
</template>
vue

Variants

primary sits directly on the page background with a border and shadow; secondary sits inside a surface such as a card, with only a tinted fill.

<script setup lang="ts">
  import { Card, Stack, TreeSelect } from '@hina-ui/vue'
  import { regions } from './data'
</script>

<template>
  <Stack class="w-64">
    <TreeSelect :items="regions" placeholder="Directly on the page" aria-label="On the page" />
    <Card>
      <TreeSelect
        variant="secondary"
        :items="regions"
        placeholder="Inside a card"
        aria-label="Inside a card"
      />
    </Card>
  </Stack>
</template>
vue

States

invalid marks a failed validation, disabled disables the whole select, and disabled on a node disables that node only.

<script setup lang="ts">
  import { Stack, TreeSelect, type TreeSelectNode } from '@hina-ui/vue'

  const items: TreeSelectNode[] = [
    {
      value: 'jp',
      label: 'Japan',
      children: [
        { value: 'tokyo', label: 'Tokyo' },
        { value: 'osaka', label: 'Osaka', disabled: true },
      ],
    },
  ]
</script>

<template>
  <Stack class="w-64">
    <TreeSelect invalid :items="items" placeholder="Choose a region" aria-label="Invalid" />
    <TreeSelect disabled :items="items" model-value="tokyo" aria-label="Disabled" />
    <TreeSelect
      :items="items"
      :default-expanded="['jp']"
      placeholder="With a disabled node"
      aria-label="With a disabled node"
    />
  </Stack>
</template>
vue

In a form

Inside a FormField the label points at the trigger, and the error message is rendered by the field and linked to it; validation rules and submission belong to the Form.

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Form, FormField, Text, TreeSelect } from '@hina-ui/vue'
  import { regions } from './data'

  const schema = v.object({
    region: v.string('Pick a region'),
  })

  const values = reactive({ region: null as string | number | null })
  const saved = ref('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    saved.value = JSON.stringify(data)
  }
</script>

<template>
  <Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
    <FormField name="region" label="Region" required>
      <TreeSelect v-model="values.region" :items="regions" />
    </FormField>
    <Button type="submit" :loading="submitting" class="self-start">Save</Button>
    <Text v-if="saved" tone="muted" size="sm">Saved: {{ saved }}</Text>
  </Form>
</template>
vue

Virtual scrolling

virtualize renders rows near the viewport, sharing measurement and scrolling with VirtualList. It is off by default. Pass { estimateSize, overscan } to configure estimated row height and the buffer on each side; actual heights are measured. Keyboard navigation covers the full collection and skips disabled items. Search still processes the full dataset. Only expanded, visible nodes participate in the window; parent-child navigation and selection do not depend on mounted rows. Rows unmount outside the rendered range; keep persistent slot state outside the row, keyed by its unique value.

Selected: 7890

<script setup lang="ts">
  import { ref } from 'vue'
  import { TreeSelect, Stack, Text } from '@hina-ui/vue'

  const selected = ref<string | number | null>(7890)
  const options = Array.from({ length: 10000 }, (_, index) => ({
    value: index,
    label: `Item ${String(index + 1).padStart(5, '0')}`,
    disabled: index % 97 === 0,
  }))
  const items = [{ value: 'root', label: 'All nodes', children: options }]
</script>

<template>
  <Stack gap="sm" class="w-64 max-w-full">
    <TreeSelect
      v-model="selected"
      :items="items"
      :default-expanded="['root']"
      :virtualize="{ estimateSize: 36, overscan: 6 }"
      searchable
      aria-label="Ten thousand items"
    />
    <Text size="sm" tone="muted">Selected: {{ selected }}</Text>
  </Stack>
</template>
vue

Behaviour

  • Clicking the arrow before a node only expands or collapses it; clicking the node itself chooses it and closes the panel.
  • Up and Down move focus, Right expands, Left collapses, and Enter or Space chooses.
  • The panel opens against the trigger at the same width, and scrolls inside when the tree is taller than the space available.
  • Page scrolling is locked while open; clicking outside or pressing Esc closes it.

Accessibility

  • The trigger is role="combobox", the tree is role="tree" and each node is role="treeitem" with aria-level, aria-expanded and aria-selected.
  • Pair it with a label element or an aria-label for its name. invalid also sets aria-invalid.

API

Props

Prop
Type
Default
Description
modelValue
string | number | null
—
Value of the chosen node
items
TreeSelectNode[]
—
The nodes, see the type below
virtualize
VirtualizeOptions
false
Virtual scrolling; content-based estimate, overscan 6
placeholder
string
locale pack
Text shown when nothing is chosen
searchable
boolean
false
Show search and filter nodes
search
string
''
Query; supports v-model:search
searchPlaceholder
string
locale pack
Search placeholder and accessible name
defaultExpanded
Array<string | number>
[]
Nodes expanded when the panel opens
open
boolean
false
Whether the panel is open; v-model:open
variant
'primary' | 'secondary'
'primary'
Variant
size
'sm' | 'md' | 'lg'
'md'
Size
invalid
boolean
false
Whether validation failed
disabled
boolean
false
Whether the select is disabled
class
string
—
Classes appended to the trigger

Slots

Slot
Payload
Description
node
{ node: TreeSelectNode }
Content of each node

Events

Event
Payload
Description
update:modelValue
value: string | number
The chosen value changed
update:open
open: boolean
The panel opened or closed
update:search
search: string
The search query changed

Types

interface TreeSelectNode {
  value: string | number
  label: string
  description?: string
  disabled?: boolean
  children?: TreeSelectNode[]
}
ts
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts