GrTreeSelect

Package: @feugene/granularitycoreGroup: forms

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

NeedTake
The options are flatGrSelect
There are many options and they are searched by typingGrAutocomplete
The tree is needed on the screen rather than in a panelGrTree
There is only one level, but with group headingsGrSelect

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…

Code
<GrTreeSelect />

Install

npm i @feugene/granularity

Import

import { GrTreeSelect } from '@feugene/granularity/components/GrTreeSelect'

API

Props

PropTypedefaultDescription
modelValuerequiredGrTreeSelectModelValue
datarequiredT[]
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 | undefinedfalseThe 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.
invalidboolean | undefinedfalse
readonlyboolean | undefinedfalseRead only: the value is visible but does not change.
requiredboolean | undefinedfalseA mandatory field (`aria-required`).
ariaLabelstring | undefinedundefinedThe accessible name outside `GrFormField`.
state"default" | "success" | "warning" | "danger" | undefined"default"
multipleboolean | undefinedfalse
showCheckboxboolean | undefinedfalseCheckboxes 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`.
checkStrictlyboolean | undefinedfalseUntie the parents from the children: the check stops spreading in a cascade.
clearableboolean | undefinedfalse
openboolean | undefinedundefinedThe 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.
namestring | undefinedundefinedThe name for a native form: a hidden input for every chosen key.
valueDisplayGrTreeSelectValueDisplay | undefined"label"How to display the chosen value in the single mode.
filterableboolean | undefinedfalse
filterPlaceholderstring | undefinedundefined
filterInputmode"search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefinedundefined
filterNodeMethodGrTreeFilterNodeMethod<T> | undefinedundefined
closeOnSelectboolean | undefinedundefined
dropdownMaxHeightnumber | undefined320
virtualboolean | undefinedfalseVirtualisation 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.
prefixMinWidthstring | undefinedundefinedThe widths of the `prefix`/`suffix` addons — the common contract of the controls of the package (`docs/form-controls.md`).
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalse
suffixFixedboolean | undefinedfalse

Slots

SlotTypeDescription
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.
emptyanyThe content of the empty state (when there is no data).
loadinganyThe content of the panel while the data is on its way.
prefixanyAn addon to the left of the value: an icon, a currency code, a mark.
suffixanyAn addon to the right of the value, before the cross and the chevron.

Events

EventTypeDescription
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

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>

Single select with path display

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>

Multiple selection with filtering

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>

Custom trigger value and node slots

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>

Keyboard

No data
  • Стрелка вниз на поле открывает панель и уводит в поиск.
  • Ещё одна стрелка — фокус уже в дереве, дальше клавиши 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>

Accessibility

APG pattern
combobox + tree

Full keyboard contract of the package

Component documentationAll components