Skip to content

Feature/add tree view #3585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 29 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
03167cc
feat: add @zag-js/tree-view to catalog
dev-viinz Jun 25, 2025
8aa1c45
feat: implemented pretty much the example from zag.js
dev-viinz Jun 27, 2025
1563008
fix: types
dev-viinz Jun 28, 2025
db170ea
chore: update zag...
dev-viinz Jun 28, 2025
4bd9f47
feat: working prototype
dev-viinz Jun 30, 2025
ed9ccb0
feat: re-order things for draft
dev-viinz Jul 1, 2025
b97c340
Merge branch 'main' into feature/add-tree-view
dev-viinz Jul 3, 2025
2fa4e9f
feat: consolidate treeview into single component + improve playground
dev-viinz Jul 3, 2025
5650a38
chore: cleanup types + lint
dev-viinz Jul 3, 2025
e910470
fix: mock for resize observer to keep zagjs from throwing during testing
dev-viinz Jul 4, 2025
39ae970
fix: remove unused context
dev-viinz Jul 4, 2025
6850646
test: add first tests to svelte treeview
dev-viinz Jul 5, 2025
dab9c6e
test: add prop tests
dev-viinz Jul 5, 2025
ec4cd52
chore: move resizeobserver mock to vitest config
dev-viinz Jul 7, 2025
02e116e
fix: remove style tag and make custom utility
dev-viinz Jul 7, 2025
e81837b
chore: move chevron placeholder from script to template
dev-viinz Jul 8, 2025
01b0aec
chore: apply suggestions to props
dev-viinz Jul 8, 2025
23e237d
fix: add button type, to prevent form submission
dev-viinz Jul 8, 2025
f2b5076
fix: forgot to adjust test after renaming props
dev-viinz Jul 8, 2025
4956014
chore: update zag
dev-viinz Jul 8, 2025
4c62218
feat: very WIP implementation of template-driven TreeView
dev-viinz Jul 14, 2025
4424d47
feat: first working template driven approach
dev-viinz Jul 18, 2025
16966fe
chore: cleanup debugging mess
dev-viinz Jul 18, 2025
1fd80e1
fix: refactor tests into the new structure
dev-viinz Jul 18, 2025
7fc0a66
chore: lint
dev-viinz Jul 18, 2025
62ab259
chore: multiple support
dev-viinz Jul 18, 2025
0a42145
chore: improve example
dev-viinz Jul 19, 2025
77487e7
feat: add prop for styling selected item
dev-viinz Jul 19, 2025
5c19677
fix: decided against additional prop
dev-viinz Jul 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion packages/skeleton-react/src/components/Segment/Segment.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';

import { Segment } from './Segment.js';

// Segment ---

