Skip to content

feat: 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

Closed
wants to merge 45 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
04ca353
chore: add missing comments
dev-viinz Aug 1, 2025
58ae163
chore: add changeset
dev-viinz Aug 3, 2025
5cd5c78
chore: merge from main
dev-viinz Aug 4, 2025
f98b741
chore: comments
dev-viinz Aug 4, 2025
be9ea66
feat: move styles from context to props
dev-viinz Aug 4, 2025
6fa2c56
fix: testid
dev-viinz Aug 5, 2025
7b9bed9
chore: isolate component from core library
dev-viinz Aug 5, 2025
959a18e
chore: forgot this
dev-viinz Aug 5, 2025
2983965
fix: item selection
dev-viinz Aug 5, 2025
c6f2663
chore: cleanup types.ts
dev-viinz Aug 5, 2025
0e041c8
Merge branch 'main' into feature/add-tree-view
dev-viinz Aug 5, 2025
fc96a30
fix: label
dev-viinz Aug 5, 2025
3aee224
fix: make the label function as intended
dev-viinz Aug 7, 2025
0910f01
fix: use the correct pettern for dynamic style props
dev-viinz Aug 7, 2025
cd5cb39
fix: lint
dev-viinz Aug 7, 2025
5cce0f4
fix: types
dev-viinz Aug 7, 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
7 changes: 7 additions & 0 deletions .changeset/neat-bushes-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@skeletonlabs/skeleton-svelte": minor
"@skeletonlabs/skeleton-react": minor
---

feat: add new `TreeView` component.

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
114 changes: 114 additions & 0 deletions packages/skeleton-svelte/src/components/TreeView/TreeBranch.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<script lang="ts">
import { getTreeContext } from './context.js';
import TreeNode from './TreeNode.svelte';
import type { TreeBranchProps } from './types.js';

let {
id,
value,
children,
disabled = false,
// Control
base = 'flex gap-2',
background = '',
selected = 'preset-tonal-primary',
spaceY = '',
hover = 'hover:preset-tonal-primary',
border = 'rounded-base',
padding = 'p-2',
shadow = '',
classes = '',
// Content
contentBase = 'flex',
contentBackground = '',
contentSpaceY = '',
contentBorder = border,
contentPadding = '',
contentShadow = '',
contentClasses = '',
// Indent
indentAmount = 'w-6',
// Indicator
indicatorBase = 'inline-flex items-center',
indicatorRotationClass = 'rotate-90',
indicatorTransition = 'transition-transform origin-center transform-fill',
// Snippets
icon,
indicator
}: TreeBranchProps = $props();

const treeContext = getTreeContext();

const rxSelected = $derived([treeContext.api?.selectedValue.includes(id) && selected]);
const rxIndicatorRotation = $derived([treeContext.api?.expandedValue.includes(id) && indicatorRotationClass]);
</script>

<!-- @component A Branch of a TreeView. -->

