GrTree

  • ядро
  • Данные
  • API · 41 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

  • данные вложены — файлы, категории, оргструктура, разделы: уровни и есть суть данных;
  • узлы отмечают галочками — с наследованием на родителей или строго (checkStrictly);
  • дети грузятся по требованию — lazy с load вместо выгрузки всего дерева разом;
  • дерево фильтруют — filterNodeMethod оставляет совпавшие узлы вместе с их ветками;
  • узлы переносят — draggable работает мышью, пальцем и с клавиатуры.

Когда взять другое

НужноБерите
Дерево нужно в панели выбора, а не на экранеGrTreeSelect
Уровень одинGrList
Данные табличныеGrDataTable
Порядок в плоском спискеGrSortableList
Разделы, из которых открыт одинGrCollapse

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 3 у каждого — живое демо и его код

  • Файлы репозитория

    Вид explorer — набор собственных переменных дерева, разложенный в style, рядом — моноширинное семейство; branch-line="elbow" рисует колено каждой связи, а слот по умолчанию даёт ему значок файла, в который упереться. vendor — lazy: load приносит детей при раскрытии. Файлы переносятся через draggable — мышью или Shift со стрелками; allow-drop не пускает их в файлы, allow-drag закрепляет README, а node-drop и node-context-menu сообщают о сделанном.

    billing-service
    src
    invoices
    prorate.ts
    render.ts
    index.ts
    README.md
    Drag a file into another folder, or focus it and use Shift with the arrows
  • Права роли по разделам

    show-checkbox с v-model:checked-keys превращает дерево в набор прав, а наполовину отмеченный раздел — честный ответ «часть»: событие check и getHalfCheckedKeys() называют такие разделы для сводки. check-strictly развязывает родителей и детей, когда раздел — право сам по себе. Вид picker даёт строки в 38px и увеличенный квадрат: цель — квадрат, а не подпись.

    Role · Accountant6 rights · every area whole or none
    Invoices
    View invoices
    Create and edit drafts
    Send to customers
    Void issued invoices
    Payments
    View payments
    Issue refunds
    Reports
    Revenue
    Tax summary
  • Навигация справочного центра

    filter-value прячет несовпавшее, но оставляет родителей совпавшего, а filter-node-method ищет и по скрытым ключевым словам — «iban» находит статью о SEPA. Событие filter отличает «ничего не нашлось» от «здесь пусто». Вид rail красит текущую страницу плашкой, а expand-on-click-node с accordion держат открытым один раздел.

    Invoices
    Prorated seats
    Reverse charge
    Dunning emails

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
dataобязательныйT[]—Корневые узлы в исходной форме. Дерево их не копирует: перенос и `appendNode` меняют эти массивы на месте.
propsGrTreePropsMap | undefined{ children: "children", label: "label", }Имена полей в данных: `children`, `label`, `isLeaf`. По умолчанию `children` и `label`.
nodeKey"id" | Extract<keyof T, string> | undefined"id" as anyПоле с уникальным ключом узла: на нём держатся раскрытие, отметки, перенос. По умолчанию `id`.
defaultExpandedKeysGrTreeKey[] | undefined[]Стартовый набор раскрытых узлов. Новый массив в пропе заменяет раскрытие целиком.
defaultExpandAllboolean | undefinedfalseРаскрывать узлы, как только они появились в данных. Уже свёрнутое руками не раскрывается обратно на каждом обновлении `data`.
filterNodeMethodGrTreeFilterNodeMethod<T> | undefined—Своё правило совпадения узла с фильтром; `undefined` из него — правило по умолчанию: подстрока подписи без учёта регистра. Совпавший узел остаётся видимым вместе с предками.
filterValuestring | undefinedundefinedЗначение фильтра. Проп, а не только метод `filter()`: обёртке иначе приходится держать `ref` на дерево и гонять значение в обход собственного реактивного контура. Результат фильтрации дерево сообщает событием `filter` — без него нельзя отличить «данных нет» от «поиск ничего не нашёл», а это разные экраны.
lazyboolean | undefinedfalseЛенивый режим: дети ветки приходят по её раскрытию через `load`. Узел считается разворачиваемым, пока не доказано обратное — полем `isLeaf` из карты `props`.
loadGrTreeLoad<T> | undefinedundefinedЗагрузчик ветки. `resolve` дописывает детей в данные узла.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedРазмер строки: высота, отступы, иконки и кегль подписи.
indentnumber | undefined0Шаг отступа уровня в пикселях. `0` — значение из темы (`--gr-tree-indent-step`).
virtualboolean | undefinedfalseВиртуализация: в DOM живёт только окно вокруг вьюпорта. Требует `maxHeight` — без ограниченной высоты окна прокрутки не существует; скроллером в этом режиме становится сам корень дерева. Включается осознанно: на списке в сотню узлов выигрыша нет, а в DOM остаётся только окно — вместе с ним меняется и то, что находит `querySelector` потребителя. Ограничение: перетаскивание работает по отрисованным строкам — уронить узел на тот, которого нет на экране, нельзя.
maxHeightstring | number | undefinedundefinedМаксимальная высота дерева со своим скроллером — всегда, не только с `virtual`: тот лишь кладёт поверх окно строк. Число — пиксели.
highlightCurrentboolean | undefinedtrueПодсвечивать строку текущего узла. По умолчанию `true`.

