Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 14 additions & 0 deletions .changeset/nine-worms-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@skeletonlabs/skeleton": minor
---

feat: Multiple revisions and updates to the Tree View feature:
- Enhanced and properly named non-recursive tree-view events.
- Separated the recursive tree-view under the new component RecursiveTreeView.
- RecursiveTreeView now utilizes the `relational` prop to enable relational checking.
- RecursiveTreeView is now using ID arrays with 2-way binding to control the tree-view state, including:
- `expandedNodes`
- `disabledNodes`
- `checkedNodes`
- `indeterminateNodes` (has effect only in multiple relational mode)
- TreeViewNode now requires a unique ID to support the new checking system.
111 changes: 111 additions & 0 deletions packages/skeleton/src/lib/components/TreeView/RecursiveTreeView.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script lang="ts">
import { setContext } from 'svelte';

// Types
import type { CssClasses, TreeViewNode } from '../../index.js';
import RecursiveTreeViewItem from './RecursiveTreeViewItem.svelte';

// Props (parent)
/** Enable tree-view selection. */
export let selection = false;
/** Enable selection of multiple items. */
export let multiple = false;
/** Enable relational checking. */
export let relational = false;
/**
* Provide data-driven nodes.
* @type {TreeViewNode[]}
*/
export let nodes: TreeViewNode[] = [];
/**
* provides id's of expanded nodes
* @type {string[]}
*/
export let expandedNodes: string[] = [];
/**
* provides id's of disabled nodes
* @type {string[]}
*/
export let disabledNodes: string[] = [];
/**
* provides id's of checked nodes
* @type {string[]}
*/
export let checkedNodes: string[] = [];
/**
* provides id's of indeterminate nodes
* @type {string[]}
*/
export let indeterminateNodes: string[] = [];
/** Provide classes to set the tree width. */
export let width: CssClasses = 'w-full';
/** Provide classes to set the vertical spacing between items. */
export let spacing: CssClasses = 'space-y-1';

// Props (children)
/** Set open by default on load. */
export let open = false;
/** Set the tree disabled state */
export let disabled = false;
/** Provide classes to set the tree item padding styles. */
export let padding: CssClasses = 'py-4 px-4';
/** Provide classes to set the tree children indentation */
export let indent: CssClasses = 'ml-4';
/** Provide classes to set the tree item hover styles. */
export let hover: CssClasses = 'hover:variant-soft';
/** Provide classes to set the tree item rounded styles. */
export let rounded: CssClasses = 'rounded-container-token';

// Props (symbols)
/** Set the rotation of the item caret in the open state. */
export let caretOpen: CssClasses = 'rotate-180';
/** Set the rotation of the item caret in the closed state. */
export let caretClosed: CssClasses = '';
/* Set the hyphen symbol opacity for non-expandable rows. */
export let hyphenOpacity: CssClasses = 'opacity-10';

// Props (regions)
/** Provide arbitrary classes to the tree item summary region. */
export let regionSummary: CssClasses = '';
/** Provide arbitrary classes to the symbol icon region. */
export let regionSymbol: CssClasses = '';
/** Provide arbitrary classes to the children region. */
export let regionChildren: CssClasses = '';

// Props A11y
/** Provide the ARIA labelledby value. */
export let labelledby = '';

// Context API
setContext('open', open);
setContext('selection', selection);
setContext('multiple', multiple);
setContext('relational', relational);
setContext('disabled', disabled);
setContext('padding', padding);
setContext('indent', indent);
setContext('hover', hover);
setContext('rounded', rounded);
setContext('caretOpen', caretOpen);
setContext('caretClosed', caretClosed);
setContext('hyphenOpacity', hyphenOpacity);
setContext('regionSummary', regionSummary);
setContext('regionSymbol', regionSymbol);
setContext('regionChildren', regionChildren);

// Reactive
$: classesBase = `${width} ${spacing} ${$$props.class ?? ''}`;
</script>

