GrTreeSelect
Selection of a value from a tree structure in a single control.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the options are nested — categories, an org structure, regions, the sections of a catalogue: a flat list would lose the levels;
- the path to a node matters — the selection is shown together with the branches rather than as a single leaf;
- several nodes are selected — with or without taking the parents into account (
checkStrictly); - the tree is large — filtering by typing and virtualisation arrive from
GrTree.
When to take something else
| Need | Take |
|---|---|
| The options are flat | GrSelect |
| There are many options and they are searched by typing | GrAutocomplete |
| The tree is needed on the screen rather than in a panel | GrTree |
| There is only one level, but with group headings | GrSelect |
Multiple selection with checkboxes
<GrTreeSelect v-model="areas" :data="tree" multiple show-checkbox />
show-checkbox switches on the checkboxes of GrTree instead of a tick of its own:
ticking a parent covers the subtree in a cascade, and a partly ticked parent is announced
aria-checked="mixed". It works only together with multiple — in a single selection the current
node is highlighted anyway.
The cascade is computed by the tree itself rather than by the select: a click on a row, a click on
the square and Space go the same path, so there is no double toggling. check-strictly unties the
parent from the children.
All of the ticked keys, the parent ones included, land in modelValue. If only the leaves are
needed, switch check-strictly on and tick them yourself, or filter the value from the outside.
The keyboard: from the trigger into the tree
↓, ↑, Enter and Space on the trigger open the panel and move the focus into the tree —
from there all of the keys of GrTree work. The panel is teleported into body, so Tab does not
lead there and there is no other way inside.
With filterable the focus first goes into the search field (typing the filter is the first thing
expected), and ↓/↑ from there lead into the tree. Tab out of the panel closes it, and Escape
closes it and returns the focus to the trigger by the means of the shared layer stack.
ARIA
The trigger is a role="combobox" with aria-haspopup="tree" and an aria-controls on the tree
while the panel is open. Referring to a tree that is not in the DOM (empty data, loading) is not
allowed — in those states aria-controls is not output.
Inside GrFormField the control takes id, aria-describedby, aria-invalid and aria-required
from the context; outside a field the accessible name is given by ariaLabel.
The states
disabled is coloured with a background and a text colour rather than with transparency: opacity
dilutes tokens tuned to AA and drops the contrast. readonly leaves the value visible but removes
both the clear button and the opening of the panel.
loading shows an indicator instead of “No data”: an empty answer and data that has not loaded must
not look the same. The markup can be replaced with the #loading slot.
The size
size is read through useGrComponentSize(), so both GrConfigProvider and a pointed
componentDefaults.GrTreeSelect apply. The same size travels into the tree inside the panel —
otherwise the control and its list would be set in different type sizes.
The display of the value
valueDisplay="path" in the single mode shows the path through / instead of a single label.
With multiple the “first label +N” stays in the trigger; the full list is given to the #value
slot — that is where markup of your own is assembled (chips, a counter, anything at all).
The `prefix` / `suffix` addons
The slots put an icon, a unit or a label into the shell; the width is bounded by six props
(prefixMinWidth/prefixMaxWidth/prefixFixed and the same for the suffix). The shared contract of
the controls — form-controls.md.
Panel control and the native form
The panel is controllable through v-model:open (the shared contract of panel overlays, as in
GrPopover): without the open prop the behaviour is uncontrolled, with it the parent owns the
state.
The name prop enables participation in a native form: for every selected key an
input[type="hidden"] with that name is rendered (the standard serialisation with a repeated key),
and an empty selection sends nothing.
Playground 27
Loading…
<GrTreeSelect />Install
npm i @feugene/granularityImport
import { GrTreeSelect } from '@feugene/granularity/components/GrTreeSelect'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValuerequired | GrTreeSelectModelValue | — | — |
datarequired | T[] | — | — |
props | GrTreePropsMap | undefined | {
children: "children",
label: "label",
} | — |
nodeKey | "id" | NodeKeyProp<T> | undefined | "id" as any | — |
defaultExpandedKeys | GrTreeKey[] | undefined | [] | — |
disabled | boolean | undefined | false | — |
placeholder | string | undefined | undefined | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
loading | boolean | undefined | false | The data is still on its way. The panel shows an indicator instead of "No data" — otherwise an empty answer and one not loaded look the same. |
invalid | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read only: the value is visible but does not change. |
required | boolean | undefined | false | A mandatory field (`aria-required`). |
ariaLabel | string | undefined | undefined | The accessible name outside `GrFormField`. |
state | "default" | "success" | "warning" | "danger" | undefined | "default" | — |
multiple | boolean | undefined | false | — |
showCheckbox | boolean | undefined | false | Checkboxes in the tree instead of a tick of its own: checking a parent closes the subtree in a cascade, and a half-chosen parent is shown as `mixed`. It works only together with `multiple`. |
checkStrictly | boolean | undefined | false | Untie the parents from the children: the check stops spreading in a cascade. |
clearable | boolean | undefined | false | — |
open | boolean | undefined | undefined | The controlled state of the panel (`v-model:open`). Without the prop the panel behaves on its own (uncontrolled), with it — listen to `update:open` and change the prop. |
name | string | undefined | undefined | The name for a native form: a hidden input for every chosen key. |
valueDisplay | GrTreeSelectValueDisplay | undefined | "label" | How to display the chosen value in the single mode. |
filterable | boolean | undefined | false | — |
filterPlaceholder | string | undefined | undefined | — |
filterInputmode | "search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefined | undefined | — |
filterNodeMethod | GrTreeFilterNodeMethod<T> | undefined | undefined | — |
closeOnSelect | boolean | undefined | undefined | — |
dropdownMaxHeight | number | undefined | 320 | — |
virtual | boolean | undefined | false | Virtualisation of the tree in the panel: only a window around the viewport lives in the DOM. The tree itself becomes the scroller in this mode rather than the container of the panel — two nested scrollers would give two scrollbars on one list. |
prefixMinWidth | string | undefined | undefined | The widths of the `prefix`/`suffix` addons — the common contract of the controls of the package (`docs/form-controls.md`). |
prefixMaxWidth | string | undefined | undefined | — |
suffixMinWidth | string | undefined | undefined | — |
suffixMaxWidth | string | undefined | undefined | — |
prefixFixed | boolean | undefined | false | — |
suffixFixed | boolean | undefined | false | — |
Slots
| Slot | Type | Description |
|---|---|---|
value | { value: GrTreeSelectModelValue; labels: string[]; displayValue: string; pathLabels?: string[] | undefined; } | The rendering of the value inside the trigger (instead of the default text). |
node | { node: GrTreeNode<T>; data: T; selected: boolean; } | The rendering of a row of the tree. |
empty | any | The content of the empty state (when there is no data). |
loading | any | The content of the panel while the data is on its way. |
prefix | any | An addon to the left of the value: an icon, a currency code, a mark. |
suffix | any | An addon to the right of the value, before the cross and the chevron. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [GrTreeSelectModelValue] | — |
change | [GrTreeSelectModelValue] | — |
update:open | [boolean] | The panel has opened or closed (`v-model:open`). |
clear | [] | — |
nodeClick | [T, GrTreeNode<T>] | — |
focus | [FocusEvent] | — |
blur | [FocusEvent] | — |
Examples 5
Addons in the trigger
<script setup lang="ts">
import { ref } from 'vue'
import { GrTreeSelect } from '@feugene/granularity'
interface CostCentre {
id: number
label: string
children?: CostCentre[]
}
const costCentres: CostCentre[] = [
{
id: 1,
label: 'Marketing',
children: [
{ id: 11, label: 'Paid acquisition' },
{ id: 12, label: 'Events' },
],
},
{
id: 2,
label: 'Engineering',
children: [
{ id: 21, label: 'Platform' },
{ id: 22, label: 'Mobile' },
],
},
]
const centre = ref<number | null>(11)
</script>
<template>
<GrTreeSelect
v-model="centre"
:data="costCentres"
clearable
:default-expanded-keys="[1]"
placeholder="Cost centre"
aria-label="Cost centre"
prefix-fixed
>
<template #prefix>
<span class="i-lucide-wallet block h-4 w-4" />
</template>
<template #suffix>
EUR
</template>
</GrTreeSelect>
</template>Single select with path display
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrTreeSelect } from '@feugene/granularity'
type TreeSelectItem = {
id: number
label: string
children?: TreeSelectItem[]
}
const treeData: TreeSelectItem[] = [
{
id: 1,
label: 'Finance',
children: [
{ id: 11, label: 'Invoices' },
{
id: 12,
label: 'Reconciliation',
children: [
{ id: 121, label: 'Daily close' },
{ id: 122, label: 'Payout matching' },
],
},
],
},
{
id: 2,
label: 'Operations',
children: [
{ id: 21, label: 'Escalations' },
{ id: 22, label: 'Runbooks' },
],
},
]
const value = ref<number | null>(122)
</script>
<template>
<div class="grid gap-4">
<GrTreeSelect
v-model="value"
:data="treeData"
clearable
value-display="path"
placeholder="Pick knowledge area"
aria-label="Pick knowledge area"
:default-expanded-keys="[1]"
/>
<GrBadge>
Current value: {{ value ?? 'nothing selected' }}
</GrBadge>
</div>
</template>Multiple selection with filtering
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrTreeSelect } from '@feugene/granularity'
type TreeSelectItem = {
id: number
label: string
children?: TreeSelectItem[]
}
const treeData: TreeSelectItem[] = [
{
id: 1,
label: 'Platform',
children: [
{ id: 11, label: 'API gateway' },
{ id: 12, label: 'Observability' },
],
},
{
id: 2,
label: 'Customer success',
children: [
{ id: 21, label: 'Escalations' },
{ id: 22, label: 'Renewals' },
],
},
{
id: 3,
label: 'Growth',
children: [
{ id: 31, label: 'Experiments' },
{ id: 32, label: 'Attribution' },
],
},
]
const selectedValues = ref<Array<number | string>>([12, 21])
const selectionLabel = computed(() => {
if (selectedValues.value.length === 0)
return 'Nothing selected yet'
return `Selected ${selectedValues.value.length} nodes`
})
</script>
<template>
<div class="grid gap-4">
<GrTreeSelect
v-model="selectedValues"
:data="treeData"
multiple
show-checkbox
filterable
clearable
:close-on-select="false"
placeholder="Filter and pick several areas"
aria-label="Filter and pick several areas"
:default-expanded-keys="[1, 2, 3]"
/>
<div class="flex flex-wrap gap-2">
<GrBadge v-for="value in selectedValues" :key="value">
{{ value }}
</GrBadge>
<GrBadge tone="neutral">
{{ selectionLabel }}
</GrBadge>
</div>
</div>
</template>Custom trigger value and node slots
<script setup lang="ts">
import { ref } from 'vue'
import { GrTreeSelect } from '@feugene/granularity'
type TreeSelectItem = {
id: number
label: string
owner: string
children?: TreeSelectItem[]
}
const treeData: TreeSelectItem[] = [
{
id: 1,
label: 'Revenue platform',
owner: 'Billing',
children: [
{ id: 11, label: 'Invoice automation', owner: 'Billing' },
{ id: 12, label: 'Risk rules', owner: 'Fraud' },
],
},
{
id: 2,
label: 'Support tools',
owner: 'Support',
children: [
{ id: 21, label: 'Macros', owner: 'Support' },
{ id: 22, label: 'Routing', owner: 'Operations' },
],
},
]
const value = ref<number | null>(11)
</script>
<template>
<div class="grid gap-4">
<GrTreeSelect
v-model="value"
:data="treeData"
placeholder="Pick workflow"
aria-label="Pick workflow"
:default-expanded-keys="[1, 2]"
>
<template #value="{ displayValue, labels }">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-600">{{ displayValue || 'Nothing selected' }}</span>
<span v-if="labels.length" class="rounded-full bg-[var(--gr-accent)] px-2 py-1 text-xs text-[var(--gr-accent-fg)]">
{{ labels.length }} label(s)
</span>
</div>
</template>
<template #node="{ data, selected }">
<div class="flex w-full items-center justify-between gap-3">
<span>{{ data.label }}</span>
<span class="text-xs text-[var(--gr-muted-fg)]">
{{ selected ? 'Selected' : data.owner }}
</span>
</div>
</template>
</GrTreeSelect>
</div>
</template>Keyboard
- Стрелка вниз на поле открывает панель и уводит в поиск.
- Ещё одна стрелка — фокус уже в дереве, дальше клавиши GrTree.
Escзакрывает панель и возвращает фокус на поле.- Пока данные едут, панель показывает индикатор, а не «нет данных».
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrTreeSelect } from '@feugene/granularity'
type Region = {
id: string
label: string
children?: Region[]
}
const catalog: Region[] = [
{
id: 'eu',
label: 'Europe',
children: [
{ id: 'eu-central', label: 'Central' },
{ id: 'eu-north', label: 'North' },
],
},
{
id: 'us',
label: 'Americas',
children: [
{ id: 'us-east', label: 'East' },
{ id: 'us-west', label: 'West' },
],
},
]
const data = ref<Region[]>([])
const loading = ref(false)
const value = ref<string | null>(null)
async function load() {
loading.value = true
data.value = []
await new Promise(resolve => setTimeout(resolve, 900))
data.value = catalog
loading.value = false
}
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div class="grid gap-3">
<GrTreeSelect
v-model="value"
:data="data"
:loading="loading"
node-key="id"
filterable
clearable
placeholder="Регион размещения"
aria-label="Регион размещения"
/>
<div>
<GrButton size="sm" variant="outline" @click="load">
Загрузить справочник
</GrButton>
</div>
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
<ul class="grid gap-1">
<li>Стрелка вниз на поле открывает панель и уводит в поиск.</li>
<li>Ещё одна стрелка — фокус уже в дереве, дальше клавиши GrTree.</li>
<li><code>Esc</code> закрывает панель и возвращает фокус на поле.</li>
<li>Пока данные едут, панель показывает индикатор, а не «нет данных».</li>
</ul>
</div>
</div>
</template>Accessibility
- APG pattern
combobox + tree