Слоты

SlotTypeОписание
default{ node: GrTreeNode<T>; data: T; }Содержимое строки вместо подписи: значок, счётчик, метка. Получает `node` и `data`.

События

EventСигнатураОписание
nodeClick(e: "nodeClick", data: T, node: GrTreeNode<T>): voidУзел выбран кликом или `Enter` и стал текущим.
nodeExpand(e: "nodeExpand", data: T, node: GrTreeNode<T>): voidУзел раскрыт кнопкой, клавиатурой или кликом при `expandOnClickNode`.
nodeCollapse(e: "nodeCollapse", data: T, node: GrTreeNode<T>): voidУзел свёрнут — пользователем или `accordion`, когда раскрыли соседа.
nodeDrop
(e: "nodeDrop", draggingNode: GrTreeNode<T>, dropNode: GrTre(e: "nodeDrop", draggingNode: GrTreeNode<T>, dropNode: GrTreeNode<T>, dropType: GrTreeNodeDropType): void
Узел перенесён указателем или `Shift` со стрелкой и уже переставлен в `data`. `dropType` — `prev`, `inner` или `next` относительно `dropNode`.
nodeContextMenu(e: "nodeContextMenu", evt: MouseEvent, data: T, node: GrTreeNode<T>): voidКонтекстное меню строки. Событие — первым аргументом: `preventDefault()` и своё меню.
update:currentKey(e: "update:currentKey", key: GrTreeKey | undefined): voidТекущий узел сменился кликом или `Enter` (`v-model:current-key`).
filter
(e: "filter", info: { value: string; visibleCount: number; m(e: "filter", info: { value: string; visibleCount: number; matchedCount: number; }): void
Результат фильтрации. Без него потребитель не отличит «данных нет» от «поиск ничего не нашёл» — а это разные пустые экраны: второй пользователь может исправить сам.
update:checkedKeys(e: "update:checkedKeys", keys: GrTreeKey[]): voidОтмеченные ключи (`v-model:checked-keys`) после отметки пользователем или методом; родители, отмеченные каскадом, входят тоже.
check
(e: "check", data: T, node: GrTreeNode<T>, info: { checkedKe(e: "check", data: T, node: GrTreeNode<T>, info: { checkedKeys: GrTreeKey[]; halfCheckedKeys: GrTreeKey[]; }): void
Отметка узла сменилась — `Space`, кликом или `setChecked()`; с итоговыми наборами ключей.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrTreeKey, GrTreeNodeDropType, GrTreeAllowDropType } from '@feugene/granularity/components/GrTree'

Файлdist/types/components/GrTree/grTreeTypes.d.ts

GrTreeKeytype
type GrTreeKey = string | number;
GrTreeNodeDropTypetype
type GrTreeNodeDropType = 'prev' | 'inner' | 'next';
GrTreeAllowDropTypetype
type GrTreeAllowDropType = GrTreeNodeDropType;
GrTreeNodetype
type GrTreeNode<T = any> = {
    key: GrTreeKey;
    label: string;
    data: T;
    level: number;
    parent?: GrTreeNode<T>;
    childNodes: GrTreeNode<T>[];
};
GrTreeNodeTargettype
type GrTreeNodeTarget<T = any> = GrTreeKey | GrTreeNode<T> | T;
GrTreeInstancetype
type GrTreeInstance<T = any> = {
    filter: (value: string) => void;

Файлdist/types/components/GrTree/grTreeProps.d.ts

GrTreePropsMaptype
type GrTreePropsMap = {
    children?: string;
    label?: string;
    /** Поле данных «узел — лист». Нужно ленивому режиму: детей ещё нет, а раскрывать нечего. */
    isLeaf?: string;
};
GrTreeFilterNodeMethodtype
type GrTreeFilterNodeMethod<T extends object = any> = (value: string, data: T, node?: GrTreeNode<T>) => boolean;
export type GrTreeBranchLine = 'line' | 'elbow';
export type GrTreeBranchLineColor<T extends object = any> = string | ((node: GrTreeNode<T>) => string | undefined | null);
export type GrTreeVisibleRow<T extends object> = {
    node: GrTreeNode<T>;
    isExpanded: boolean;
    isLeaf: boolean;
    isMatched: boolean;
    /**
     * Место узла среди соседей. В плоском DOM группы нет, поэтому структуру
     * дерева диктору сообщают `aria-level` + `aria-posinset` + `aria-setsize`.
     */
    posInSet: number;
    setSize: number;
    /** Идёт ли сейчас ленивая загрузка детей этого узла. */
    isLoading: boolean;
    /** Цвета направляющих для уровней предков (пусто, если `branchLine` выключен). */
    branchColors: string[];
};
export type GrTreeClassValue = HTMLAttributes['class'];
export type GrTreeNodeClass<T extends object> = GrTreeClassValue | ((row: GrTreeVisibleRow<T>) => GrTreeClassValue);
export type GrTreeDataProps<T extends object> = {
    /**
     * Корневые узлы в исходной форме. Дерево их не копирует: перенос и `appendNode` меняют эти
     * массивы на месте.
     */
    data: T[];
    /** Имена полей в данных: `children`, `label`, `isLeaf`. По умолчанию `children` и `label`. */
    props?: GrTreePropsMap;
    /** Поле с уникальным ключом узла: на нём держатся раскрытие, отметки, перенос. По умолчанию `id`. */
    nodeKey?: NodeKeyProp<T> | 'id';
    /** Стартовый набор раскрытых узлов. Новый массив в пропе заменяет раскрытие целиком. */
    defaultExpandedKeys?: GrTreeKey[];
    /**
     * Раскрывать узлы, как только они появились в данных. Уже свёрнутое руками
     * не раскрывается обратно на каждом обновлении `data`.
     */
    defaultExpandAll?: boolean;
    /**
     * Своё правило совпадения узла с фильтром; `undefined` из него — правило по умолчанию: подстрока
     * подписи без учёта регистра. Совпавший узел остаётся видимым вместе с предками.
     */
    filterNodeMethod?: GrTreeFilterNodeMethod<T>;
    /**
     * Значение фильтра. Проп, а не только метод `filter()`: обёртке иначе
     * приходится держать `ref` на дерево и гонять значение в обход собственного
     * реактивного контура.
     *
     * Результат фильтрации дерево сообщает событием `filter` — без него нельзя
     * отличить «данных нет» от «поиск ничего не нашёл», а это разные экраны.
     */
    filterValue?: string;
    /**
     * Ленивый режим: дети ветки приходят по её раскрытию через `load`.
     * Узел считается разворачиваемым, пока не доказано обратное — полем `isLeaf`
     * из карты `props`.
     */
    lazy?: boolean;
    /** Загрузчик ветки. `resolve` дописывает детей в данные узла. */
    load?: GrTreeLoad<T>;
};
export type GrTreeLoad<T extends object = any> = (node: GrTreeNode<T>, resolve: (children: T[]) => void) => void;
export type GrTreeViewProps<T extends object> = {
    /** Размер строки: высота, отступы, иконки и кегль подписи. */
    size?: GrTreeSize;
    /** Шаг отступа уровня в пикселях. `0` — значение из темы (`--gr-tree-indent-step`). */
    indent?: number;
    /**
     * Виртуализация: в DOM живёт только окно вокруг вьюпорта.
     *
     * Требует `maxHeight` — без ограниченной высоты окна прокрутки не существует;
     * скроллером в этом режиме становится сам корень дерева.
     *
     * Включается осознанно: на списке в сотню узлов выигрыша нет, а в DOM остаётся
     * только окно — вместе с ним меняется и то, что находит `querySelector`
     * потребителя.
     *
     * Ограничение: перетаскивание работает по отрисованным строкам — уронить узел
     * на тот, которого нет на экране, нельзя.
     */
    virtual?: boolean;
    /**
     * Максимальная высота дерева со своим скроллером — всегда, не только с
     * `virtual`: тот лишь кладёт поверх окно строк. Число — пиксели.
     */
    maxHeight?: number | string;
    /** Подсвечивать строку текущего узла. По умолчанию `true`. */
    highlightCurrent?: boolean;
    /**
     * Иконка свёрнутого узла: Vue-компонент либо класс иконки (`'i-lucide-plus'` —
     * правило для класса заводит приложение: фабрике движка или провайдером,
     * см. `docs/installation.md#иконки`).
     * Не задана — встроенная стрелка.
     */
    expandIcon?: string | Component;
    /** Иконка раскрытого узла. Не задана — та же встроенная стрелка, повёрнутая. */
    collapseIcon?: string | Component;
    /** Поворот иконки раскрытого узла на 90°: хватает одной `expandIcon`. По умолчанию `true`. */
    toggleIconRotate?: boolean;
    /**
     * Направляющие уровней. `true` — то же, что `'line'`: одна вертикаль на
     * уровень. `'elbow'` добавляет горизонтальное колено к строке и обрывает
     * линию на середине последнего ребёнка — так видно не только уровень, но и
     * то, какие ветки ещё продолжаются.
     */
    branchLine?: boolean | GrTreeBranchLine;
    /**
     * Цвет направляющих: CSS-значение или функция от узла. Не задан —
     * `--gr-tree-branch-line-default-color`, иначе цвет рамки.
     */
    branchLineColor?: GrTreeBranchLineColor<T>;
    /** Цвет направляющей у ветки текущего узла: значение или функция. Не задан — `branchLineColor`. */
    branchLineActiveColor?: GrTreeBranchLineColor<T>;
    /**
     * Класс строки узла — значением или функцией от строки (`node`, `isExpanded`, `isLeaf`,
     * `isMatched`). Перебивает кегль и цвет дерева: так делают ступени по уровням.
     */
    rowClass?: GrTreeNodeClass<T>;
    /** Класс ручки переноса — значением или функцией от строки. */
    dragHandleClass?: GrTreeNodeClass<T>;
    /** Класс кнопки раскрытия — значением или функцией от строки. */
    toggleClass?: GrTreeNodeClass<T>;
    /** Класс иконки раскрытия — значением или функцией от строки. */
    toggleIconClass?: GrTreeNodeClass<T>;
    /** Класс заглушки на месте кнопки раскрытия у листа — значением или функцией от строки. */
    toggleSpacerClass?: GrTreeNodeClass<T>;
    /** Класс обёртки содержимого строки (подписи или слота) — значением или функцией от строки. */
    contentClass?: GrTreeNodeClass<T>;
    /** i18n-метка кнопки "Перетащить" (default: 'Drag'). */
    dragLabel?: string;
    /** i18n-метка кнопки "Развернуть" (default: 'Expand'). */
    expandLabel?: string;
    /** i18n-метка кнопки "Свернуть" (default: 'Collapse'). */
    collapseLabel?: string;
};
export type GrTreeInteractionProps<T extends object> = {
    /** Клик по строке раскрывает/сворачивает узел, а не только выбирает его. */
    expandOnClickNode?: boolean;
    /** На каждом уровне раскрыт максимум один узел. */
    accordion?: boolean;
    /** Перенос узлов мышью, пальцем и `Shift` со стрелкой. Ограничивают `allowDrag` и `allowDrop`. */
    draggable?: boolean;
    /** Иконка ручки переноса: компонент, класс иконки или ничего — тогда встроенная. */
    dragHandleIcon?: string | Component;
    /** Можно ли уронить узел на цель: `prev` — перед ней, `inner` — внутрь, `next` — после. */
    allowDrop?: (draggingNode: GrTreeNode<T>, dropNode: GrTreeNode<T>, type: GrTreeAllowDropType) => boolean;
    /** Можно ли взять узел: `false` выключает его ручку переноса. */
    allowDrag?: (draggingNode: GrTreeNode<T>) => boolean;
    /**
     * Когда показывать ручку переноса.
     *
     * `hover` — только под курсором; `always` — всегда; `auto` (по умолчанию) —
     * `always` там, где наведения не бывает (`@media (hover: none)`), иначе
     * `hover`. На тач-устройстве ручка «по наведению» недостижима вовсе, то есть
     * перетаскивания там нет — а это не решение дизайна, а отсутствие функции.
     */
    dragHandleVisibility?: 'hover' | 'always' | 'auto';
};
export type GrTreeSelectionProps = {
    /** Чекбоксы у узлов: множественный выбор поверх дерева. */
    showCheckbox?: boolean;
    /**
     * Текущий узел — `v-model:current-key`.
     *
     * Не задан — дерево ведёт текущий узел само, как и раньше. Задан — источник
     * правды снаружи: подсветка строки и признак, по которому обёртка рисует
     * собственные детали, перестают быть двумя разными состояниями, способными
     * разойтись на такт.
     */
    currentKey?: GrTreeKey | null;
    /** Отмеченные ключи (`v-model:checked-keys`). */
    checkedKeys?: GrTreeKey[];
    /** Стартовые отметки, когда `checkedKeys` не ведут снаружи. Читаются один раз, при создании. */
    defaultCheckedKeys?: GrTreeKey[];
    /** Не связывать родителей и детей: каждый узел отмечается сам по себе. */
    checkStrictly?: boolean;
};
export type GrTreeProps<T extends object> = GrTreeDataProps<T> & GrTreeViewProps<T> & GrTreeSelectionProps & GrTreeInteractionProps<T>;
GrTreeBranchLineColortype
type GrTreeBranchLineColor<T extends object = any> = string | ((node: GrTreeNode<T>) => string | undefined | null);
export type GrTreeVisibleRow<T extends object> = {
GrTreeVisibleRowtype
type GrTreeVisibleRow<T extends object> = {
    node: GrTreeNode<T>;
    isExpanded: boolean;
    isLeaf: boolean;
    isMatched: boolean;
    /**
     * Место узла среди соседей. В плоском DOM группы нет, поэтому структуру
     * дерева диктору сообщают `aria-level` + `aria-posinset` + `aria-setsize`.
     */
    posInSet: number;
    setSize: number;
    /** Идёт ли сейчас ленивая загрузка детей этого узла. */
    isLoading: boolean;
    /** Цвета направляющих для уровней предков (пусто, если `branchLine` выключен). */
    branchColors: string[];
};
GrTreeNodeClasstype
type GrTreeNodeClass<T extends object> = GrTreeClassValue | ((row: GrTreeVisibleRow<T>) => GrTreeClassValue);
export type GrTreeDataProps<T extends object> = {
GrTreeLoadtype
type GrTreeLoad<T extends object = any> = (node: GrTreeNode<T>, resolve: (children: T[]) => void) => void;
export type GrTreeViewProps<T extends object> = {
    /** Размер строки: высота, отступы, иконки и кегль подписи. */
    size?: GrTreeSize;
    /** Шаг отступа уровня в пикселях. `0` — значение из темы (`--gr-tree-indent-step`). */
    indent?: number;
    /**
     * Виртуализация: в DOM живёт только окно вокруг вьюпорта.
     *
     * Требует `maxHeight` — без ограниченной высоты окна прокрутки не существует;
     * скроллером в этом режиме становится сам корень дерева.
     *
     * Включается осознанно: на списке в сотню узлов выигрыша нет, а в DOM остаётся
     * только окно — вместе с ним меняется и то, что находит `querySelector`
     * потребителя.
     *
     * Ограничение: перетаскивание работает по отрисованным строкам — уронить узел
     * на тот, которого нет на экране, нельзя.
     */
    virtual?: boolean;
    /**
     * Максимальная высота дерева со своим скроллером — всегда, не только с
     * `virtual`: тот лишь кладёт поверх окно строк. Число — пиксели.
     */
    maxHeight?: number | string;
    /** Подсвечивать строку текущего узла. По умолчанию `true`. */
    highlightCurrent?: boolean;
    /**
     * Иконка свёрнутого узла: Vue-компонент либо класс иконки (`'i-lucide-plus'` —
GrTreeSelectionPropstype
type GrTreeSelectionProps = {
    /** Чекбоксы у узлов: множественный выбор поверх дерева. */
    showCheckbox?: boolean;
    /**
     * Текущий узел — `v-model:current-key`.
     *
     * Не задан — дерево ведёт текущий узел само, как и раньше. Задан — источник
     * правды снаружи: подсветка строки и признак, по которому обёртка рисует
     * собственные детали, перестают быть двумя разными состояниями, способными
     * разойтись на такт.
     */
    currentKey?: GrTreeKey | null;
    /** Отмеченные ключи (`v-model:checked-keys`). */
    checkedKeys?: GrTreeKey[];
    /** Стартовые отметки, когда `checkedKeys` не ведут снаружи. Читаются один раз, при создании. */
    defaultCheckedKeys?: GrTreeKey[];
    /** Не связывать родителей и детей: каждый узел отмечается сам по себе. */
    checkStrictly?: boolean;
};
GrTreePropstype
type GrTreeProps<T extends object> = GrTreeDataProps<T> & GrTreeViewProps<T> & GrTreeSelectionProps & GrTreeInteractionProps<T>;

Файлdist/types/composables/internal/treeChecking.d.ts

GrTreeCheckStatetype
type GrTreeCheckState = typeof GR_TREE_CHECK_STATES[number];

Файлdist/types/components/GrTree/config.d.ts

grTreeConfigconst
const grTreeConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTree">;

Файлdist/types/components/GrTree/defaults.d.ts

GrTreeConfigurablePropsinterface

Пропы GrTree, настраиваемые глобально через componentDefaults.

interface GrTreeConfigurableProps {
    size: GrComponentSize;
}

Файлdist/types/components/GrTree/grTreeStyles.d.ts

GrTreeSizetype
type GrTreeSize = GrComponentSize;
GrTreeViewtype

Готовые виды дерева — наборы значений тех же --gr-tree-*, что задаёт size. Не проп: второй канал спорил бы с размером за одни переменные, а набор с ним композируется — потребитель раскладывает его в style и при необходимости переопределяет по одной строке. Шрифт и значки в набор не входят: семейства у дерева своего токена нет, а значки — это expandIcon/collapseIcon и слот строки, то есть решение потребителя, а не вида.

type GrTreeView = 'explorer' | 'rail' | 'outline' | 'picker';
grTreeViewVarsconst
const grTreeViewVars: Record<GrTreeView, Record<string, string>>;

Файлdist/types/components/GrTree/safelist.d.ts

grTreeSafelistconst

Пуст: составных классов у дерева нет. Утилиты стоят в шаблоне литералами, строку рисуют собственные классы gr-tree__* из <style>, и granum извлекает всё это из чанков компонента сам, включая общие. Классы из пропов (rowClass, expandIcon/collapseIcon/dragHandleIcon и т. п.) приходят от потребителя — их генерирует его сборка, а не наша.

const grTreeSafelist: readonly [];

Файлdist/types/components/GrTree/GrTree.vue.d.ts

GrTreeEmitsinterface
interface GrTreeEmits<T extends Record<string, any> = any> {
    /** Узел выбран кликом или `Enter` и стал текущим. */
    (e: 'nodeClick', data: T, node: GrTreeNode<T>): void;
    /** Узел раскрыт кнопкой, клавиатурой или кликом при `expandOnClickNode`. */
    (e: 'nodeExpand', data: T, node: GrTreeNode<T>): void;
    /** Узел свёрнут — пользователем или `accordion`, когда раскрыли соседа. */
    (e: 'nodeCollapse', data: T, node: GrTreeNode<T>): void;
    /**
     * Узел перенесён указателем или `Shift` со стрелкой и уже переставлен в `data`. `dropType` —
     * `prev`, `inner` или `next` относительно `dropNode`.
     */
    (e: 'nodeDrop', draggingNode: GrTreeNode<T>, dropNode: GrTreeNode<T>, dropType: GrTreeNodeDropType): void;
    /** Контекстное меню строки. Событие — первым аргументом: `preventDefault()` и своё меню. */
    (e: 'nodeContextMenu', evt: MouseEvent, data: T, node: GrTreeNode<T>): void;
    /** Текущий узел сменился кликом или `Enter` (`v-model:current-key`). */
    (e: 'update:currentKey', key: GrTreeKey | undefined): void;
    /**
     * Результат фильтрации. Без него потребитель не отличит «данных нет» от
     * «поиск ничего не нашёл» — а это разные пустые экраны: второй пользователь
     * может исправить сам.
     */
    (e: 'filter', info: {
        value: string;
        visibleCount: number;
        matchedCount: number;
    }): void;
    /**
     * Отмеченные ключи (`v-model:checked-keys`) после отметки пользователем или методом; родители,
     * отмеченные каскадом, входят тоже.
     */
    (e: 'update:checkedKeys', keys: GrTreeKey[]): void;
    /** Отметка узла сменилась — `Space`, кликом или `setChecked()`; с итоговыми наборами ключей. */
    (e: 'check', data: T, node: GrTreeNode<T>, info: {
        checkedKeys: GrTreeKey[];
        halfCheckedKeys: GrTreeKey[];
    }): void;
}

Доступность

Паттерн APG

tree (roving tabindex)

Клавиши

↓ / ↑→←Home / End

КлавишаДействие
↓ / ↑по видимым узлам
→раскрыть/внутрь
←свернуть/к родителю
Home / Endк краям
Enterвыбрать
Spaceвыбрать (а при showCheckbox — переключить отметку)
*раскрыть всех соседей уровня
—печатные символы — typeahead; при draggable — Shift+↑/↓ переставить среди соседей
—Shift+→ сделать дочерним предыдущего соседа
—Shift+← вынести к родителю

Использование из прозы библиотеки

Разметка: плоский список вместо вложенного DOM

Дерево рендерится одним списком строк, а не вложенными компонентами: узел любого уровня — это div[role="treeitem"] в общем контейнере role="tree", отступ задаёт padding-left строки. Иерархию несут aria-level, aria-posinset и aria-setsize — при плоском представлении паттерн tree требует все три.

Практические следствия:

  • глубина дерева больше не превращается в глубину дерева компонентов: раскрытые 2 000 узлов — это 2 000 строк, а не 2 000 инстансов со своими вычислениями;
  • подсветка строки (наведение, текущий узел) и кольцо фокуса начинаются от отступа уровня, а не от левого края дерева: иначе фон накрывал бы направляющие предков — линию ветки не было бы видно ровно там, где она нужна. Рисует их отдельный слой строки, вставленный слева на --gr-tree-row-indent;
  • шаг отступа настраивается пропом indent (в пикселях) или темой — --gr-tree-indent-step.
Готовые виды

Вид — это набор значений тех же --gr-tree-*, что задаёт size, а не проп: второй канал спорил бы с размером за одни переменные. Наборы экспортируются и раскладываются в style, поэтому композируются с size и переопределяются по одной строке.

vue
<script setup lang="ts">
import { grTreeViewVars } from '@feugene/granularity'
import { GrTree } from '@feugene/granularity/components/GrTree'
</script>

<template>
  <GrTree :data="nodes" node-key="id" branch-line="elbow" :style="grTreeViewVars.explorer" />
</template>
ВидДля чегоЧто меняет
explorerфайлы, пространства имён, тысячи узловстрока 24px и кегль под неё, волосяные нейтральные направляющие, колена связей, подпись ветки без утолщения
railнавигация по разделам, боковая панельстрока 30px, выбор — сплошная плашка, скруглённая только справа, с перекрашенным текстом
outlineоглавление рядом с текстомни подложек, ни направляющих: выбор отмечен полосой у края списка и цветом, вес отдан уровню
pickerотбор узлов отметкамистрока 38px и увеличенный квадрат: цель нажатия — сам квадрат, а не подпись

picker отметок не включает — это ортогональная ось (showCheckbox), и она работает в любом виде. Набор задаёт под неё только плотность.

Семейство шрифта в набор не входит, а кегль входит: своего токена для семейства у дерева нет, а высота строки без кегля недостижима — её держит текст, а не min-height. Моноширинный набор проводника подмешивается рядом: :style="{ ...grTreeViewVars.explorer, fontFamily: 'var(--gr-font-mono)' }".

Значки и хвост строки — тоже не вид, а слот. Проводнику значок папки и файла нужен не для красоты: колено связи упирается в начало содержимого, и без значка черта обрывается в пустоте перед подписью. Содержимое строки растянуто на всю свободную ширину, поэтому счётчик или метка прижимаются вправо обычным margin-left: auto.

Набор кладётся на само дерево, а не на обёртку. Значения по умолчанию дерево объявляет на своём корне, поэтому переменная, выставленная предком, до строки не доходит — её перекрывает собственное объявление. Так же поступает и GrTreeSections со своим видом строк: отдаёт его внутреннему дереву, а не секции.

rowClass перебивает кегль и цвет дерева: обе величины объявлены на корне и строкой наследуются, а не задаются ей. Ступени по уровням иначе не сделать — уровень строки знает только разметка.

Своего вида не хватило — берите набор за основу и переопределяйте переменные поштучно; список — в ../tokens.md.

Направляющие уровней

branchLine по умолчанию 'line': без направляющих дерево читается как список с отступами, и уровень видно только по сдвигу подписи.

'elbow' добавляет горизонтальное колено к строке и обрывает вертикаль на середине последнего ребёнка. Разница не косметическая: линия начинает соединять узлы, а не только отмечать уровень, и по обрыву видно, какие ветки ещё продолжаются. Колено получает только направляющая собственного уровня строки — у транзитных предков черта указывала бы на строку, к которой они не относятся.

false — явный отказ.

Фильтрация

Значение фильтра — проп filterValue; метод filter() остаётся для императивных сценариев. Результат дерево сообщает событием filter: visibleCount — сколько строк реально видно (совпадения плюс раскрытые ради них родители), matchedCount — сколько узлов совпало само.

Число наружу нужно не для счётчика: без него потребитель не отличит «данных нет» от «поиск ничего не нашёл», а это разные пустые экраны — второй пользователь может исправить сам, первый нет.

Высота и виртуализация

maxHeight ограничивает дерево всегда: корень получает max-height и свой скролл, и длинное дерево не вырастает на всю страницу.

virtual вместе с maxHeight оставляет в DOM только окно вокруг вьюпорта — именно плоская разметка выше это и открывает. Скроллером становится сам корень дерева, а срезанное сверху и снизу держат его отступы: обёртки между role="tree" и role="treeitem" отняли бы у роли обязательных потомков.

vue
<GrTree :data="nodes" node-key="id" virtual :max-height="400" />

aria-setsize и aria-posinset остаются от полного набора, а не от окна — иначе диктор объявлял бы «1 из 20» на списке в тысячу узлов.

Включать осознанно: на сотне строк выигрыша нет, а разметка меняется, и вместе с ней — то, что находит querySelector потребителя. Высота строки берётся из --gr-tree-row-min-height как оценка и уточняется замером отрисованных строк. Устройство и ограничения — virtual-list.md.

Раскрытие
ПропЧто делает
defaultExpandedKeysстартовый набор раскрытых узлов
defaultExpandAllраскрывает узел в момент его появления в данных
expandOnClickNodeклик по строке раскрывает узел, а не только выбирает
accordionна каждом уровне раскрыт максимум один узел

defaultExpandAll намеренно не «держит всё раскрытым»: он отмечает уже виденные ключи, поэтому свёрнутое руками не разворачивается обратно на каждом обновлении data. Иначе живое дерево из БД схлопывало бы работу пользователя при любом рефетче.

expandOnClickNode действует только на мышь. Enter в паттерне tree закреплён за выбором, и подмешивать в него раскрытие нельзя — иначе выбрать папку с клавиатуры стало бы невозможно.

Иконки

expandIcon, collapseIcon и dragHandleIcon не заданы — дерево рисует свои встроенные, и от конфига приложения они не зависят. Своя иконка передаётся Vue-компонентом либо классом иконки (i-lucide-* — правило для него заводит приложение: фабрике движка или провайдером, иначе — Vue-компонент; см. «Иконки»).

toggleIconRotate поворачивает иконку раскрытия на 90° вместо смены картинки — поэтому одной expandIcon обычно достаточно.

Чекбоксы
vue
<GrTree v-model:checked-keys="checked" :data="data" node-key="id" show-checkbox />

Отметки связаны по дереву: отмеченный родитель отмечает всех потомков, частично отмеченный показывает aria-checked="mixed". checkStrictly эту связь отключает — каждый узел отвечает сам за себя.

Состояние объявляется на самом узле (aria-checked на treeitem), а видимый квадратик декоративен и скрыт от диктора: вкладывать интерактивный чекбокс внутрь роли-виджета нельзя — роль объявляет потомков презентационными. Корень при этом становится aria-multiselectable="true".

Space при включённых чекбоксах переключает отметку (это и есть выбор в multi-select дереве), Enter по-прежнему выбирает узел.

Императивно: getCheckedKeys({ leafOnly }), setCheckedKeys(), getHalfCheckedKeys(), setChecked(node, checked). checkedKeys снаружи может содержать только листья — родители посчитаются сами.

Ленивая подгрузка
vue
<GrTree :data="roots" node-key="id" lazy :load="loadChildren" />
ts
function loadChildren(node: GrTreeNode<Folder>, resolve: (children: Folder[]) => void) {
  fetchChildren(node.key).then(resolve)
}

В ленивом режиме узел считается разворачиваемым, пока не доказано обратное: лист объявляется полем isLeaf в данных (имя настраивается картой props). Ветка грузится по первому раскрытию — повторное запроса не делает; на время запроса строка показывает спиннер и помечается aria-busy.

Загруженные дети живут в состоянии компонента, а не дописываются в data: проп реактивным быть не обязан, а показать пришедшее дерево должно в любом случае. Пустой ответ делает ветку листом.

defaultExpandAll незагруженные ветки не трогает — иначе первый рендер дёрнул бы весь бэкенд.

Выбор и объявление скринридеру

Текущий узел задаётся пропом — v-model:current-key, — а не только методом setCurrentKey(). Разница не в удобстве: подсветку строки рисует дерево, а всё, что вокруг неё (заголовок панели, доступность действий), считает потребитель по своему состоянию. Пока это два владельца одного понятия, они способны разойтись на такт: строка подсвечена, а панель показывает другое. С пропом источник правды один, и обёртке не нужен ни ref на дерево, ни nextTick вокруг вызова.

Проп не задан — дерево ведёт текущий узел само, как и раньше; метод остаётся для императивных сценариев.

aria-selected="true" стоит только на выбранном узле. Проставлять false на каждом — значит заставить диктора проговаривать «не выбрано» на каждом шаге навигации; для одиночного выбора APG этого не требует.

Фокус

Один узел на всё дерево держит tabindex="0" (roving tabindex), остальные недостижимы Tab. DOM-узлы строк лежат в общем реестре key → element: навигация стрелками не должна обходить DOM поддерева на каждое нажатие.

Снаружи фокус ставится через focus(key?) — без аргумента на держателя roving tabindex. Именно им пользуется GrTreeSelect, чтобы отдать дереву клавиатуру.

При виртуализации порядок обязателен: сперва прокрутка к узлу, и только следующим тиком фокус. Узла вне окна в DOM нет, и focus() по нему уронил бы фокус на body вместе с размонтированной строкой.

Обратный случай — строка с фокусом уезжает из окна при прокрутке — дерево разбирает само: фокус переходит на корень, и следующая же стрелка возвращает его на видимую строку. Условие ровно одно: фокус был на этой строке. Прокрутка дерева, которого пользователь не касался, фокус страницы не трогает.

Drag & drop

draggable включает перетаскивание, allowDrag/allowDrop его ограничивают.

Ручка переноса видна по dragHandleVisibility. По умолчанию auto: под курсором там, где наведение есть, и всегда там, где его не бывает (@media (hover: none)). Это не украшение: на тач-устройстве событий наведения нет вовсе, поэтому ручка «по наведению» не появляется никогда — то есть жеста, которым начинается перетаскивание, на экране просто нет, хотя touch-action: none у ручки стоит ровно ради него. Режимы hover и always фиксируют поведение явно.

Решает это медиазапрос, а не matchMedia: ответ нужен и на сервере, а спрашивать среду в первом рендере нельзя — гидрация разойдётся.

Клавиатурный путь от этого не зависит: Shift со стрелкой двигает узел без всякой ручки. Обработчик drop гасит дефолт браузера до всех проверок: если событие дошло до дерева, дефолт — это навигация по брошенной ссылке или открытие брошенного файла поверх страницы.

Свёрнутая ветка под узлом раскрывается сама — если целиться внутрь неё (средняя треть строки) и задержаться там. Иначе положить узел внутрь свёрнутой ветки на нужное место было нельзя вовсе: перенос пришлось бы бросить, раскрыть её руками и начать заново.

Только на «внутрь»: целясь в соседа сверху или снизу, пользователь не просил менять раскладку, а раскрытие увело бы строки из-под курсора вместе с точкой, в которую он метил.

С virtual цель — отрисованная строка, и это свойство указателя, а не недоделка: навестись на то, чего нет на экране, всё равно нечем. У края списка работает автопрокрутка, и попадание пересчитывается на каждом её кадре — доехавшая до окна строка становится целью сразу. Клавиатурный перенос от этого не зависит вовсе: он ходит по модели.

Контекстное меню

nodeContextMenu отдаёт исходное событие первым аргументом — вызвать preventDefault() и показать своё меню решает потребитель.

Токены

Размер выражен переменными --gr-tree-* (высота строки, отступы, размер иконок, кегль), а не утилитарными классами: те же переменные объявлены точками кастомизации, и size задаёт им дефолты, а не спорит с ними вторым каналом. Цвета выводятся из --gr-primary/--gr-muted через color-mix — без hex- фолбэков, которые в тёмной теме давали непредсказуемый результат.

Фон строки безопасен при любом значении, включая непрозрачное. Подсветку рисует отдельный слой (::before), вставленный слева на отступ уровня: фон на самой коробке шёл бы от левого края дерева и накрывал направляющие предков. Слой уведён под содержимое строки (z-index: -1), а строка изолирована (isolation: isolate) — без изоляции отрицательный слой уехал бы за фон ближайшего предка с заливкой, и подсветка пропала бы на карточке.

Пара обязательна целиком, и обе половины держит e2e-гейт apps/showcase/e2e/geometry.spec.ts: он задаёт непрозрачный фон и меряет по пикселям, что подпись видна и что подсветка при этом есть. В jsdom это не ловится — CSS компонента там не применяется, а полупрозрачные дефолты маскируют дефект и в браузере.