diff --git a/.changeset/nine-worms-cheat.md b/.changeset/nine-worms-cheat.md new file mode 100644 index 0000000000..6c643cb528 --- /dev/null +++ b/.changeset/nine-worms-cheat.md @@ -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. \ No newline at end of file diff --git a/packages/skeleton/src/lib/components/TreeView/RecursiveTreeView.svelte b/packages/skeleton/src/lib/components/TreeView/RecursiveTreeView.svelte new file mode 100644 index 0000000000..f465ec6c7d --- /dev/null +++ b/packages/skeleton/src/lib/components/TreeView/RecursiveTreeView.svelte @@ -0,0 +1,111 @@ + + +
+ {#if nodes && nodes.length > 0} + + {/if} +
diff --git a/packages/skeleton/src/lib/components/TreeView/RecursiveTreeViewItem.svelte b/packages/skeleton/src/lib/components/TreeView/RecursiveTreeViewItem.svelte new file mode 100644 index 0000000000..68ee74302b --- /dev/null +++ b/packages/skeleton/src/lib/components/TreeView/RecursiveTreeViewItem.svelte @@ -0,0 +1,161 @@ + + +{#if nodes && nodes.length > 0} + {#each nodes as node, i} + toggleNode(node, e.detail.open)} + on:groupChange={(e) => checkNode(node, e.detail.checked, e.detail.indeterminate)} + > + {@html node.content} + + {@html node.lead} + + + + + + {/each} +{/if} diff --git a/packages/skeleton/src/lib/components/TreeView/TreeView.svelte b/packages/skeleton/src/lib/components/TreeView/TreeView.svelte index 6355e81542..337e512cd0 100644 --- a/packages/skeleton/src/lib/components/TreeView/TreeView.svelte +++ b/packages/skeleton/src/lib/components/TreeView/TreeView.svelte @@ -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. */ @@ -114,9 +108,5 @@ aria-label={labelledby} aria-disabled={disabled} > - {#if nodes && nodes.length > 0} - - {:else} - - {/if} + diff --git a/packages/skeleton/src/lib/components/TreeView/TreeViewDataDrivenItem.svelte b/packages/skeleton/src/lib/components/TreeView/TreeViewDataDrivenItem.svelte deleted file mode 100644 index fea63fd8d3..0000000000 --- a/packages/skeleton/src/lib/components/TreeView/TreeViewDataDrivenItem.svelte +++ /dev/null @@ -1,119 +0,0 @@ - - -{#if nodes && nodes.length > 0} - {#each nodes as node, i} - - {@html node.content} - - {@html node.lead} - - - - - - {/each} -{/if} diff --git a/packages/skeleton/src/lib/components/TreeView/TreeViewItem.svelte b/packages/skeleton/src/lib/components/TreeView/TreeViewItem.svelte index 0b925e0f74..f11b5223e6 100644 --- a/packages/skeleton/src/lib/components/TreeView/TreeViewItem.svelte +++ b/packages/skeleton/src/lib/components/TreeView/TreeViewItem.svelte @@ -7,7 +7,7 @@ * @slot {{}} lead - Allows for an optional leading element, such as an icon. * @slot {{}} children - Provide TreeView item children. */ - import { getContext, createEventDispatcher } from 'svelte'; + import { getContext, createEventDispatcher, onMount } from 'svelte'; // Types import type { CssClasses, SvelteEvent, TreeViewItem } from '../../index.js'; @@ -28,6 +28,8 @@ * @type {unknown} */ export let value: unknown = undefined; + /** Set the input's check state */ + export let checked = false; /** * Provide children references to support relational checking. * @type {TreeViewItem[]} @@ -78,7 +80,6 @@ export let hideChildren = false; // Locals - let checked = false; let treeItem: HTMLDetailsElement; let childrenDiv: HTMLDivElement; @@ -90,28 +91,54 @@ // Svelte Checkbox Bugfix // GitHub: https://github.com/sveltejs/svelte/issues/2308 // REPL: https://svelte.dev/repl/de117399559f4e7e9e14e2fc9ab243cc?version=3.12.1 - $: if (multiple) updateCheckbox(group); - $: if (multiple) updateGroup(checked); - function updateCheckbox(group: unknown) { + $: if (multiple) updateCheckbox(group, indeterminate); + $: if (multiple) updateGroup(checked, indeterminate); + $: if (!multiple) updateRadio(group); + $: if (!multiple) updateRadioGroup(checked); + let initUpdate = true; + function updateCheckbox(group: unknown, indeterminate: boolean) { if (!Array.isArray(group)) return; checked = group.indexOf(value) >= 0; + /** @event {{checked: boolean, indeterminate: boolean}} groupChange - Fires when the group changes */ + dispatch('groupChange', { checked: checked, indeterminate: indeterminate }); + dispatch('childChange'); + // called only once when initializing to apply default checks + if (initUpdate) { + onParentChange(); + initUpdate = false; + } } - function updateGroup(checked: boolean) { + function updateGroup(checked: boolean, indeterminate: boolean) { if (!Array.isArray(group)) return; const index = group.indexOf(value); if (checked) { if (index < 0) { group.push(value); group = group; + // called only when the group changes + onParentChange(); } } else { if (index >= 0) { group.splice(index, 1); group = group; + // called only when the group changes + onParentChange(); } } } + function updateRadio(group: unknown) { + checked = group === value; + /** @event {{checked: boolean, indeterminate: boolean}} groupChange - Fires when the group changes */ + dispatch('groupChange', { checked: checked, indeterminate: false }); + if (group) dispatch('childChange'); + } + function updateRadioGroup(checked: boolean) { + if (checked && group !== value) group = value; + else if (!checked && group === value) group = ''; + } + // called when a child's value is changed function onChildValueChange() { if (multiple) { @@ -123,6 +150,10 @@ // at least one child is indeterminate => indeterminate item if (children.some((c) => c.indeterminate)) { indeterminate = true; + if (index >= 0) { + group.splice(index, 1); + group = group; + } } // all children are checked => check item else if (childrenValues.every((c) => Array.isArray(childrenGroup) && childrenGroup.includes(c))) { @@ -135,6 +166,10 @@ // not all children are checked => indeterminate item else if (childrenValues.some((c) => Array.isArray(childrenGroup) && childrenGroup.includes(c))) { indeterminate = true; + if (index >= 0) { + group.splice(index, 1); + group = group; + } } // all children are unchecked => uncheck item else { @@ -147,13 +182,17 @@ } // single selection mode else { - if (group !== value) { - // check item + // one of the children is checked => check item + if (group !== value && children.some((c) => c.checked)) { group = value; + // none of the children are checked => uncheck item + } else if (group === value && !children.some((c) => c.checked)) { + group = ''; } } // important to notify parent of item - dispatch('change'); + /** @event childChange - Fires when the group of the child changes */ + dispatch('childChange'); } // used to update children of item when checked / unchecked in multiple mode @@ -168,7 +207,6 @@ if (!child || !Array.isArray(child.group)) return; child.indeterminate = false; if (child.group.indexOf(child.value) < 0) { - // child.group = [...child.group, child.value] won't work here. child.group.push(child.value); child.group = child.group; } @@ -184,6 +222,7 @@ }; children.forEach((child) => { + if (!child) return; // if parent is checked, check all children, else uncheck all children index >= 0 ? checkChild(child) : uncheckChild(child); // notify children to update values @@ -192,11 +231,11 @@ } // used to update children of item when checked / unchecked in single mode - $: if (!multiple && group) { + $: if (!multiple && group !== undefined) { if (group !== value) { // uncheck all children children.forEach((child) => { - if (child) child.group = []; + if (child) child.group = ''; }); } } @@ -208,7 +247,7 @@ // whenever children are changed, reassign on:change events. $: children.forEach((child) => { - if (child) child.$on('change', () => onChildValueChange()); + if (child) child.$on('childChange', onChildValueChange); }); // A11y Key Down Handler @@ -337,12 +376,10 @@ {value} bind:checked bind:indeterminate - on:click - on:change on:change={onParentChange} /> {:else} - + {/if} {/if} diff --git a/packages/skeleton/src/lib/components/TreeView/types.ts b/packages/skeleton/src/lib/components/TreeView/types.ts index 879b738cdc..f62dff7b22 100644 --- a/packages/skeleton/src/lib/components/TreeView/types.ts +++ b/packages/skeleton/src/lib/components/TreeView/types.ts @@ -1,18 +1,12 @@ export interface TreeViewNode { + /** Nodes Unique ID */ + id: string; /** Main content. accepts HTML. */ content: string; /** Lead content. accepts HTML. */ lead?: string; - /** Set open by default on load. */ - open?: boolean; - /** Set the tree disabled state. */ - disabled?: boolean; /** children nodes. */ children?: TreeViewNode[]; /** Set the input's value. */ value?: unknown; - /** input checked */ - checked?: boolean; - /** input is set to indeterminate, only availabe in multiple selection mode. */ - indeterminate?: boolean; } diff --git a/packages/skeleton/src/lib/index.ts b/packages/skeleton/src/lib/index.ts index ec662e29e9..a9eecdd6e7 100644 --- a/packages/skeleton/src/lib/index.ts +++ b/packages/skeleton/src/lib/index.ts @@ -87,7 +87,8 @@ export { default as Tab } from './components/Tab/Tab.svelte'; export { default as TabAnchor } from './components/Tab/TabAnchor.svelte'; export { default as TreeView } from './components/TreeView/TreeView.svelte'; export { default as TreeViewItem } from './components/TreeView/TreeViewItem.svelte'; -export { default as TreeViewDataDrivenItem } from './components/TreeView/TreeViewDataDrivenItem.svelte'; +export { default as RecursiveTreeView } from './components/TreeView/RecursiveTreeView.svelte'; +export { default as RecursiveTreeViewItem } from './components/TreeView/RecursiveTreeViewItem.svelte'; // Utility Components export { default as CodeBlock } from './utilities/CodeBlock/CodeBlock.svelte'; export { default as Modal } from './utilities/Modal/Modal.svelte'; diff --git a/sites/skeleton.dev/src/routes/(inner)/components/tree-views/+page.svelte b/sites/skeleton.dev/src/routes/(inner)/components/tree-views/+page.svelte index 5c54405eb1..4ca8ad6668 100644 --- a/sites/skeleton.dev/src/routes/(inner)/components/tree-views/+page.svelte +++ b/sites/skeleton.dev/src/routes/(inner)/components/tree-views/+page.svelte @@ -3,19 +3,21 @@ import { DocsFeature, type DocsShellSettings } from '$lib/layouts/DocsShell/types'; import DocsPreview from '$lib/components/DocsPreview/DocsPreview.svelte'; // Components - import { TreeView, TreeViewItem, type TreeViewNode } from '@skeletonlabs/skeleton'; + import { TreeView, TreeViewItem, RecursiveTreeView } from '@skeletonlabs/skeleton'; // Utilities import { CodeBlock } from '@skeletonlabs/skeleton'; // Sveld import sveldTreeView from '@skeletonlabs/skeleton/components/TreeView/TreeView.svelte?raw&sveld'; import sveldTreeViewItem from '@skeletonlabs/skeleton/components/TreeView/TreeViewItem.svelte?raw&sveld'; + import sveldRecursiveTreeView from '@skeletonlabs/skeleton/components/TreeView/RecursiveTreeView.svelte?raw&sveld'; + import { nodes } from './exampleData'; // Docs Shell const settings: DocsShellSettings = { feature: DocsFeature.Component, name: 'Tree Views', description: 'Display information in a hierarchical structure using collapsible nodes.', - imports: ['TreeView', 'TreeViewItem', 'type TreeViewNode'], + imports: ['TreeView', 'TreeViewItem', 'RecursiveTreeView', 'type TreeViewNode'], source: 'packages/skeleton/src/lib/components/TreeView', aria: 'https://www.w3.org/WAI/ARIA/apg/patterns/treeview/', components: [ @@ -38,7 +40,8 @@ 'regionSymbol', 'regionChildren' ] - } + }, + { label: 'RecursiveTreeView', sveld: sveldRecursiveTreeView } ], keyboard: [ ['Tab', "Focus the next tree-view item or it's input."], @@ -67,94 +70,11 @@ let expandTree: TreeView; - let simpleDD: TreeViewNode[] = [ - { - content: 'Books', - lead: '', - open: true, - children: [ - { content: 'Clean Code', value: 'Clean Code' }, - { content: 'The Clean Coder', value: 'The Clean Coder' }, - { content: 'The Art of Unix Programming', value: 'The Art of Unix Programming' } - ], - value: 'books' - }, - { - content: 'Movies', - lead: '', - children: [ - { content: 'The Flash', value: 'The Flash' }, - { content: 'Guardians of the Galaxy', value: 'Guardians of the Galaxy' }, - { content: 'Black Panther', value: 'Black Panther' } - ], - value: 'movies' - }, - { - content: 'TV', - lead: '', - value: 'tv' - } - ]; - - let singleDD: TreeViewNode[] = [ - { - content: 'Books', - lead: '', - open: true, - checked: true, - children: [ - { content: 'Clean Code', value: 'Clean Code' }, - { content: 'The Clean Coder', value: 'The Clean Coder' }, - { content: 'The Art of Unix Programming', value: 'The Art of Unix Programming', checked: true } - ], - value: 'books' - }, - { - content: 'Movies', - lead: '', - children: [ - { content: 'The Flash', value: 'The Flash' }, - { content: 'Guardians of the Galaxy', value: 'Guardians of the Galaxy' }, - { content: 'Black Panther', value: 'Black Panther' } - ], - value: 'movies' - }, - { - content: 'TV', - lead: '', - value: 'tv' - } - ]; - - let multipleDD: TreeViewNode[] = [ - { - content: 'Books', - lead: '', - open: true, - indeterminate: true, - children: [ - { content: 'Clean Code', value: 'Clean Code' }, - { content: 'The Clean Coder', value: 'The Clean Coder', checked: true }, - { content: 'The Art of Unix Programming', value: 'The Art of Unix Programming', checked: true } - ], - value: 'books' - }, - { - content: 'Movies', - lead: '', - children: [ - { content: 'The Flash', value: 'The Flash' }, - { content: 'Guardians of the Galaxy', value: 'Guardians of the Galaxy' }, - { content: 'Black Panther', value: 'Black Panther' } - ], - value: 'movies' - }, - { - content: 'TV', - lead: '', - value: 'tv' - } - ]; + let expandedNodes: string[] = []; + let disabledNodes: string[] = ['programming']; + let singleCheckedNodes: string[] = []; + let multiCheckedNodes: string[] = ['javascript']; + let indeterminateNodes: string[] = []; @@ -734,24 +654,25 @@ let booksChildren: TreeViewItem[] = [];

Recursive Mode

-

Tree views can be generated using a recursive data-driven method.

+

+ Tree views can be generated with a recursive data-driven method using the RecursiveTreeView components. +

- + +

To get expected results make sure to include a unique Id for each node.

+ `} />
- -

Single Selection

+ +

Expanded

- Relational checking is automatically applied when generating your list in a recursive manner. Setting a child as checked will not automatically affect the parent. + To access and modify the expanded nodes use expandedNodes array prop.

- + + `} /> + + Expanded nodes: {expandedNodes} + - -

Multiple Selection

-

Relational checking is automatically applied when generating your list in a recursive manner.

+ +

Disabled

+ +

+ To access and modify the disabled nodes use disabledNodes array prop. +

- + + + `} + /> + + + Disabled nodes: {disabledNodes} + + + +

Selection

+ +

+ Just like normal Tree-view, Recursive Tree-view supports selection with both single and multiple modes. +

+

To access and modify the checked nodes use checkedNodes array prop.

+ + + + + + + + `} + /> + + + checked nodes: {singleCheckedNodes} + + + + +