<TreeNode {id} {value} {disabled}>
{#snippet content({ node: nodeData, nodeProps })}
<!-- Branch -->
<div {...treeContext.api?.getBranchProps(nodeProps)} data-testid="tree-branch">
<!-- Control -->
<button
class="{base} {background} {spaceY} {hover} {border} {padding} {shadow} {classes} {rxSelected}"
{...treeContext.api?.getBranchControlProps(nodeProps)}
data-testid="tree-branch-control"
type="button"
>
<!-- Indicator -->
<span
class="{indicatorBase} {indicatorTransition} {rxIndicatorRotation}"
{...treeContext.api?.getBranchIndicatorProps(nodeProps)}
data-testid="tree-indicator"
>
{#if indicator}
{@render indicator()}
{:else}
{@render chevron()}
{/if}
</span>
<!-- Icon -->
{#if icon}
<div data-testid="tree-branch-icon">
{@render icon()}
</div>
{/if}
<!-- Text -->
<span {...treeContext.api?.getBranchTextProps(nodeProps)} data-testid="tree-branch-text">
{nodeData.value}
</span>
</button>

<!-- Content -->
<div
class="{contentBase} {contentBackground} {contentSpaceY} {contentBorder} {contentPadding} {contentShadow} {contentClasses}"
{...treeContext.api?.getBranchContentProps(nodeProps)}
data-testid="tree-content"
>
<!-- IndentGuide -->
<div {...treeContext.api?.getBranchIndentGuideProps(nodeProps)} class={indentAmount}></div>
<!-- Children -->
<div>
{@render children()}
</div>
</div>
</div>
{/snippet}
</TreeNode>

{#snippet chevron()}
<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>
{/snippet}
54 changes: 54 additions & 0 deletions packages/skeleton-svelte/src/components/TreeView/TreeItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script lang="ts">
import { getTreeContext } from './context.js';
import TreeNode from './TreeNode.svelte';
import type { TreeItemProps } from './types.js';

let {
id,
value,
disabled = false,
// Item
base = 'flex gap-2',
background = '',
selected = 'preset-tonal-primary',
spaceY = '',
hover = 'hover:preset-tonal-primary',
border = 'rounded-base',
padding = 'p-2',
shadow = '',
classes = '',
// Snippets
icon
}: TreeItemProps = $props();

const treeContext = getTreeContext();

const rxSelected = $derived([treeContext.api?.selectedValue.includes(id) && selected]);
</script>

<!-- @component An Item of a TreeView. -->

<TreeNode {id} {value} {disabled}>
{#snippet content({ node, nodeProps })}
{#if node != null}
<!-- Item -->
<button
class="{base} {background} {spaceY} {hover} {border} {padding} {shadow} {classes} {rxSelected}"
{...treeContext.api?.getItemProps(nodeProps)}
data-testid="tree-item"
type="button"
>
<!-- Icon -->
{#if icon}
<div data-testid="tree-item-icon">
{@render icon()}
</div>
{/if}
<!-- Text -->
<span {...treeContext.api?.getItemTextProps(nodeProps)} data-testid="tree-item-text">
{node.value}
</span>
</button>
{/if}
{/snippet}
</TreeNode>
103 changes: 103 additions & 0 deletions packages/skeleton-svelte/src/components/TreeView/TreeNode.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { CollectionNode, NodeContext, TreeNodeProps } from './types.js';
import { getNodeContext, getTreeContext, setNodeContext } from './context.js';

const { id, value, content }: TreeNodeProps = $props();

const treeContext = getTreeContext();
const parentNodeContext = getNodeContext();

let childNodes = $state<CollectionNode[]>([]);
let isRegistered = $state(false);
let currentIndexPath = $state<number[]>([]);

const nodeData: CollectionNode = $derived({
id,
value,
indexPath: currentIndexPath,
children: childNodes
});

const updateSelf = () => {
if (parentNodeContext) {
parentNodeContext.updateChild(nodeData);
} else {
treeContext.updateNode(nodeData);
}
};

const registerChild = (child: CollectionNode) => {
const childIndex = childNodes.length;
const childWithIndex = { ...child, indexPath: [...nodeData.indexPath, childIndex] };
childNodes = [...childNodes, childWithIndex];

updateSelf();
return childWithIndex.indexPath;
};

const unregisterChild = (childId: string) => {
childNodes = childNodes.filter((child) => child.id !== childId);
childNodes = childNodes.map((child, index) => ({
...child,
indexPath: [...nodeData.indexPath, index]
}));

updateSelf();
};

const updateChild = (updatedChild: CollectionNode) => {
const index = childNodes.findIndex((child) => child.id === updatedChild.id);
if (index !== -1) {
childNodes[index] = {
...updatedChild,
indexPath: [...nodeData.indexPath, index]
};
childNodes = [...childNodes]; // Trigger reactivity

// Bubble up the change
updateSelf();
}
};

const nodeContextData: NodeContext = {
get node() {
return nodeData;
},
registerChild,
unregisterChild,
updateChild
};

setNodeContext(nodeContextData);

const nodeProps = $derived({
indexPath: currentIndexPath,
node: nodeData
});

onMount(() => {
if (parentNodeContext) {
// Register with parent node context and get index
currentIndexPath = parentNodeContext.registerChild(nodeData);
} else {
// For root nodes, register with tree context and get index
currentIndexPath = treeContext.registerNode(nodeData);
}
isRegistered = true;

return () => {
if (isRegistered) {
if (parentNodeContext) {
parentNodeContext.unregisterChild(id);
} else {
treeContext.unregisterNode(id);
}
}
};
});
</script>

{#if isRegistered}
{@render content({ node: nodeData, nodeProps })}
{/if}
Loading
Loading