<div
class="tree {classesBase}"
data-testid="tree"
role="tree"
aria-multiselectable={multiple}
aria-label={labelledby}
aria-disabled={disabled}
>
{#if nodes && nodes.length > 0}
<RecursiveTreeViewItem {nodes} bind:expandedNodes bind:disabledNodes bind:checkedNodes bind:indeterminateNodes />
{/if}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<script lang="ts">
import TreeViewItem from './TreeViewItem.svelte';
import RecursiveTreeViewItem from './RecursiveTreeViewItem.svelte';
import type { TreeViewNode } from './types.js';
import { getContext, onMount, tick } from 'svelte';

// this can't be passed using context, since we have to pass it to recursive children.
/** Provide data-driven nodes. */
export let nodes: TreeViewNode[] = [];

/**
* provides id's of expanded nodes
* @type {string[]}
*/
export let expandedNodes: string[] = [];
/**
* provides id's of disabled nodes
* @type {string[]}
*/
export let disabledNodes: string[] = [];
/**
* provides id's of checked nodes
* @type {string[]}
*/
export let checkedNodes: string[] = [];
/**
* provides id's of indeterminate nodes
* @type {string[]}
*/
export let indeterminateNodes: string[] = [];

// Context API
let selection: boolean = getContext('selection');
let multiple: boolean = getContext('multiple');
let relational: boolean = getContext('relational');

let tempCheckedNodes: string[] = [];

// Locals
let group: unknown;
let name = '';

function toggleNode(node: TreeViewNode, open: boolean) {
// toggle only nodes with children
if (!node.children?.length) return;
if (open) {
// node is not registered as opened
if (!expandedNodes.includes(node.id)) {
expandedNodes.push(node.id);
expandedNodes = expandedNodes;
}
} else {
// node is registered as open
if (expandedNodes.includes(node.id)) {
expandedNodes.splice(expandedNodes.indexOf(node.id), 1);
expandedNodes = expandedNodes;
}
}
}

function checkNode(node: TreeViewNode, checked: boolean, indeterminate: boolean) {
if (checked) {
// node is not registered as checked
if (!checkedNodes.includes(node.id)) {
checkedNodes.push(node.id);
checkedNodes = checkedNodes;
}

// node is not indeterminate but registered as indeterminate
if (!indeterminate && indeterminateNodes.includes(node.id)) {
indeterminateNodes.splice(indeterminateNodes.indexOf(node.id), 1);
indeterminateNodes = indeterminateNodes;
}
} else {
// node is registered as checked
if (checkedNodes.includes(node.id)) {
checkedNodes.splice(checkedNodes.indexOf(node.id), 1);
checkedNodes = checkedNodes;
}

// node is indeterminate but not registered as indeterminate
if (indeterminate && !indeterminateNodes.includes(node.id)) {
indeterminateNodes.push(node.id);
indeterminateNodes = indeterminateNodes;
// node is not indeterminate but registered as indeterminate
} else if (!indeterminate && indeterminateNodes.includes(node.id)) {
indeterminateNodes.splice(indeterminateNodes.indexOf(node.id), 1);
indeterminateNodes = indeterminateNodes;
}
}
}

// init check flow will messup the checked nodes, so we save it to reassign it onMount.
tempCheckedNodes = [...checkedNodes];
onMount(async () => {
if (selection) {
// random number as name
name = String(Math.random());

// init groups if not initialized yet
if (group === undefined) {
if (multiple) {
group = [];
nodes.forEach((node) => {
if (checkedNodes.includes(node.id) && Array.isArray(group)) group.push(node.id);
});
group = group;
} else if (!nodes.some((node) => checkedNodes.includes(node.id))) {
group = '';
}
}

// remove relational links
if (!relational) treeItems = [];

// reassign checkNodes to ensure component starting with the correct check values.
checkedNodes = [];
await tick();
checkedNodes = [...tempCheckedNodes];
}
});

// important to pass children up to items (recursively)
export let treeItems: TreeViewItem[] = [];
let children: TreeViewItem[][] = [];
</script>

{#if nodes && nodes.length > 0}
{#each nodes as node, i}
<TreeViewItem
bind:this={treeItems[i]}
bind:children={children[i]}
bind:group
bind:name
bind:value={node.id}
hideLead={!node.lead}
hideChildren={!node.children || node.children.length === 0}
open={expandedNodes.includes(node.id)}
disabled={disabledNodes.includes(node.id)}
checked={checkedNodes.includes(node.id)}
indeterminate={indeterminateNodes.includes(node.id)}
on:toggle={(e) => toggleNode(node, e.detail.open)}
on:groupChange={(e) => checkNode(node, e.detail.checked, e.detail.indeterminate)}
>
{@html node.content}
<svelte:fragment slot="lead">
{@html node.lead}
</svelte:fragment>
<svelte:fragment slot="children">
<RecursiveTreeViewItem
nodes={node.children}
bind:expandedNodes
bind:disabledNodes
bind:checkedNodes
bind:indeterminateNodes
bind:treeItems={children[i]}
/>
</svelte:fragment>
</TreeViewItem>
{/each}
{/if}
14 changes: 2 additions & 12 deletions packages/skeleton/src/lib/components/TreeView/TreeView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,13 @@
import { setContext } from 'svelte';

// Types
import type { CssClasses, TreeViewNode } from '../../index.js';
import TreeViewDataDrivenItem from './TreeViewDataDrivenItem.svelte';
import type { CssClasses } from '../../index.js';

// Props (parent)
/** Enable tree-view selection. */
export let selection = false;
/** Enable selection of multiple items. */
export let multiple = false;
/**
* Provide data-driven nodes.
* @type {TreeViewNode[]}
*/
export let nodes: TreeViewNode[] = [];
/** Provide classes to set the tree width. */
export let width: CssClasses = 'w-full';
/** Provide classes to set the vertical spacing between items. */
Expand Down Expand Up @@ -114,9 +108,5 @@
aria-label={labelledby}
aria-disabled={disabled}
>
{#if nodes && nodes.length > 0}
<TreeViewDataDrivenItem bind:nodes on:change on:click on:toggle on:keydown on:keyup />
{:else}
<slot />
{/if}
<slot />
</div>
Loading