Relational Selection

+ +

+ Just like normal Tree-view, Recursive Tree-view supports relational selection using the prop relational. +

+

To access and modify the checked nodes use checkedNodes array prop.

+

+ In multiple relational selection mode, an extra array prop indeterminateNodes is available to indicate indeterminate + nodes. +

+ + + + + + + `} /> + + indeterminate nodes: {indeterminateNodes} +
diff --git a/sites/skeleton.dev/src/routes/(inner)/components/tree-views/exampleData.ts b/sites/skeleton.dev/src/routes/(inner)/components/tree-views/exampleData.ts new file mode 100644 index 0000000000..c99c8a596f --- /dev/null +++ b/sites/skeleton.dev/src/routes/(inner)/components/tree-views/exampleData.ts @@ -0,0 +1,223 @@ +import type { TreeViewNode } from '@skeletonlabs/skeleton'; + +export const nodes: TreeViewNode[] = [ + { + id: 'programming', + content: 'programming', + value: 'programming', + children: [ + { + id: 'language', + content: 'language', + value: 'language', + children: [ + { + id: 'javascript', + content: 'javascript', + value: 'javascript' + }, + { + id: 'c#', + content: 'c#', + value: 'c#' + }, + { + id: 'rust', + content: 'rust', + value: 'rust' + } + ] + }, + { + content: 'database', + value: 'database', + id: 'database', + children: [ + { + id: 'mongodb', + content: 'mongodb', + value: 'mongodb' + }, + { + id: 'mssql', + content: 'mssql', + value: 'mssql' + }, + { + id: 'casandra', + content: 'casandra', + value: 'casandra' + } + ] + }, + { + content: 'framework', + value: 'framework', + id: 'framework', + children: [ + { + id: 'svelte', + content: 'svelte', + value: 'svelte' + }, + { + id: 'angular', + content: 'angular', + value: 'angular' + }, + { + id: 'react', + content: 'react', + value: 'react' + } + ] + } + ] + }, + { + content: 'books', + value: 'books', + id: 'books', + children: [ + { + id: 'clean code', + content: 'clean code', + value: 'clean code', + children: [ + { + id: 'clean code - section 1', + content: 'clean code - section 1', + value: 'clean code - section 1' + }, + { + id: 'clean code - section 2', + content: 'clean code - section 2', + value: 'clean code - section 2' + }, + { + id: 'clean code - section 3', + content: 'clean code - section 3', + value: 'clean code - section 3' + } + ] + }, + { + id: 'structure', + content: 'structure', + value: 'structure', + children: [ + { + id: 'structure - section 1', + content: 'structure - section 1', + value: 'structure - section 1' + }, + { + id: 'structure - section 2', + content: 'structure - section 2', + value: 'structure - section 2' + }, + { + id: 'structure - section 3', + content: 'structure - section 3', + value: 'structure - section 3' + } + ] + }, + { + id: 'clean coder', + content: 'clean coder', + value: 'clean coder', + children: [ + { + id: 'clean coder - section 1', + content: 'clean coder - section 1', + value: 'clean coder - section 1' + }, + { + id: 'clean coder - section 2', + content: 'clean coder - section 2', + value: 'clean coder - section 2' + }, + { + id: 'clean coder - section 3', + content: 'clean coder - section 3', + value: 'clean coder - section 3' + } + ] + } + ] + }, + { + id: 'series', + content: 'series', + value: 'series', + children: [ + { + id: 'Mr. Robot', + content: 'Mr. Robot', + value: 'Mr. Robot', + children: [ + { + id: 'Mr. Robot - season 1', + content: 'Mr. Robot - season 1', + value: 'Mr. Robot - season 1' + }, + { + id: 'Mr. Robot - season 2', + content: 'Mr. Robot - season 2', + value: 'Mr. Robot - season 2' + }, + { + id: 'Mr. Robot - season 3', + content: 'Mr. Robot - season 3', + value: 'Mr. Robot - season 3' + } + ] + }, + { + id: 'silicon valley', + content: 'silicon valley', + value: 'silicon valley', + children: [ + { + id: 'silicon valley - season 1', + content: 'silicon valley - season 1', + value: 'silicon valley - season 1' + }, + { + id: 'silicon valley - season 2', + content: 'silicon valley - season 2', + value: 'silicon valley - season 2' + }, + { + id: 'silicon valley - season 3', + content: 'silicon valley - season 3', + value: 'silicon valley - season 3' + } + ] + }, + { + id: 'code monkeys', + content: 'code monkeys', + value: 'code monkeys', + children: [ + { + id: 'code monkeys - season 1', + content: 'code monkeys - season 1', + value: 'code monkeys - season 1' + }, + { + id: 'code monkeys - season 2', + content: 'code monkeys - season 2', + value: 'code monkeys - season 2' + }, + { + id: 'code monkeys - season 3', + content: 'code monkeys - season 3', + value: 'code monkeys - season 3' + } + ] + } + ] + } +];