GrTreeSelect

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда варианты вложены.

Когда брать

  • варианты вложены — категории, оргструктура, регионы, разделы каталога: плоский список потерял бы уровни;
  • важен путь до узла — выбранное показывается вместе с ветками, а не одним листом;
  • выбирают несколько узлов — с учётом или без учёта родителей (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

PropTypeпо умолчаниюОписание
modelValueобязательныйGrTreeSelectModelValue
dataобязательныйT[]
propsGrTreePropsMap | undefined{ children: "children", label: "label", }
nodeKey"id" | NodeKeyProp<T> | undefined"id" as any
defaultExpandedKeysGrTreeKey[] | undefined[]
disabledboolean | undefinedfalse
placeholderstring | undefinedundefined
size"xs" | "sm" | "md" | "lg" | undefinedundefined
loadingboolean | undefinedfalseДанные ещё едут. Панель показывает индикатор вместо «Нет данных» — иначе пустой ответ и незагруженный выглядят одинаково.
invalidboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: значение видно, но не меняется.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
ariaLabelstring | undefinedundefinedДоступное имя вне `GrFormField`.
state"default" | "success" | "warning" | "danger" | undefined"default"
multipleboolean | undefinedfalse
showCheckboxboolean | undefinedfalseЧекбоксы в дереве вместо собственной галочки: отметка родителя каскадом закрывает поддерево, полувыбранный родитель показывается `mixed`. Работает только вместе с `multiple`.
checkStrictlyboolean | undefinedfalseОтвязать родителей от детей: отметка перестаёт распространяться каскадом.
clearableboolean | undefinedfalse
openboolean | undefinedundefinedКонтролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп.
namestring | undefinedundefinedИмя для нативной формы: hidden input на каждый выбранный ключ.
valueDisplayGrTreeSelectValueDisplay | undefined"label"Как отображать выбранное значение в single-режиме.
filterableboolean | undefinedfalse
filterPlaceholderstring | undefinedundefined
filterInputmode"search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefinedundefined
filterNodeMethodGrTreeFilterNodeMethod<T> | undefinedundefined
closeOnSelectboolean | undefinedundefined
dropdownMaxHeightnumber | undefined320
virtualboolean | undefinedfalseВиртуализация дерева в панели: в DOM живёт только окно вокруг вьюпорта. Скроллером в этом режиме становится само дерево, а не контейнер панели — два вложенных скроллера дали бы две полосы прокрутки на одном списке.
prefixMinWidthstring | undefinedundefinedШирины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`docs/form-controls.md`).
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalse
suffixFixedboolean | undefinedfalse

Slots

SlotTypeОписание
value{ value: GrTreeSelectModelValue; labels: string[]; displayValue: string; pathLabels?: string[] | undefined; }Рендер значения внутри триггера (вместо дефолтного текста).
node{ node: GrTreeNode<T>; data: T; selected: boolean; }Рендер строки дерева.
emptyanyСодержимое пустого состояния (когда нет данных).
loadinganyСодержимое панели, пока данные едут.
prefixanyАддон слева от значения: иконка, код валюты, метка.
suffixanyАддон справа от значения, перед крестиком и шевроном.

Events

EventTypeОписание
update:modelValue[GrTreeSelectModelValue]
change[GrTreeSelectModelValue]
update:open[boolean]Панель открылась/закрылась (`v-model:open`).
clear[]
nodeClick[T, GrTreeNode<T>]
focus[FocusEvent]
blur[FocusEvent]

Примеры 5

Аддоны в триггере

Иконка и валюта в триггере: prefixFixed держит ширину аддона, поэтому колонка полей не плывёт.

Marketing
Paid acquisition
Events

Addons
<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", когда пользователю нужен контекст полной ветки.

Finance
Invoices
Current value: 122

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>

Множественный выбор с фильтрацией

Показываем наиболее ценный complex-flow: multi-select режим, встроенный filter и closeOnSelect=false для пакетного выбора узлов.

Platform
API gateway
Observability
Customer success
Escalations
Renewals
Growth
Experiments
Attribution
1221Selected 2 nodes

Multiple Filter
<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.

Invoice automation1 label(s)
Revenue platformBilling
Invoice automationSelected
Risk rulesFraud
Support toolsSupport
MacrosSupport
RoutingOperations

Custom 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>

Этот пример помогает увидеть, как GrTreeSelect превращается из generic picker в domain-specific selector без форка компонента.

Клавиатура и загрузка справочника

Стрелка с поля открывает панель и уводит в дерево, Esc возвращает фокус обратно, а loading не даёт спутать «ещё едет» с «ничего нет».

Нет данных
  • Стрелка вниз на поле открывает панель и уводит в поиск.
  • Ещё одна стрелка — фокус уже в дереве, дальше клавиши GrTree.
  • Esc закрывает панель и возвращает фокус на поле.
  • Пока данные едут, панель показывает индикатор, а не «нет данных».

Keyboard
<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

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты