mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 19:15:01 +02:00
feat(editor): Add new Tree component to @n8n/design-system (#33291)
This commit is contained in:
parent
62f3c7c3c3
commit
cbbe113d25
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as N8nTree2 } from './Tree.vue';
|
||||
export type * from './Tree.types';
|
||||
Loading…
Reference in New Issue
Block a user