feat(editor): Add new Tree component to @n8n/design-system (#33291)

This commit is contained in:
Sebastien Powell 2026-07-03 10:19:22 +01:00 committed by GitHub
parent 62f3c7c3c3
commit cbbe113d25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2262 additions and 51 deletions

View File

@ -20,6 +20,8 @@ export { default as N8nLoading2 } from './v2/components/Loading/Loading.vue';
export type * from './v2/components/Loading/Loading.types';
export { default as N8nInputNumber2 } from './v2/components/InputNumber/InputNumber.vue';
export type * from './v2/components/InputNumber/InputNumber.types';
export { default as N8nTree2 } from './v2/components/Tree/Tree.vue';
export type * from './v2/components/Tree/Tree.types';
export { default as N8nSwitch2 } from './components/N8nSwitch/Switch.vue';
export type * from './components/N8nSwitch/Switch.types';
export { createPasswordRules } from './components/N8nFormInput/validators';

View File

@ -0,0 +1,791 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { defineComponent, type PropType, ref } from 'vue';
import type { TreeBranch, TreeNodeContext } from './Tree.types';
import Tree from './Tree.vue';
type GenericMeta<C> = Omit<Meta<C>, 'component'> & {
component: Record<keyof C, unknown>;
};
const menuItems: TreeBranch[] = [
{
id: 'workflows',
label: 'Workflows',
icon: 'bolt-filled',
children: [
{
id: 'all-workflows',
label: 'All workflows',
icon: 'list-tree',
children: [
{
id: 'personal-workflows',
label: 'Personal',
icon: 'user',
children: [
{
id: 'draft-workflows',
label: 'Drafts',
icon: 'file-text',
children: [
{
id: 'recent-drafts',
label: 'Recent drafts',
icon: 'history',
children: [{ id: 'today-drafts', label: 'Edited today', icon: 'clock' }],
},
{ id: 'archived-drafts', label: 'Archived', icon: 'archive' },
],
},
{ id: 'published-workflows', label: 'Published', icon: 'check' },
],
},
{ id: 'shared-workflows', label: 'Shared with me' },
{ id: 'team-workflows', label: 'Team workflows', icon: 'users' },
],
},
{ id: 'workflow-templates', label: 'Templates', icon: 'layout-template' },
{ id: 'workflow-favorites', label: 'Favorites', icon: 'star' },
],
},
{
id: 'credentials',
label: 'Credentials',
icon: 'lock',
children: [
{ id: 'all-credentials', label: 'All credentials', icon: 'key-round' },
{ id: 'shared-credentials', label: 'Shared credentials', icon: 'share' },
],
},
{
id: 'executions',
label: 'Executions',
icon: 'list',
children: [
{ id: 'recent-executions', label: 'Recent', icon: 'play' },
{ id: 'failed-executions', label: 'Failed', icon: 'circle-x' },
],
},
{
id: 'variables',
label: 'Variables',
icon: 'variable',
},
{
id: 'settings',
label: 'Settings',
icon: 'settings',
children: [
{ id: 'users', label: 'Users', icon: 'user-round' },
{ id: 'api', label: 'API', icon: 'plug' },
],
},
];
const defaultExpanded = [
'workflows',
'all-workflows',
'personal-workflows',
'draft-workflows',
'recent-drafts',
'credentials',
'executions',
'settings',
];
const controlledDemoItems: TreeBranch[] = [
{
id: 'workflows',
label: 'Workflows',
icon: 'bolt-filled',
children: [
{ id: 'all-workflows', label: 'All workflows', icon: 'list-tree' },
{ id: 'templates', label: 'Templates', icon: 'layout-template' },
],
},
{
id: 'credentials',
label: 'Credentials',
icon: 'lock',
children: [{ id: 'all-credentials', label: 'All credentials', icon: 'key-round' }],
},
{
id: 'variables',
label: 'Variables',
icon: 'variable',
},
];
const meta = {
title: 'Experimental/Tree',
component: Tree,
tags: ['autodocs'],
argTypes: {
disabled: {
control: 'boolean',
description: 'Disable tree interaction',
},
multiple: {
control: 'boolean',
description: 'Allow selecting multiple items',
},
showExpandArrow: {
control: 'boolean',
description: 'Show chevron toggle for expandable items',
},
virtualized: {
control: 'boolean',
description: 'Virtualize the flattened list for large trees',
},
estimateSize: {
control: 'number',
description: 'Estimated row height in px when virtualized',
},
},
} satisfies GenericMeta<typeof Tree>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: (args) => ({
components: { Tree },
setup() {
const selected = ref<string[]>([]);
const expanded = ref<string[]>([...defaultExpanded]);
return { args, selected, expanded, menuItems };
},
template: `
<div style="width: 320px; height: 480px;">
<Tree
v-bind="args"
:items="menuItems"
v-model="selected"
v-model:expanded="expanded"
/>
</div>
`,
}),
args: {
items: menuItems,
disabled: false,
multiple: false,
showExpandArrow: true,
},
};
const VIRTUAL_TREE_BRANCHES = 5;
const VIRTUAL_TREE_CATEGORIES = 12;
const VIRTUAL_TREE_ITEMS = 10;
function createNestedVirtualTree(): {
items: TreeBranch[];
nodeCount: number;
defaultExpanded: string[];
} {
let nodeCount = 0;
const defaultExpanded: string[] = [];
const items = Array.from({ length: VIRTUAL_TREE_BRANCHES }, (_, branchIndex) => {
const branchId = `branch-${branchIndex}`;
defaultExpanded.push(branchId);
nodeCount++;
return {
id: branchId,
label: `Department ${branchIndex + 1}`,
icon: 'list-tree' as const,
children: Array.from({ length: VIRTUAL_TREE_CATEGORIES }, (_, categoryIndex) => {
const categoryId = `${branchId}-category-${categoryIndex}`;
defaultExpanded.push(categoryId);
nodeCount++;
return {
id: categoryId,
label: `Category ${categoryIndex + 1}`,
icon: 'layout-template' as const,
children: Array.from({ length: VIRTUAL_TREE_ITEMS }, (_, itemIndex) => {
nodeCount++;
return {
id: `${categoryId}-item-${itemIndex}`,
label: `Item ${itemIndex + 1}`,
icon: 'file-text' as const,
};
}),
};
}),
};
});
return { items, nodeCount, defaultExpanded };
}
const {
items: nestedVirtualTreeItems,
nodeCount: nestedVirtualTreeNodeCount,
defaultExpanded: nestedVirtualTreeExpanded,
} = createNestedVirtualTree();
export const Virtualized: Story = {
render: (args) => ({
components: { Tree },
setup() {
const selected = ref<string[]>([]);
const expanded = ref<string[]>([...nestedVirtualTreeExpanded]);
return {
args,
selected,
expanded,
nestedVirtualTreeItems,
nestedVirtualTreeNodeCount,
VIRTUAL_TREE_BRANCHES,
VIRTUAL_TREE_CATEGORIES,
VIRTUAL_TREE_ITEMS,
};
},
template: `
<div style="width: 320px; height: 480px; display: flex; flex-direction: column; gap: var(--spacing--2xs);">
<p style="margin: 0; font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
{{ nestedVirtualTreeNodeCount }} nodes across
{{ VIRTUAL_TREE_BRANCHES }} departments,
{{ VIRTUAL_TREE_CATEGORIES }} categories each,
{{ VIRTUAL_TREE_ITEMS }} items per category
</p>
<Tree
style="flex: 1; min-height: 0;"
v-bind="args"
:items="nestedVirtualTreeItems"
virtualized
v-model="selected"
v-model:expanded="expanded"
/>
</div>
`,
}),
args: {
items: nestedVirtualTreeItems,
virtualized: true,
estimateSize: 32,
disabled: false,
multiple: false,
showExpandArrow: true,
},
};
export const Controlled: Story = {
render: () => ({
components: { Tree },
setup() {
const controlledSelected = ref<string[]>(['all-workflows']);
const controlledExpanded = ref<string[]>(['workflows']);
function selectCredentials() {
controlledSelected.value = ['credentials'];
}
function clearSelection() {
controlledSelected.value = [];
}
function expandAll() {
controlledExpanded.value = ['workflows', 'credentials'];
}
function collapseAll() {
controlledExpanded.value = [];
}
return {
controlledDemoItems,
controlledSelected,
controlledExpanded,
selectCredentials,
clearSelection,
expandAll,
collapseAll,
};
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--spacing--md);">
<section style="display: flex; flex-direction: column; gap: var(--spacing--2xs);">
<h3 style="margin: 0; font-size: var(--font-size--sm); font-weight: var(--font-weight--bold);">
Controlled
</h3>
<p style="margin: 0; font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
Selection and expansion are owned by the parent via
<code>v-model</code> and <code>v-model:expanded</code>.
</p>
<dl style="display: flex; flex-direction: column; gap: var(--spacing--4xs); margin: 0; font-size: var(--font-size--2xs);">
<div style="display: grid; grid-template-columns: 5rem 1fr; gap: var(--spacing--3xs);">
<dt style="margin: 0; color: var(--color--text--tint-1);">Selected</dt>
<dd style="margin: 0; word-break: break-word;">{{ controlledSelected.join(', ') || 'none' }}</dd>
</div>
<div style="display: grid; grid-template-columns: 5rem 1fr; gap: var(--spacing--3xs);">
<dt style="margin: 0; color: var(--color--text--tint-1);">Expanded</dt>
<dd style="margin: 0; word-break: break-word;">{{ controlledExpanded.join(', ') || 'none' }}</dd>
</div>
</dl>
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing--3xs);">
<button type="button" @click="selectCredentials">Select credentials</button>
<button type="button" @click="clearSelection">Clear selection</button>
<button type="button" @click="expandAll">Expand all</button>
<button type="button" @click="collapseAll">Collapse all</button>
</div>
<div
style="width: 100%; max-width: 320px; min-height: 240px; padding: var(--spacing--2xs); border: 1px solid var(--border-color--subtle); border-radius: var(--radius);"
>
<Tree
:items="controlledDemoItems"
v-model="controlledSelected"
v-model:expanded="controlledExpanded"
/>
</div>
</section>
<section style="display: flex; flex-direction: column; gap: var(--spacing--2xs);">
<h3 style="margin: 0; font-size: var(--font-size--sm); font-weight: var(--font-weight--bold);">
Uncontrolled
</h3>
<p style="margin: 0; font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
Initial state is set with <code>default-value</code> and
<code>default-expanded</code>. The tree manages its own state after mount.
</p>
<div
style="width: 100%; max-width: 320px; min-height: 240px; padding: var(--spacing--2xs); border: 1px solid var(--border-color--subtle); border-radius: var(--radius);"
>
<Tree
:items="controlledDemoItems"
:default-value="['all-workflows']"
:default-expanded="['workflows']"
/>
</div>
</section>
</div>
`,
}),
args: {
items: controlledDemoItems,
disabled: false,
multiple: false,
showExpandArrow: true,
},
};
type EmojiTreeBranch = Omit<TreeBranch, 'children'> & {
emoji?: string;
children?: EmojiTreeBranch[];
};
const customNodeItems: EmojiTreeBranch[] = [
{
id: 'planets',
label: 'Planets',
emoji: '🪐',
children: [
{
id: 'inner',
label: 'Inner solar system',
emoji: '☀️',
children: [
{ id: 'mercury', label: 'Mercury', emoji: '☿️' },
{ id: 'venus', label: 'Venus', emoji: '♀️' },
{
id: 'earth',
label: 'Earth',
emoji: '🌍',
children: [
{
id: 'continents',
label: 'Continents',
emoji: '🗺️',
children: [
{
id: 'europe',
label: 'Europe',
emoji: '🇪🇺',
children: [
{
id: 'france',
label: 'France',
emoji: '🇫🇷',
children: [
{ id: 'paris', label: 'Paris', emoji: '🗼' },
{ id: 'lyon', label: 'Lyon', emoji: '🏙️' },
],
},
{
id: 'germany',
label: 'Germany',
emoji: '🇩🇪',
children: [{ id: 'berlin', label: 'Berlin', emoji: '🐻' }],
},
],
},
{
id: 'asia',
label: 'Asia',
emoji: '🌏',
children: [{ id: 'japan', label: 'Japan', emoji: '🇯🇵' }],
},
],
},
],
},
{ id: 'mars', label: 'Mars', emoji: '🔴' },
],
},
{
id: 'outer',
label: 'Outer solar system',
emoji: '🌌',
children: [
{ id: 'jupiter', label: 'Jupiter', emoji: '🟠' },
{ id: 'saturn', label: 'Saturn', emoji: '🪐' },
{ id: 'uranus', label: 'Uranus', emoji: '🔵' },
{ id: 'neptune', label: 'Neptune', emoji: '🌀' },
],
},
],
},
{
id: 'animals',
label: 'Animals',
emoji: '🦁',
children: [
{
id: 'mammals',
label: 'Mammals',
emoji: '🐻',
children: [
{
id: 'bears',
label: 'Bears',
emoji: '🐻‍❄️',
children: [
{ id: 'grizzly', label: 'Grizzly bear', emoji: '🐻' },
{ id: 'polar', label: 'Polar bear', emoji: '🐻‍❄️' },
],
},
{ id: 'cats', label: 'Cats', emoji: '🐱' },
],
},
{ id: 'birds', label: 'Birds', emoji: '🦅' },
{ id: 'fish', label: 'Fish', emoji: '🐠' },
],
},
{
id: 'weather',
label: 'Weather',
emoji: '🌤️',
children: [
{ id: 'sunny', label: 'Sunny', emoji: '☀️' },
{ id: 'rainy', label: 'Rainy', emoji: '🌧️' },
{ id: 'snowy', label: 'Snowy', emoji: '❄️' },
],
},
];
const customNodeDefaultExpanded = [
'planets',
'inner',
'earth',
'continents',
'europe',
'france',
'outer',
'animals',
'mammals',
'bears',
];
function getCustomNodeProps({ item }: TreeNodeContext<EmojiTreeBranch>): {
label: string;
emoji?: string;
} {
const treeItem = item.value;
return { label: treeItem.label, emoji: treeItem.emoji };
}
const customNodeClass = {
treeItem: 'sb-tree-custom-item',
treeItemSelected: 'sb-tree-custom-item--selected',
treeItemDisabled: 'sb-tree-custom-item--disabled',
treeItemTrackline: 'sb-tree-custom-item__trackline',
treeItemEmoji: 'sb-tree-custom-item__emoji',
treeItemLabel: 'sb-tree-custom-item__label',
treeItemToggle: 'sb-tree-custom-item__toggle',
treeItemToggleIcon: 'sb-tree-custom-item__toggle-icon',
treeItemToggleIconExpanded: 'sb-tree-custom-item__toggle-icon--expanded',
} as const;
const customNodeStyles = `
.${customNodeClass.treeItem} {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
box-sizing: border-box;
width: 100%;
height: var(--tree-item-height);
position: relative;
padding-block: var(--tree-item-padding-block);
padding-inline: var(--tree-item-padding-inline);
padding-left: calc(
var(--tree-item-padding-inline) + var(--tree-indent-unit) * var(--tree-indent, 0)
);
border-radius: var(--radius--lg);
cursor: pointer;
user-select: none;
color: var(--color--text--shade-1);
background-color: light-dark(var(--color--neutral-100), var(--color--neutral-800));
transition:
background-color 0.15s ease,
box-shadow 0.15s ease;
}
.${customNodeClass.treeItem}[data-has-toggle] {
padding-right: calc(var(--tree-item-padding-inline) * 2);
}
.${customNodeClass.treeItem}:hover:not([data-disabled]) {
background-color: light-dark(var(--color--neutral-200), var(--color--neutral-700));
}
.${customNodeClass.treeItem}:active:not([data-disabled]) {
box-shadow: inset 0 0 0 1px var(--border-color--subtle);
}
[role='treeitem']:focus-visible .${customNodeClass.treeItem} {
outline: var(--focus--border-width) solid var(--focus--outline-color);
outline-offset: 0;
}
.${customNodeClass.treeItemSelected} {
color: var(--color--primary);
background-color: light-dark(var(--color--primary--tint-3), var(--color--primary--shade-3));
box-shadow: inset 3px 0 0 var(--color--primary);
}
.${customNodeClass.treeItemSelected}:hover:not([data-disabled]),
.${customNodeClass.treeItemSelected}:active:not([data-disabled]),
[role='treeitem']:focus-visible .${customNodeClass.treeItemSelected} {
background-color: light-dark(var(--color--primary--tint-3), var(--color--primary--shade-3));
}
.${customNodeClass.treeItemSelected} .${customNodeClass.treeItemEmoji} {
background-color: light-dark(var(--color--background--light-1), var(--color--background--dark-1));
}
.${customNodeClass.treeItemDisabled} {
cursor: not-allowed;
opacity: 0.5;
}
.${customNodeClass.treeItemTrackline} {
position: absolute;
top: 0;
bottom: 0;
left: calc(
var(--tree-item-padding-inline) + var(--tree-indent-unit) * var(--indent) +
var(--tree-icon-size) / 2
);
z-index: 1;
width: 1px;
background-color: var(--border-color--subtle);
pointer-events: none;
}
.${customNodeClass.treeItemEmoji} {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--tree-icon-size);
height: var(--tree-icon-size);
border-radius: var(--radius--full);
font-size: var(--font-size--sm);
line-height: 1;
background-color: light-dark(var(--color--background--light-1), var(--color--background--dark-1));
}
.${customNodeClass.treeItemToggle} {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin-left: auto;
border: none;
background: transparent;
color: var(--color--text--tint-1);
cursor: pointer;
}
.${customNodeClass.treeItemToggle}:disabled {
cursor: not-allowed;
}
.${customNodeClass.treeItemToggleIcon} {
font-size: var(--font-size--2xs);
line-height: 1;
transition: transform var(--duration--snappy) var(--easing--ease-out);
}
@media (prefers-reduced-motion: reduce) {
.${customNodeClass.treeItemToggleIcon} {
transition: none;
}
}
.${customNodeClass.treeItemToggleIconExpanded} {
transform: rotate(90deg);
}
.${customNodeClass.treeItemLabel} {
flex: 1;
min-width: 0;
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--medium);
line-height: var(--line-height--md);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
let customNodeStylesInjected = false;
function ensureCustomNodeStyles() {
if (customNodeStylesInjected || typeof document === 'undefined') {
return;
}
const style = document.createElement('style');
style.textContent = customNodeStyles;
document.head.appendChild(style);
customNodeStylesInjected = true;
}
const TreeNodeCustom = defineComponent({
name: 'TreeNodeCustom',
props: {
label: { type: String, required: true },
emoji: { type: String, required: false },
disabled: { type: Boolean, default: false },
showExpandArrow: { type: Boolean, default: true },
isExpanded: { type: Boolean, required: true },
isSelected: { type: Boolean, required: true },
hasChildren: { type: Boolean, required: true },
indentLevel: { type: Number, default: 0 },
handleToggle: { type: Function as PropType<() => void>, required: true },
handleSelect: { type: Function as PropType<() => void>, required: true },
},
setup() {
ensureCustomNodeStyles();
return { customNodeClass };
},
template: `
<div
:class="[
customNodeClass.treeItem,
{
[customNodeClass.treeItemSelected]: isSelected,
[customNodeClass.treeItemDisabled]: disabled,
},
]"
:style="{ '--tree-indent': indentLevel }"
:data-disabled="disabled ? '' : undefined"
:data-has-toggle="showExpandArrow && hasChildren ? '' : undefined"
@click="!disabled && handleSelect()"
>
<template v-if="indentLevel > 0">
<div
v-for="n in indentLevel"
:key="n"
:class="customNodeClass.treeItemTrackline"
:style="{ '--indent': n - 1 }"
aria-hidden="true"
/>
</template>
<span v-if="emoji" :class="customNodeClass.treeItemEmoji" aria-hidden="true">
{{ emoji }}
</span>
<span
:class="customNodeClass.treeItemLabel"
:aria-disabled="disabled ? 'true' : undefined"
>
{{ label }}
</span>
<button
v-if="showExpandArrow && hasChildren"
type="button"
:class="customNodeClass.treeItemToggle"
:aria-label="isExpanded ? \`Collapse \${label}\` : \`Expand \${label}\`"
:aria-expanded="isExpanded"
:disabled="disabled"
@click.stop="handleToggle()"
>
<span
:class="[
customNodeClass.treeItemToggleIcon,
{ [customNodeClass.treeItemToggleIconExpanded]: isExpanded },
]"
aria-hidden="true"
>
</span>
</button>
</div>
`,
});
export const CustomNode: Story = {
render: (args) => ({
components: { Tree, TreeNodeCustom },
setup() {
const selected = ref<string[]>(['paris']);
const expanded = ref<string[]>([...customNodeDefaultExpanded]);
return {
args,
selected,
expanded,
customNodeItems,
getCustomNodeProps,
TreeNodeCustom,
};
},
template: `
<div style="width: 320px; height: 480px;">
<Tree
v-bind="args"
:items="customNodeItems"
:node="TreeNodeCustom"
:get-node-props="getCustomNodeProps"
v-model="selected"
v-model:expanded="expanded"
/>
</div>
`,
}),
args: {
items: customNodeItems,
disabled: false,
multiple: false,
showExpandArrow: true,
},
};

View File

@ -0,0 +1,265 @@
import userEvent from '@testing-library/user-event';
import { render, waitFor } from '@testing-library/vue';
import { defineComponent } from 'vue';
import type { TreeBranch, TreeProps } from './Tree.types';
import Tree from './Tree.vue';
const treeItems: TreeBranch[] = [
{
id: 'parent',
label: 'Parent',
icon: 'folder',
children: [
{ id: 'child', label: 'Child', icon: 'file-text' },
{ id: 'sibling', label: 'Sibling', icon: 'file-text' },
],
},
{
id: 'leaf',
label: 'Leaf',
icon: 'variable',
},
];
const defaultExpanded = ['parent'];
type TreeRenderOptions = {
props?: Partial<TreeProps>;
slots?: Record<string, string>;
attrs?: Record<string, unknown>;
};
function renderTree(options: TreeRenderOptions = {}) {
const { props: overrideProps, ...rest } = options;
return render(Tree, {
...rest,
props: {
items: treeItems,
defaultExpanded,
...overrideProps,
},
});
}
function getRowByLabel(wrapper: ReturnType<typeof renderTree>, label: string) {
return wrapper.getByText(label).closest('[data-test-id="tree-node"]');
}
interface CustomItem extends TreeBranch {
uuid: string;
nodes?: CustomItem[];
}
const customItems: CustomItem[] = [
{
id: 'parent',
uuid: 'parent-uuid',
label: 'Custom parent',
nodes: [{ id: 'child', uuid: 'child-uuid', label: 'Custom child' }],
},
];
function renderCustomTree(props?: Partial<TreeProps>) {
return render(Tree, {
props: {
items: customItems,
defaultExpanded: ['parent-uuid'],
getKey: (item) => (item as CustomItem).uuid,
getChildren: (item) => (item as CustomItem).nodes,
...props,
},
});
}
const CustomNode = defineComponent({
props: {
label: { type: String, required: true },
},
template: '<span data-test-id="custom-node">{{ label }}</span>',
});
describe('v2/components/Tree', () => {
describe('rendering', () => {
it('should render with default data-test-id on root', () => {
const wrapper = renderTree();
expect(wrapper.getByTestId('tree')).toBeInTheDocument();
});
it('should allow overriding root data-test-id', () => {
const wrapper = renderTree({
attrs: { 'data-test-id': 'sidebar-tree' },
});
expect(wrapper.getByTestId('sidebar-tree')).toBeInTheDocument();
});
it('should forward non-class attributes to root', () => {
const wrapper = renderTree({
attrs: { 'aria-label': 'Navigation tree' },
});
expect(wrapper.getByTestId('tree')).toHaveAttribute('aria-label', 'Navigation tree');
});
it('should propagate disabled state to rows', () => {
const wrapper = renderTree({
props: { disabled: true },
});
for (const node of wrapper.getAllByTestId('tree-node')) {
expect(node).toHaveAttribute('data-disabled');
}
});
});
describe('v-model', () => {
it('should emit selected item keys on row click', async () => {
const wrapper = renderTree();
await userEvent.click(wrapper.getByText('Child'));
await waitFor(() => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['child']]);
});
});
it('should reflect controlled modelValue keys as selected rows', () => {
const wrapper = renderTree({
props: { modelValue: ['child'] },
});
expect(getRowByLabel(wrapper, 'Child')).toHaveAttribute('data-selected');
});
it('should support multiple selection with key arrays', async () => {
const wrapper = renderTree({
props: {
multiple: true,
modelValue: ['child'],
},
});
await userEvent.click(wrapper.getByText('Sibling'));
await waitFor(() => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['child', 'sibling']]);
});
});
});
describe('defaultValue', () => {
it('should use defaultValue keys for initial selection', () => {
const wrapper = renderTree({
props: { defaultValue: ['child'] },
});
expect(getRowByLabel(wrapper, 'Child')).toHaveAttribute('data-selected');
});
it('should emit keys when toggling from defaultValue', async () => {
const wrapper = renderTree({
props: { defaultValue: ['child'] },
});
await userEvent.click(wrapper.getByText('Sibling'));
await waitFor(() => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['sibling']]);
});
});
});
describe('custom data accessors', () => {
it('should resolve keys and children from custom accessors', async () => {
const wrapper = renderCustomTree();
expect(wrapper.getByText('Custom child')).toBeInTheDocument();
await userEvent.click(wrapper.getByText('Custom child'));
await waitFor(() => {
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['child-uuid']]);
});
});
});
describe('slots', () => {
test.each([
['icon', '<span data-test-id="custom-icon">icon</span>', 'custom-icon'],
['label', '<span data-test-id="custom-label">label</span>', 'custom-label'],
[
'toggle',
'<button type="button" data-test-id="custom-toggle">toggle</button>',
'custom-toggle',
],
] as const)('should render %s slot content', (slotName, slotContent, testId) => {
const wrapper = renderTree({
slots: { [slotName]: slotContent },
});
expect(wrapper.getAllByTestId(testId).length).toBeGreaterThan(0);
});
it('should render default slot content instead of default row', () => {
const wrapper = renderTree({
slots: {
default: '<div data-test-id="custom-row">custom row</div>',
},
});
expect(wrapper.getAllByTestId('custom-row').length).toBeGreaterThan(0);
expect(wrapper.queryByTestId('tree-node-label')).not.toBeInTheDocument();
});
it('should pass label to label slot scope', () => {
const wrapper = renderTree({
slots: {
label:
'<template #label="{ label }"><span data-test-id="scoped-label">{{ label }}</span></template>',
},
});
const childLabel = wrapper
.getAllByTestId('scoped-label')
.find((element) => element.textContent === 'Child');
expect(childLabel).toBeInTheDocument();
});
});
describe('custom node', () => {
it('should render custom node component with item props', () => {
const wrapper = renderTree({
props: { node: CustomNode },
});
expect(
wrapper.getAllByTestId('custom-node').find((node) => node.textContent === 'Child'),
).toHaveTextContent('Child');
});
it('should map getNodeProps to custom node props', () => {
const wrapper = renderTree({
props: {
node: CustomNode,
getNodeProps: () => ({ label: 'Mapped label' }),
},
});
expect(wrapper.getAllByText('Mapped label').length).toBeGreaterThan(0);
});
});
describe('virtualized', () => {
it('should mount TreeVirtualizer when virtualized', () => {
const wrapper = renderTree({
props: { virtualized: true },
});
expect(wrapper.container.querySelector('[data-reka-virtualizer]')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,94 @@
import type { FlattenedItem, TreeRootEmits, TreeRootProps } from 'reka-ui';
import type { Component } from 'vue';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
export type TreeItem = {
id: string;
label: string;
icon?: IconName;
};
export interface TreeBranch extends TreeItem {
children?: TreeBranch[];
}
export type TreeNodeContext<T extends TreeItem = TreeBranch> = {
item: FlattenedItem<T>;
handleToggle: () => void;
handleSelect: () => void;
isExpanded: boolean;
hasChildren: boolean;
};
export type TreeDefaultNodeProps = {
label: string;
};
export type TreeGetNodeProps<
T extends TreeItem = TreeBranch,
P extends Record<string, unknown> = TreeDefaultNodeProps,
> = (context: TreeNodeContext<T>) => P;
export type TreeProps = Omit<
TreeRootProps<TreeBranch, TreeBranch, boolean>,
'asChild' | 'as' | 'getKey' | 'getChildren' | 'modelValue' | 'defaultValue'
> & {
items: TreeBranch[];
getKey?: (item: TreeBranch) => string;
getChildren?: (item: TreeBranch) => TreeBranch[] | undefined;
modelValue?: string[];
defaultValue?: string[];
showExpandArrow?: boolean;
virtualized?: boolean;
estimateSize?: number;
overscan?: number;
textContent?: (item: TreeBranch) => string;
node?: Component;
getNodeProps?: TreeGetNodeProps;
};
export type TreeEmits = Omit<TreeRootEmits<TreeBranch, boolean>, 'update:modelValue'> & {
'update:modelValue': [value: string[]];
};
export type TreeDefaultSlotProps = TreeNodeContext;
type TreeNodeDefaultIconSlotProps = {
icon?: IconName;
disabled?: boolean;
isSelected: boolean;
ui: { class: string };
};
type TreeNodeDefaultLabelSlotProps = {
label: string;
disabled?: boolean;
handleSelect: () => void;
ui: { class: string };
};
type TreeNodeDefaultToggleSlotProps = {
label: string;
disabled?: boolean;
isExpanded: boolean;
handleToggle: () => void;
ui: {
class: string;
iconClass: string;
iconExpandedClass: string;
};
};
export type TreeNodeDefaultSlots = {
icon?: (props: TreeNodeDefaultIconSlotProps) => unknown;
label?: (props: TreeNodeDefaultLabelSlotProps) => unknown;
toggle?: (props: TreeNodeDefaultToggleSlotProps) => unknown;
};
export type TreeSlots = {
default?: (props: TreeDefaultSlotProps) => unknown;
icon?: (props: TreeNodeDefaultIconSlotProps) => unknown;
label?: (props: TreeNodeDefaultLabelSlotProps) => unknown;
toggle?: (props: TreeNodeDefaultToggleSlotProps) => unknown;
};

View File

@ -0,0 +1,7 @@
.root {
--tree-item-height: var(--spacing--xl);
--tree-icon-size: var(--spacing--lg);
--tree-item-padding-block: var(--spacing--4xs);
--tree-item-padding-inline: var(--tree-item-padding-block);
--tree-indent-unit: calc(var(--tree-item-padding-inline) + var(--tree-icon-size));
}

View File

@ -0,0 +1,260 @@
<script setup lang="ts">
import { reactiveOmit, reactivePick } from '@vueuse/core';
import { TreeRoot, TreeVirtualizer, useForwardPropsEmits } from 'reka-ui';
import type { FlattenedItem } from 'reka-ui';
import { computed, useAttrs, useCssModule } from 'vue';
import type {
TreeBranch,
TreeDefaultNodeProps,
TreeDefaultSlotProps,
TreeEmits,
TreeProps,
TreeSlots,
} from './Tree.types';
import treeVariables from './Tree.variables.module.css';
import TreeNode from './TreeNode.vue';
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const rootClass = computed(() => attrs.class);
const rootAttrs = computed(() => reactiveOmit(attrs, ['class']));
const $style = useCssModule();
const props = withDefaults(defineProps<TreeProps>(), {
disabled: false,
multiple: false,
showExpandArrow: true,
virtualized: false,
estimateSize: 32,
});
const emit = defineEmits<TreeEmits>();
defineSlots<TreeSlots>();
function resolvedGetKey(item: TreeBranch) {
if (props.getKey) {
return props.getKey(item);
}
return item.id;
}
function resolvedGetChildren(item: TreeBranch) {
if (props.getChildren) {
return props.getChildren(item);
}
return item.children;
}
function findItemsByKeys(items: TreeBranch[], keys: readonly string[]): TreeBranch[] {
if (keys.length === 0) return [];
const keySet = new Set(keys);
const result: TreeBranch[] = [];
function walk(nodes: readonly TreeBranch[]) {
for (const node of nodes) {
const key = resolvedGetKey(node);
if (keySet.has(key)) {
result.push(node);
}
const children = resolvedGetChildren(node);
if (children?.length) {
walk(children);
}
}
}
walk(items);
return result;
}
function mapKeysToItems(keys: readonly string[]) {
const matchedItems = findItemsByKeys(props.items, keys);
return props.multiple ? matchedItems : matchedItems[0];
}
const rekaModelValue = computed(() => {
if (props.modelValue === undefined) {
return undefined;
}
return mapKeysToItems(props.modelValue);
});
const rekaDefaultValue = computed(() => {
if (props.defaultValue === undefined) {
return undefined;
}
return mapKeysToItems(props.defaultValue);
});
const rootProps = useForwardPropsEmits(
reactivePick(props, 'disabled', 'multiple', 'expanded', 'defaultExpanded'),
(event, ...args) => {
if (event === 'update:modelValue') {
return;
}
emit(event as 'update:expanded', ...(args as TreeEmits['update:expanded']));
},
);
function handleModelValueUpdate(value: TreeBranch | TreeBranch[] | undefined) {
const selectedItems = value === undefined ? [] : Array.isArray(value) ? value : [value];
emit(
'update:modelValue',
selectedItems.map((item) => resolvedGetKey(item)),
);
}
function getDefaultNodeProps(context: TreeDefaultSlotProps): TreeDefaultNodeProps {
const item = context.item.value;
if ('label' in item && typeof item.label === 'string') {
return { label: item.label };
}
return { label: resolvedGetKey(item) };
}
function resolvedGetNodeProps(context: TreeDefaultSlotProps): TreeDefaultNodeProps {
return (props.getNodeProps ?? getDefaultNodeProps)(context);
}
function getSharedTreeNodeProps() {
return {
disabled: props.disabled,
showExpandArrow: props.showExpandArrow,
getNodeProps: resolvedGetNodeProps,
node: props.node,
};
}
function defaultTextContent(item: TreeBranch): string {
if ('label' in item && typeof item.label === 'string') {
return item.label;
}
return resolvedGetKey(item);
}
function resolvedTextContent(item: TreeBranch): string {
return (props.textContent ?? defaultTextContent)(item);
}
function isTreeBranch(value: unknown): value is TreeBranch {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'label' in value &&
typeof value.label === 'string'
);
}
function virtualTextContent(item: Record<string, unknown>): string {
if (!isTreeBranch(item)) {
return '';
}
return resolvedTextContent(item);
}
function mapVirtualFlattenedItem(
item: FlattenedItem<Record<string, unknown>>,
): FlattenedItem<TreeBranch> {
if (!isTreeBranch(item.value)) {
throw new Error('Tree received an invalid virtualized item');
}
const parentItem = item.parentItem && isTreeBranch(item.parentItem) ? item.parentItem : undefined;
return {
...item,
value: item.value,
parentItem,
bind: {
...item.bind,
value: item.value,
},
};
}
</script>
<template>
<TreeRoot
v-slot="{ flattenItems }"
data-test-id="tree"
v-bind="{ ...rootProps, ...rootAttrs }"
:class="[treeVariables.root, rootClass, { [$style.virtualized]: virtualized }]"
:items="items"
:get-key="resolvedGetKey"
:get-children="resolvedGetChildren"
:model-value="rekaModelValue"
:default-value="rekaDefaultValue"
@update:model-value="handleModelValueUpdate"
>
<TreeVirtualizer
v-if="virtualized"
:estimate-size="estimateSize"
:overscan="overscan"
:text-content="virtualTextContent"
v-slot="{ item: flattenedItem }"
>
<TreeNode
:key="flattenedItem._id"
:flattened-item="mapVirtualFlattenedItem(flattenedItem)"
v-bind="getSharedTreeNodeProps()"
>
<template v-if="$slots.default" #default="slotProps">
<slot v-bind="slotProps" />
</template>
<template v-if="$slots.icon" #icon="iconProps">
<slot name="icon" v-bind="iconProps" />
</template>
<template v-if="$slots.label" #label="labelProps">
<slot name="label" v-bind="labelProps" />
</template>
<template v-if="$slots.toggle" #toggle="toggleProps">
<slot name="toggle" v-bind="toggleProps" />
</template>
</TreeNode>
</TreeVirtualizer>
<template v-else>
<TreeNode
v-for="flattenedItem in flattenItems"
:key="flattenedItem._id"
:flattened-item="flattenedItem"
v-bind="getSharedTreeNodeProps()"
>
<template v-if="$slots.default" #default="slotProps">
<slot v-bind="slotProps" />
</template>
<template v-if="$slots.icon" #icon="iconProps">
<slot name="icon" v-bind="iconProps" />
</template>
<template v-if="$slots.label" #label="labelProps">
<slot name="label" v-bind="labelProps" />
</template>
<template v-if="$slots.toggle" #toggle="toggleProps">
<slot name="toggle" v-bind="toggleProps" />
</template>
</TreeNode>
</template>
</TreeRoot>
</template>
<style module>
.virtualized {
overflow: auto;
height: 100%;
}
</style>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { TreeItem as RekaTreeItem } from 'reka-ui';
import type { Component } from 'vue';
import { computed } from 'vue';
import type { TreeDefaultSlotProps, TreeGetNodeProps, TreeNodeDefaultSlots } from './Tree.types';
import TreeNodeDefault from './TreeNodeDefault.vue';
const props = withDefaults(
defineProps<{
flattenedItem: TreeDefaultSlotProps['item'];
disabled?: boolean;
showExpandArrow?: boolean;
node?: Component;
getNodeProps: TreeGetNodeProps;
}>(),
{
showExpandArrow: true,
},
);
const slots = defineSlots<
TreeNodeDefaultSlots & {
default?: (props: TreeDefaultSlotProps) => unknown;
}
>();
const isItemDisabled = computed(() => {
const item = props.flattenedItem.value;
return props.disabled || (item && 'disabled' in item && !!item.disabled);
});
const indentLevel = computed(() => Math.max(props.flattenedItem.level - 1, 0));
function buildNodeContext(
handleToggle: () => void,
handleSelect: () => void,
isExpanded: boolean,
): TreeDefaultSlotProps {
return {
item: props.flattenedItem,
handleToggle,
handleSelect,
isExpanded,
hasChildren: props.flattenedItem.hasChildren,
};
}
function getDefaultNodeBindings(
handleToggle: () => void,
handleSelect: () => void,
isExpanded: boolean,
isSelected: boolean,
) {
const context = buildNodeContext(handleToggle, handleSelect, isExpanded);
const resolved = props.getNodeProps(context);
return {
label: typeof resolved.label === 'string' ? resolved.label : '',
icon: props.flattenedItem.value.icon,
disabled: isItemDisabled.value,
isSelected,
isExpanded,
hasChildren: props.flattenedItem.hasChildren,
showExpandArrow: props.showExpandArrow,
indentLevel: indentLevel.value,
handleToggle,
handleSelect,
};
}
function getCustomNodeProps(
handleToggle: () => void,
handleSelect: () => void,
isExpanded: boolean,
isSelected: boolean,
) {
const context = buildNodeContext(handleToggle, handleSelect, isExpanded);
return {
...context,
...props.getNodeProps(context),
disabled: isItemDisabled.value,
isSelected,
showExpandArrow: props.showExpandArrow,
indentLevel: indentLevel.value,
};
}
</script>
<template>
<RekaTreeItem
as-child
v-bind="flattenedItem.bind"
:disabled="isItemDisabled"
v-slot="{ handleToggle, handleSelect, isExpanded, isSelected }"
>
<slot v-bind="buildNodeContext(handleToggle, handleSelect, isExpanded)">
<component
v-if="node"
:is="node"
v-bind="getCustomNodeProps(handleToggle, handleSelect, isExpanded, isSelected)"
/>
<TreeNodeDefault
v-else
v-bind="getDefaultNodeBindings(handleToggle, handleSelect, isExpanded, isSelected)"
:data-disabled="isItemDisabled ? '' : undefined"
:data-selected="isSelected ? '' : undefined"
>
<template v-if="slots.icon" #icon="iconProps">
<slot name="icon" v-bind="iconProps" />
</template>
<template v-if="slots.label" #label="labelProps">
<slot name="label" v-bind="labelProps" />
</template>
<template v-if="slots.toggle" #toggle="toggleProps">
<slot name="toggle" v-bind="toggleProps" />
</template>
</TreeNodeDefault>
</slot>
</RekaTreeItem>
</template>

View File

@ -0,0 +1,270 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import treeVariables from './Tree.variables.module.css';
import TreeNodeDefault from './TreeNodeDefault.vue';
import Checkbox from '../Checkbox/Checkbox.vue';
const noop = () => undefined;
const longLabel = 'Quarterly automation rollout for customer onboarding and billing reconciliation';
type VariantProps = {
label: string;
icon?: IconName;
disabled?: boolean;
showExpandArrow?: boolean;
isExpanded: boolean;
isSelected: boolean;
hasChildren: boolean;
};
const variants: Array<{ name: string; props: VariantProps }> = [
{
name: 'Default',
props: {
label: 'Workflows',
icon: 'bolt-filled',
isExpanded: false,
isSelected: false,
hasChildren: false,
},
},
{
name: 'Selected',
props: {
label: 'All workflows',
icon: 'list-tree',
isExpanded: false,
isSelected: true,
hasChildren: false,
},
},
{
name: 'Disabled',
props: {
label: 'Credentials',
icon: 'lock',
isExpanded: false,
isSelected: true,
hasChildren: false,
disabled: true,
},
},
{
name: 'Branch (collapsed)',
props: {
label: 'Executions',
icon: 'list',
isExpanded: false,
isSelected: false,
hasChildren: true,
},
},
{
name: 'Branch (expanded)',
props: {
label: 'Executions',
icon: 'list',
isExpanded: true,
isSelected: false,
hasChildren: true,
},
},
{
name: 'Without expand arrow',
props: {
label: 'Settings',
icon: 'settings',
isExpanded: false,
isSelected: false,
hasChildren: true,
showExpandArrow: false,
},
},
{
name: 'Long label',
props: {
label: longLabel,
icon: 'list-tree',
isExpanded: false,
isSelected: false,
hasChildren: true,
},
},
{
name: 'Without icon',
props: {
label: 'Variables',
isExpanded: false,
isSelected: false,
hasChildren: false,
},
},
];
const meta = {
title: 'Experimental/Tree/TreeNodeDefault',
component: TreeNodeDefault,
tags: ['autodocs'],
parameters: {
docs: {
source: { type: 'dynamic' },
},
},
} satisfies Meta<typeof TreeNodeDefault>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Workflows',
icon: 'bolt-filled',
isExpanded: false,
isSelected: false,
hasChildren: false,
handleToggle: noop,
handleSelect: noop,
},
render: (args) => ({
components: { TreeNodeDefault },
setup() {
return { args, treeVariables, noop };
},
template: `
<ul
role="tree"
:class="treeVariables.root"
style="width: 320px; list-style: none; padding: 0; margin: 0;"
>
<TreeNodeDefault
v-bind="args"
tabindex="0"
:handle-toggle="noop"
:handle-select="noop"
/>
</ul>
`,
}),
};
export const Variants: Story = {
render: () => ({
components: { TreeNodeDefault, Checkbox },
setup() {
const checkboxSelected = ref(false);
const checkboxDisabledSelected = ref(true);
function toggleCheckboxSelected() {
checkboxSelected.value = !checkboxSelected.value;
}
return {
variants,
treeVariables,
noop,
checkboxSelected,
checkboxDisabledSelected,
toggleCheckboxSelected,
};
},
template: `
<div style="display: flex; flex-direction: column; gap: var(--spacing--sm);">
<div
v-for="variant in variants"
:key="variant.name"
style="display: flex; flex-direction: column; gap: var(--spacing--4xs);"
>
<span style="font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
{{ variant.name }}
</span>
<ul
role="tree"
:class="treeVariables.root"
style="width: 320px; list-style: none; padding: 0; margin: 0;"
>
<TreeNodeDefault
v-bind="variant.props"
tabindex="0"
:handle-toggle="noop"
:handle-select="noop"
/>
</ul>
</div>
<div style="display: flex; flex-direction: column; gap: var(--spacing--4xs);">
<span style="font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
Checkbox icon (interactive)
</span>
<ul
role="tree"
:class="treeVariables.root"
style="width: 320px; list-style: none; padding: 0; margin: 0;"
>
<TreeNodeDefault
label="Workflows"
tabindex="0"
:is-expanded="false"
:is-selected="checkboxSelected"
:has-children="true"
:handle-toggle="noop"
:handle-select="toggleCheckboxSelected"
>
<template #icon="{ disabled, ui }">
<span v-bind="ui">
<Checkbox
:model-value="checkboxSelected"
:disabled="disabled"
@click.stop
@update:model-value="toggleCheckboxSelected"
/>
</span>
</template>
</TreeNodeDefault>
</ul>
</div>
<div style="display: flex; flex-direction: column; gap: var(--spacing--4xs);">
<span style="font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
Checkbox icon (disabled)
</span>
<ul
role="tree"
:class="treeVariables.root"
style="width: 320px; list-style: none; padding: 0; margin: 0;"
>
<TreeNodeDefault
label="Credentials"
tabindex="0"
disabled
:is-expanded="false"
:is-selected="checkboxDisabledSelected"
:has-children="false"
:handle-toggle="noop"
:handle-select="noop"
>
<template #icon="{ disabled, ui }">
<span v-bind="ui">
<Checkbox :model-value="checkboxDisabledSelected" :disabled="disabled" />
</span>
</template>
</TreeNodeDefault>
</ul>
</div>
</div>
`,
}),
args: {
label: 'Workflows',
icon: 'bolt-filled',
isExpanded: false,
isSelected: false,
hasChildren: false,
handleToggle: noop,
handleSelect: noop,
},
};

View File

@ -0,0 +1,245 @@
<script setup lang="ts">
import { useCssModule } from 'vue';
import Icon from '@n8n/design-system/components/N8nIcon/Icon.vue';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { TreeNodeDefaultSlots } from './Tree.types';
import treeVariables from './Tree.variables.module.css';
const $style = useCssModule();
withDefaults(
defineProps<{
label: string;
icon?: IconName;
disabled?: boolean;
showExpandArrow?: boolean;
isExpanded: boolean;
isSelected: boolean;
hasChildren: boolean;
indentLevel?: number;
handleToggle: () => void;
handleSelect: () => void;
}>(),
{
showExpandArrow: true,
indentLevel: 0,
},
);
defineSlots<TreeNodeDefaultSlots>();
</script>
<template>
<li
data-test-id="tree-node"
:class="[
treeVariables.root,
$style.treeItem,
{
[$style.treeItemSelected]: isSelected,
[$style.treeItemDisabled]: disabled,
},
]"
:style="{ '--tree-indent': indentLevel }"
:data-has-toggle="showExpandArrow && hasChildren ? '' : undefined"
>
<template v-if="indentLevel > 0">
<div
v-for="n in indentLevel"
:key="n"
:class="$style.trackline"
:style="{ '--indent': n - 1 }"
aria-hidden="true"
/>
</template>
<slot
name="icon"
:icon="icon"
:disabled="disabled"
:is-selected="isSelected"
:ui="{ class: $style.treeItemIcon }"
>
<span v-if="icon" :class="$style.treeItemIcon" data-test-id="tree-node-icon">
<Icon :icon="icon" size="small" />
</span>
</slot>
<slot
name="label"
:label="label"
:disabled="disabled"
:handle-select="handleSelect"
:ui="{ class: $style.treeItemLabel }"
>
<span
:class="$style.treeItemLabel"
data-test-id="tree-node-label"
:aria-disabled="disabled ? 'true' : undefined"
>
{{ label }}
</span>
</slot>
<slot
v-if="showExpandArrow && hasChildren"
name="toggle"
:label="label"
:disabled="disabled"
:is-expanded="isExpanded"
:handle-toggle="handleToggle"
:ui="{
class: $style.treeItemToggle,
iconClass: $style.treeItemToggleIcon,
iconExpandedClass: $style.treeItemToggleIconExpanded,
}"
>
<button
type="button"
tabindex="-1"
:class="$style.treeItemToggle"
:aria-label="isExpanded ? `Collapse ${label}` : `Expand ${label}`"
:aria-expanded="isExpanded"
:disabled="disabled"
data-test-id="tree-node-toggle"
@click.stop="handleToggle()"
>
<Icon
icon="chevron-right"
size="small"
aria-hidden="true"
:class="[$style.treeItemToggleIcon, { [$style.treeItemToggleIconExpanded]: isExpanded }]"
/>
</button>
</slot>
</li>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/focus';
.treeItem {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
width: 100%;
height: var(--tree-item-height);
position: relative;
padding-block: var(--tree-item-padding-block);
padding-left: calc(
var(--tree-item-padding-inline) + var(--tree-indent-unit) * var(--tree-indent, 0)
);
border-radius: var(--radius);
outline: none;
cursor: pointer;
user-select: none;
color: var(--text-color);
background-color: transparent;
&[data-has-toggle] {
padding-right: calc(var(--tree-item-padding-inline) * 2);
}
&:hover:not([data-disabled]) {
background-color: var(--background--hover);
}
&:active:not([data-disabled]) {
background-color: var(--background--active);
}
&:focus-visible:not([data-disabled]) {
background-color: var(--background--hover);
@include focus.focus-ring;
}
&:focus-visible[data-selected]:not([data-disabled]) {
background-color: var(--background--hover);
}
}
.treeItemSelected {
color: var(--button--color--text--highlight-fill--hover-active-focus);
background-color: var(--background--active);
&:hover:not([data-disabled]),
&:active:not([data-disabled]),
&:focus-visible:not([data-disabled]) {
background-color: var(--background--active);
}
}
.treeItemSelected .treeItemIcon {
color: var(--button--color--text--highlight-fill--hover-active-focus);
}
.treeItemDisabled {
cursor: not-allowed;
color: var(--text-color--disabled);
}
.treeItemDisabled .treeItemIcon,
.treeItemDisabled .treeItemToggle {
color: var(--text-color--disabled);
}
.trackline {
position: absolute;
top: 0;
bottom: 0;
left: calc(
var(--tree-item-padding-inline) + var(--tree-indent-unit) * var(--indent) +
var(--tree-icon-size) / 2
);
z-index: 1;
width: 1px;
background-color: var(--border-color--subtle);
pointer-events: none;
}
.treeItemIcon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--tree-icon-size);
height: var(--tree-icon-size);
border-radius: var(--radius--sm);
color: var(--icon-color);
}
.treeItemToggle {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 0;
border: none;
background: none;
color: var(--icon-color);
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
.treeItemToggleIconExpanded {
transform: rotate(90deg);
}
.treeItemLabel {
flex: 1;
min-width: 0;
font-size: var(--font-size--xs);
line-height: var(--line-height--md);
font-weight: var(--font-weight--medium);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,107 +1,260 @@
# Component specification
A generic tree component that displays hierarchical data in a collapsible structure. It supports virtualization for performance with large datasets, provides flexible rendering through slots, and can work with any data type that implements the `TreeItem` interface.
A tree component that displays hierarchical data in a collapsible structure. It wraps [Reka UI Tree](https://reka-ui.com/docs/components/tree) with n8n styling, a default row layout (`TreeNodeDefault`), and slots for customizing icons, labels, and expand toggles. Custom row components can be supplied via the `node` prop.
- **Component Name:** N8nTree
- **Component Name:** N8nTree2
- **Figma Component:** [Tree](https://www.figma.com/design/8zib7Trf2D2CHYXrEGPHkg/n8n-Design-System-V3?m=auto&node-id=2536-2108)
- **Reka UI Component:** [Tree](https://reka-ui.com/docs/components/tree)
## Generic Type Support
## Component structure
The Tree component is generic and can work with any data type that extends the base `TreeItem` interface:
```
Tree (N8nTree2)
└── TreeNode (per flattened item)
├── #default slot → fully custom row
├── custom `node` component (via `node` + `getNodeProps`)
└── TreeNodeDefault (default row: icon, label, expand toggle)
```
## Data model
Items use the `TreeBranch` shape by default. Each node requires `id` and `label`; `icon` and `children` are optional. Individual items may also define `disabled?: boolean`.
```typescript
interface TreeItem {
type TreeItem = {
id: string;
children?: TreeItem[];
label: string;
icon?: IconName;
};
interface TreeBranch extends TreeItem {
children?: TreeBranch[];
}
```
Use `getKey` and `getChildren` when item keys or nesting are not stored on `id` / `children`.
## Public API Definition
**Props**
- `items: T[]` - Tree data structure using generic type T (defaults to IMenuItem)
- `estimateSize?: number` - Estimated height of each tree item (in px) - used for virtualization | `default: 32`
- `getKey?: (item: T) => string` - Optional function to get unique key from each item | `default: item.id`
- `modelValue?: string[]` - The controlled selected values of the Tree. Can be bind as `v-model`
- `expanded?: string[]` - The controlled expanded state of tree items. Can be bind as `v-model:expanded`
- `defaultExpanded?: string[]` - The expanded state when initially rendered
- `multiple?: boolean` - Whether multiple items can be selected
- `disabled?: boolean` - When `true`, prevents user interaction with the Tree
- `items: TreeBranch[]` — Tree data
- `getKey?: (item: TreeBranch) => string` — Unique key per item | `default: item.id`
- `getChildren?: (item: TreeBranch) => TreeBranch[] | undefined` — Child nodes | `default: item.children`
- `modelValue?: string[]` — Controlled selected item keys. Bind with `v-model`
- `defaultValue?: string[]` — Initial selected keys when uncontrolled
- `expanded?: string[]` — Controlled expanded item keys. Bind with `v-model:expanded`
- `defaultExpanded?: string[]` — Initial expanded keys when uncontrolled
- `multiple?: boolean` — Allow selecting multiple items | `default: false`
- `disabled?: boolean` — Disable the entire tree | `default: false`
- `showExpandArrow?: boolean` — Show chevron toggle on expandable rows | `default: true`
- `virtualized?: boolean` — Virtualize the flattened list for large trees | `default: false`
- `estimateSize?: number` — Estimated row height in px when virtualized | `default: 32`
- `overscan?: number` — Number of rows rendered outside the visible area when virtualized
- `textContent?: (item: TreeBranch) => string` — Label text for type-ahead when virtualized | `default: item.label`
- `node?: Component` — Custom Vue component for each row (replaces `TreeNodeDefault`)
- `getNodeProps?: (context: TreeNodeContext) => Record<string, unknown>` — Maps tree context to props for `node`. Defaults to `{ label: item.label }`
**Events**
- `update:modelValue(value: string[])` - Emitted when selection changes
- `update:expanded([val: Record<string, any> | Record<string, any>[]])` - Emitted when expanded items change
- `update:modelValue(value: string[])` Emitted when selection changes
- `update:expanded(value: string[])` — Emitted when expanded items change
**Slots**
- `default`: `{ item: FlattenedItem<T>; handleToggle: () => void; handleSelect: () => void; isExpanded: boolean, hasChildren: boolean }` - Main content for each tree item.
- `empty`: - Slot for empty state.
All slots are forwarded from `Tree``TreeNode``TreeNodeDefault` unless `#default` replaces the entire row.
### Template usage example
- `default`: `TreeNodeContext` — Fully custom row content. Replaces the default node renderer.
- `icon`: `{ icon?: IconName; disabled?: boolean; isSelected: boolean; ui: { class: string } }` — Leading icon area (default: item icon)
- `label`: `{ label: string; disabled?: boolean; handleSelect: () => void; ui: { class: string } }` — Label area
- `toggle`: `{ label: string; disabled?: boolean; isExpanded: boolean; handleToggle: () => void; ui: { class: string; iconClass: string; iconExpandedClass: string } }` — Expand/collapse control (shown when `showExpandArrow` and item has children)
#### Basic Usage
**TreeNodeContext**
```typescript
type TreeNodeContext = {
item: FlattenedItem<TreeBranch>;
handleToggle: () => void;
handleSelect: () => void;
isExpanded: boolean;
hasChildren: boolean;
};
```
### Template usage examples
#### Basic usage
```vue
<script setup lang="ts">
import type { IMenuItem } from '@n8n/design-system/types'
import { ref } from 'vue';
import { N8nTree2 } from '@n8n/design-system';
import type { TreeBranch } from '@n8n/design-system';
const items = ref<IMenuItem[]>([...])
const items = ref<TreeBranch[]>([
{
id: 'workflows',
label: 'Workflows',
icon: 'bolt-filled',
children: [{ id: 'all-workflows', label: 'All workflows', icon: 'list-tree' }],
},
]);
const selected = ref<string[]>([]);
const expanded = ref<string[]>(['workflows']);
</script>
<template>
<N8nTree :items="items" />
<N8nTree2
:items="items"
v-model="selected"
v-model:expanded="expanded"
/>
</template>
```
#### Custom slot and key
#### Uncontrolled
```vue
<N8nTree2
:items="items"
:default-value="['all-workflows']"
:default-expanded="['workflows']"
/>
```
#### Custom key and children
```vue
<script setup lang="ts">
interface CustomItem extends TreeItem {
uuid: string
name: string
children?: CustomItem[]
interface CustomItem {
id: string;
uuid: string;
name: string;
nodes?: CustomItem[];
}
const customItems = ref<CustomItem[]>([
{
id: 'should-not-be-used',
id: 'root',
uuid: 'abc-123',
name: 'Custom Item',
children: [
{
id: 'should-not-be-used',
uuid: 'def-456',
name: 'Child Item'
}
]
}
])
nodes: [{ id: 'child', uuid: 'def-456', name: 'Child Item' }],
},
]);
function getCustomKey(item: CustomItem) {
return item.uuid
return item.uuid;
}
function getCustomChildren(item: CustomItem) {
return item.nodes;
}
</script>
<template>
<N8nTree
<N8nTree2
:items="customItems"
:get-key="getCustomKey"
>
<template #default="{ item, handleToggle, isExpanded, hasChildren }">
<div>
<N8nIconButton v-if="hasChildren" :icon="isExpanded ? 'chevron-down' : 'chevron-right'" @click="handleToggle" />
<span>{{ item.value.name }} ({{ item.value.uuid }})</span>
</div>
</template>
<template #empty>
<p>No custom items to display</p>
</template>
</N8nTree>
:get-children="getCustomChildren"
/>
</template>
```
#### Custom node component
Pass a Vue component to `:node` and map item data with `:get-node-props`. See the `CustomNode` story in Storybook for a full inline example.
```vue
<script setup lang="ts">
import { defineComponent, ref } from 'vue';
import { N8nTree2 } from '@n8n/design-system';
import type { TreeBranch, TreeNodeContext } from '@n8n/design-system';
const CustomNode = defineComponent({
props: {
label: { type: String, required: true },
isExpanded: { type: Boolean, required: true },
isSelected: { type: Boolean, required: true },
hasChildren: { type: Boolean, required: true },
handleToggle: { type: Function, required: true },
handleSelect: { type: Function, required: true },
},
template: `
<button type="button" @click="handleSelect">
{{ label }}
</button>
`,
});
const items = ref<TreeBranch[]>([/* ... */]);
const selected = ref<string[]>([]);
const expanded = ref<string[]>([]);
function getNodeProps({ item }: TreeNodeContext) {
return { label: item.value.label, icon: item.value.icon };
}
</script>
<template>
<N8nTree2
:items="items"
:node="CustomNode"
:get-node-props="getNodeProps"
v-model="selected"
v-model:expanded="expanded"
/>
</template>
```
#### Slot customization (checkbox icon)
Use `#icon` to replace the leading icon. Row selection is handled by the default row click target (`#label` area); keep the checkbox non-interactive or use `#default` for full control.
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { N8nCheckbox, N8nTree2 } from '@n8n/design-system';
const items = ref([/* TreeBranch[] */]);
const selected = ref<string[]>([]);
const expanded = ref<string[]>([]);
</script>
<template>
<N8nTree2
:items="items"
multiple
v-model="selected"
v-model:expanded="expanded"
>
<template #icon="{ isSelected, disabled, ui }">
<span v-bind="ui">
<N8nCheckbox :model-value="isSelected" :disabled="disabled" tabindex="-1" />
</span>
</template>
</N8nTree2>
</template>
```
#### Fully custom row (`#default`)
```vue
<N8nTree2 :items="items">
<template #default="{ item, handleToggle, handleSelect, isExpanded, hasChildren }">
<div>
<button v-if="hasChildren" type="button" @click="handleToggle">
{{ isExpanded ? '' : '+' }}
</button>
<button type="button" @click="handleSelect">
{{ item.value.label }}
</button>
</div>
</template>
</N8nTree2>
```
## Related components
- **TreeNodeDefault** — Default row renderer used inside `Tree`. Documented separately under `Experimental/Tree/TreeNodeDefault` in Storybook.
- **N8nTree** — Legacy tree component in `@n8n/design-system/components`. Use `N8nTree2` for new work.

View File

@ -0,0 +1,2 @@
export { default as N8nTree2 } from './Tree.vue';
export type * from './Tree.types';