describe('<Segment>', () => {
beforeEach(() => {
global.ResizeObserver = class MockedResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
};
});

afterEach(() => {
vi.clearAllMocks();
});

it('should render the component', () => {
const { getByTestId } = render(
<Segment name="align" value="0">
Expand Down
3 changes: 2 additions & 1 deletion packages/skeleton-svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
"@zag-js/tabs": "catalog:",
"@zag-js/tags-input": "catalog:",
"@zag-js/toast": "catalog:",
"@zag-js/tooltip": "catalog:"
"@zag-js/tooltip": "catalog:",
"@zag-js/tree-view": "catalog:"
},
"peerDependencies": {
"svelte": "^5.20.0"
Expand Down
185 changes: 185 additions & 0 deletions packages/skeleton-svelte/src/components/TreeView/TreeView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<script lang="ts" module>
import { createRawSnippet } from 'svelte';

const chevron = createRawSnippet(() => {
return {
render: () => /* html */ `
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
<path d="m9 18 6-6-6-6" />
</svg>
`
};
});
</script>

<script lang="ts">
import * as tree from '@zag-js/tree-view';
import { useMachine, normalizeProps } from '@zag-js/svelte';
import type { CollectionNode, TreeViewProps } from './types.js';
import { slide } from 'svelte/transition';

const {
// Animation
animationConfig,
// Data
collection,
// Root
base = 'flex flex-col w-fit',
bg = '',
spaceY = 'space-y-4',
border = ' rounded-base',
padding = 'p-4',
shadow = '',
classes = '',
// Control
controlBase = 'flex gap-2',
controlBg = '',
controlSpaceY = '',
controlHover = 'hover:preset-tonal-primary',
controlBorder = 'rounded-base',
controlPadding = 'p-2',
controlShadow = '',
controlClasses = '',
// Content
contentBase = 'flex gap-1',
contentBg = '',
contentSpaceY = '',
contentBorder = controlBorder,
contentPadding = '',
contentShadow = '',
contentClasses = '',
// Item
itemBase = 'flex gap-2',
itemBg = '',
itemSpaceY = '',
itemHover = controlHover,
itemBorder = contentBorder,
itemPadding = controlPadding,
itemShadow = '',
itemClasses = '',
// Indent
indentAmount = '1.2rem',
// Indicator
indicatorOpenRotation = '90deg',
indicatorTransition = 'transition-transform',
// Snippets
branchIcon,
itemIcon,
branchIndicator,
...zagProps
}: TreeViewProps = $props();

// Zag
const treeCollection = tree.collection<CollectionNode>({
nodeToValue: (node) => node.id,
nodeToString: (node) => node.value,
rootNode: {
id: 'ROOT_NODE',
value: '',
children: collection
}
});
const id = $props.id();
const service = useMachine(tree.machine as tree.Machine<CollectionNode>, () => ({
id: id,
collection: treeCollection,
...zagProps
}));
const api = $derived(tree.connect(service, normalizeProps));
</script>

<!-- @component A collapsible TreeView. -->

<!-- Tree -->
<div class="{base} {bg} {spaceY} {border} {padding} {shadow} {classes}" {...api.getRootProps()} data-testid="tree">
<div {...api.getTreeProps()}>
{#if treeCollection.rootNode.children}
{#each treeCollection.rootNode.children as node, index}
{@render treeNode({ node, indexPath: [index] })}
{/each}
{/if}
</div>
</div>

<!-- Node -->
{#snippet treeNode(nodeProps: tree.NodeProps)}
{#if api.getNodeState(nodeProps).isBranch}
<!-- Branch -->
<div {...api.getBranchProps(nodeProps)} data-testid="tree-branch">
<!-- Control -->
<button
class="{controlBase} {controlBg} {controlSpaceY} {controlHover} {controlBorder} {controlPadding} {controlShadow} {controlClasses}"
{...api.getBranchControlProps(nodeProps)}
data-testid="tree-control"
>
<span
class="flex items-center {indicatorTransition}"
style="--indicator-rotation:{indicatorOpenRotation};"
{...api.getBranchIndicatorProps(nodeProps)}
data-testid="tree-indicator"
>
{#if branchIndicator}
{@render branchIndicator()}
{:else}
{@render chevron()}
{/if}
</span>
{#if branchIcon}
<div data-testid="tree-branch-icon">
{@render branchIcon()}
</div>
{/if}
<span {...api.getBranchTextProps(nodeProps)} data-testid="tree-branch-text">
{nodeProps.node.value}
</span>
</button>

<!-- Content -->
{#if api.expandedValue.includes(nodeProps.node.id)}
<div
class="{contentBase} {contentBg} {contentSpaceY} {contentBorder} {contentPadding} {contentShadow} {contentClasses}"
transition:slide={animationConfig}
{...api.getBranchContentProps(nodeProps)}
data-testid="tree-content"
>
<div {...api.getBranchIndentGuideProps(nodeProps)} style="--indent-factor:{indentAmount}"></div>
<div class="flex flex-col">
{#if nodeProps.node.children}
{#each nodeProps.node.children as childNode, index}
{@render treeNode({ node: childNode, indexPath: [...nodeProps.indexPath, index] })}
{/each}
{/if}
</div>
</div>
{/if}
</div>
{:else}
<button
class="{itemBase} {itemBg} {itemSpaceY} {itemHover} {itemBorder} {itemPadding} {itemShadow} {itemClasses}"
{...api.getItemProps(nodeProps)}
data-testid="tree-item"
>
{#if itemIcon}
<div data-testid="tree-item-icon">
{@render itemIcon()}
</div>
{/if}
<span {...api.getItemTextProps(nodeProps)} data-testid="tree-item-text">
{nodeProps.node.value}
</span>
</button>
{/if}
{/snippet}

<style>
[data-scope='tree-view'][data-part='branch-indent-guide'] {
width: calc(var(--indent-factor));
}
[data-scope='tree-view'][data-part='branch-indicator'] {
&[data-state='open'] {
transform-box: fill-box;
transform-origin: center;
transform: rotate(var(--indicator-rotation));
}
}
</style>
99 changes: 99 additions & 0 deletions packages/skeleton-svelte/src/components/TreeView/TreeView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/svelte';

import TreeView from './TreeView.svelte';
import type { TreeViewProps } from './types.js';
import { mockSnippet } from '../../internal/test-utils.js';

describe('TreeView', () => {
const testIds = {
root: 'tree',
branch: 'tree-branch',
indicator: 'tree-indicator',
branchIcon: 'tree-branch-icon',
itemIcon: 'tree-item-icon',
branchText: 'tree-branch-text',
itemText: 'tree-item-text',
control: 'tree-control',
content: 'tree-content',
item: 'tree-item'
} as const;
const commonProps: TreeViewProps = {
collection: [
{
id: 'LEVEL_1',
value: 'node_modules',
children: [{ id: 'LEVEL_2.1', value: 'zag-js' }]
}
]
} as const;

it('Renders the component', () => {
render(TreeView, { ...commonProps });
const component = screen.getByTestId(testIds.root);
expect(component).toBeInTheDocument();
});

it(`should render with the branchIndicator snippet`, () => {
const testValue = 'testIndicator';
render(TreeView, { ...commonProps, branchIndicator: mockSnippet(testValue) });
const input = screen.getByTestId(testIds.indicator);
expect(input).toHaveTextContent(testValue);
});

it(`should render with the branchIcon snippet`, () => {
const testValue = 'testIcon';
render(TreeView, { ...commonProps, branchIcon: mockSnippet(testValue) });
const input = screen.getByTestId(testIds.branchIcon);
expect(input).toHaveTextContent(testValue);
});

it(`should render with the value/text of a branch`, () => {
const valueToTest = commonProps.collection[0].value;
render(TreeView, { ...commonProps });
const input = screen.getByTestId(testIds.branchText);
expect(input).toHaveTextContent(valueToTest);
});

it(`should render expanded with the itemIcon snippet`, () => {
const testValue = 'testIcon';
render(TreeView, { ...commonProps, defaultExpandedValue: ['LEVEL_1'], itemIcon: mockSnippet(testValue) });
const input = screen.getByTestId(testIds.itemIcon);
expect(input).toHaveTextContent(testValue);
});

it(`should render expanded with the value/text of an item`, () => {
const nodeToTest = commonProps.collection[0].children?.at(0);
render(TreeView, { ...commonProps, defaultExpandedValue: ['LEVEL_1'] });
const input = screen.getByTestId(testIds.itemText);
expect(nodeToTest).toBeDefined();
expect(input).toHaveTextContent(nodeToTest?.value ?? '');
});

const propMap = [
{ testId: testIds.root, props: ['base', 'bg', 'spaceY', 'border', 'padding', 'shadow', 'classes'] },
{
testId: testIds.control,
props: ['controlBase', 'controlBg', 'controlSpaceY', 'controlBorder', 'controlPadding', 'controlShadow', 'controlClasses']
},
{
testId: testIds.content,
props: ['contentBase', 'contentBg', 'contentSpaceY', 'contentBorder', 'contentPadding', 'contentShadow', 'contentClasses']
},
{
testId: testIds.item,
props: ['itemBase', 'itemBg', 'itemSpaceY', 'itemBorder', 'itemPadding', 'itemShadow', 'itemClasses']
}
];

for (const item of propMap) {
for (const prop of item.props) {
it(`Correctly applies the ${prop} prop`, () => {
const value = 'bg-green-500';
render(TreeView, { ...commonProps, defaultExpandedValue: ['LEVEL_1'], [prop]: value });
const component = screen.getByTestId(item.testId);
expect(component).toHaveClass(value);
});
}
}
});
Loading