GrTreeSelect
Берут, когда варианты вложены.
Когда брать
- варианты вложены — категории, оргструктура, регионы, разделы каталога: плоский список потерял бы уровни;
- важен путь до узла — выбранное показывается вместе с ветками, а не одним листом;
- выбирают несколько узлов — с учётом или без учёта родителей (
checkStrictly); - дерево большое — фильтрация вводом и виртуализация приезжают из
GrTree.
Когда взять другое
| Нужно | Берите |
|---|---|
| Варианты плоские | GrSelect |
| Вариантов много и их ищут вводом | GrAutocomplete |
| Дерево нужно на экране, а не в панели | GrTree |
| Уровень всего один, но с заголовками групп | GrSelect |
Множественный выбор чекбоксами
<GrTreeSelect v-model="areas" :data="tree" multiple show-checkbox />
show-checkbox включает чекбоксы GrTree вместо собственной
галочки: отметка родителя каскадом закрывает поддерево, частично отмеченный
родитель объявляется aria-checked="mixed". Работает только вместе с
multiple — в одиночном выборе текущий узел и так подсвечен.
Каскад считает само дерево, а не селект: клик по строке, клик по квадратику и
Space идут одним путём, поэтому двойного переключения не бывает.
check-strictly отвязывает родителя от детей.
В modelValue попадают все отмеченные ключи, включая родительские. Если
нужны только листья, включите check-strictly и отмечайте их сами либо
отфильтруйте значение снаружи.
Клавиатура: из триггера в дерево
↓, ↑, Enter и Space на триггере открывают панель и переводят фокус в
дерево — дальше работают все клавиши GrTree. Панель телепортирована в
body, поэтому Tab туда не ведёт и другого пути внутрь нет.
При filterable фокус сначала уходит в поле поиска (набирать фильтр — первое,
чего ждут), а ↓/↑ оттуда уводят в дерево. Tab из панели её закрывает,
Escape закрывает и возвращает фокус на триггер силами общего стека слоёв.
ARIA
Триггер — role="combobox" с aria-haspopup="tree" и aria-controls на
дерево, пока панель открыта. Ссылаться на дерево, которого нет в DOM (пустые
данные, загрузка), нельзя — в этих состояниях aria-controls не выводится.
Внутри GrFormField контрол берёт из контекста id, aria-describedby,
aria-invalid и aria-required; вне поля доступное имя даёт ariaLabel.
Состояния
disabled красится фоном и цветом текста, а не прозрачностью: opacity
разбавляет выверенные на AA токены и роняет контраст. readonly оставляет
значение видимым, но убирает и кнопку очистки, и открытие панели.
loading показывает индикатор вместо «Нет данных»: пустой ответ и
незагруженные данные не должны выглядеть одинаково. Разметку можно заменить
слотом #loading.
Размер
size читается через useGrComponentSize(), поэтому действует и
GrConfigProvider, и точечный componentDefaults.GrTreeSelect. Тот же размер
уезжает в дерево внутри панели — иначе контрол и его список набирались бы
разным кеглем.
Отображение значения
valueDisplay="path" в одиночном режиме показывает путь через / вместо
одной подписи. При multiple в триггере остаётся «первая метка +N»; полный
список отдаётся слоту #value — там же собирается своя разметка (чипы,
счётчик, что угодно).
Аддоны `prefix` / `suffix`
Слоты кладут в оболочку иконку, единицу или метку; ширина ограничивается шестью
пропами (prefixMinWidth/prefixMaxWidth/prefixFixed и то же для суффикса).
Общий контракт контролов — form-controls.md.
Управление панелью и нативная форма
Панель управляема через v-model:open (общий контракт панельных оверлеев, как
у GrPopover): без пропа open — uncontrolled-поведение, с ним состоянием
владеет родитель.
Проп name включает участие в нативной форме: на каждый выбранный ключ
рендерится input[type="hidden"] с этим именем (стандартная сериализация
повторяющимся ключом), пустой выбор не отправляет ничего.
Playground 27
Загружается…
<GrTreeSelect />Установка
npm i @feugene/granularityИмпорт
import { GrTreeSelect } from '@feugene/granularity/components/GrTreeSelect'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelValueобязательный | GrTreeSelectModelValue | — | — |
dataобязательный | 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 | Данные ещё едут. Панель показывает индикатор вместо «Нет данных» — иначе пустой ответ и незагруженный выглядят одинаково. |
invalid | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: значение видно, но не меняется. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). |
ariaLabel | string | undefined | undefined | Доступное имя вне `GrFormField`. |
state | "default" | "success" | "warning" | "danger" | undefined | "default" | — |
multiple | boolean | undefined | false | — |
showCheckbox | boolean | undefined | false | Чекбоксы в дереве вместо собственной галочки: отметка родителя каскадом закрывает поддерево, полувыбранный родитель показывается `mixed`. Работает только вместе с `multiple`. |
checkStrictly | boolean | undefined | false | Отвязать родителей от детей: отметка перестаёт распространяться каскадом. |
clearable | boolean | undefined | false | — |
open | boolean | undefined | undefined | Контролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп. |
name | string | undefined | undefined | Имя для нативной формы: hidden input на каждый выбранный ключ. |
valueDisplay | GrTreeSelectValueDisplay | undefined | "label" | Как отображать выбранное значение в single-режиме. |
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 | Виртуализация дерева в панели: в DOM живёт только окно вокруг вьюпорта. Скроллером в этом режиме становится само дерево, а не контейнер панели — два вложенных скроллера дали бы две полосы прокрутки на одном списке. |
prefixMinWidth | string | undefined | undefined | Ширины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`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 | Описание |
|---|---|---|
value | { value: GrTreeSelectModelValue; labels: string[]; displayValue: string; pathLabels?: string[] | undefined; } | Рендер значения внутри триггера (вместо дефолтного текста). |
node | { node: GrTreeNode<T>; data: T; selected: boolean; } | Рендер строки дерева. |
empty | any | Содержимое пустого состояния (когда нет данных). |
loading | any | Содержимое панели, пока данные едут. |
prefix | any | Аддон слева от значения: иконка, код валюты, метка. |
suffix | any | Аддон справа от значения, перед крестиком и шевроном. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [GrTreeSelectModelValue] | — |
change | [GrTreeSelectModelValue] | — |
update:open | [boolean] | Панель открылась/закрылась (`v-model:open`). |
clear | [] | — |
nodeClick | [T, GrTreeNode<T>] | — |
focus | [FocusEvent] | — |
blur | [FocusEvent] | — |
Примеры 5
Аддоны в триггере
Иконка и валюта в триггере: prefixFixed держит ширину аддона, поэтому колонка полей не плывёт.
<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>Одиночный выбор с показом пути
Базовый сценарий для GrTreeSelect: single-value режим с valueDisplay="path", когда пользователю нужен контекст полной ветки.
<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>Множественный выбор с фильтрацией
Показываем наиболее ценный complex-flow: multi-select режим, встроенный filter и closeOnSelect=false для пакетного выбора узлов.
<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>Это хороший reference для permission matrices, taxonomy pickers и bulk-assignment flows.
Свои слоты значения и узла
Документируем slot API компонента: кастомный value-preview в trigger и enriched node rendering внутри dropdown-tree.
<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>Этот пример помогает увидеть, как GrTreeSelect превращается из generic picker в domain-specific selector без форка компонента.
Клавиатура и загрузка справочника
Стрелка с поля открывает панель и уводит в дерево, Esc возвращает фокус обратно, а loading не даёт спутать «ещё едет» с «ничего нет».
- Стрелка вниз на поле открывает панель и уводит в поиск.
- Ещё одна стрелка — фокус уже в дереве, дальше клавиши 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>Доступность
- Паттерн APG
combobox + tree- Клавиши
↓/↑/Enter/Space— открыть панель и перевести фокус в дерево (приfilterable— сначала в поле поиска, оттуда в дерево ведёт↓/↑),Esc— закрыть и вернуть фокус на триггер,Tabиз панели — закрыть; внутри дерева — клавишиGrTree