customized treeview code from carbon components
mellbacon committed
Aug 9, 2022 at 18:07 UTC
43a914f1e8fd6fc806c99e920f70cee4f419894c
3 files changed
+439
src/components/FileTree/TreeView.svelte
new
+163
@@ -0,0 +1,163 @@
1
+<script>
2
+ /**
3
+ * @typedef {string | number} TreeNodeId
4
+ * @typedef {{ id: TreeNodeId; name: string; icon?: typeof import("svelte").SvelteComponent; disabled?: boolean; expanded?: boolean; path?: string; }} TreeNode
5
+ * @event {TreeNode & { expanded: boolean; leaf: boolean; }} select
6
+ * @event {TreeNode & { expanded: boolean; leaf: boolean; }} toggle
7
+ * @event {TreeNode & { expanded: boolean; leaf: boolean; }} focus
8
+ */
9
+
10
+ /**
11
+ * Provide an array of children nodes to render
12
+ * @type {Array<TreeNode & { children?: TreeNode[] }>}
13
+ */
14
+ export let children = [];
15
+
16
+ /**
17
+ * Set the current active node id
18
+ * Only one node can be active
19
+ * @type {TreeNodeId}
20
+ */
21
+ export let activeId = "";
22
+
23
+ /**
24
+ * Set the node ids to be selected
25
+ * @type {ReadonlyArray<TreeNodeId>}
26
+ */
27
+ export let selectedIds = [];
28
+
29
+ /**
30
+ * Set the node ids to be expanded
31
+ * @type {ReadonlyArray<TreeNodeId>}
32
+ */
33
+ export let expandedIds = [];
34
+
35
+ /**
36
+ * Specify the TreeView size
37
+ * @type {"default" | "compact"}
38
+ */
39
+ export let size = "default";
40
+
41
+ /** Specify the label text */
42
+ export let labelText = "";
43
+
44
+ /** Set to `true` to visually hide the label text */
45
+ export let hideLabel = false;
46
+
47
+ /**
48
+ * Programmatically expand all nodes
49
+ * @type {() => void}
50
+ */
51
+ export function expandAll() {
52
+ expandedIds = [...nodeIds];
53
+ }
54
+
55
+ /**
56
+ * Programmatically collapse all nodes
57
+ * @type {() => void}
58
+ */
59
+ export function collapseAll() {
60
+ expandedIds = [];
61
+ }
62
+
63
+ import { createEventDispatcher, setContext, onMount } from "svelte";
64
+ import { writable } from "svelte/store";
65
+ import TreeViewNodeList from "./TreeViewNodeList.svelte";
66
+
67
+ const dispatch = createEventDispatcher();
68
+ const labelId = `label-${Math.random().toString(36)}`;
69
+ const activeNodeId = writable(activeId);
70
+ const selectedNodeIds = writable(selectedIds);
71
+ const expandedNodeIds = writable(expandedIds);
72
+
73
+ let ref = null;
74
+ let treeWalker = null;
75
+
76
+ setContext("TreeView", {
77
+ activeNodeId,
78
+ selectedNodeIds,
79
+ expandedNodeIds,
80
+ clickNode: (node) => {
81
+ activeId = node.id;
82
+ selectedIds = [node.id];
83
+ dispatch("select", node);
84
+ },
85
+ selectNode: (node) => {
86
+ selectedIds = [node.id];
87
+ },
88
+ expandNode: (node, expanded) => {
89
+ if (expanded) {
90
+ expandedIds = [...expandedIds, node.id];
91
+ } else {
92
+ expandedIds = expandedIds.filter((_id) => _id !== node.id);
93
+ }
94
+ },
95
+ focusNode: (node) => dispatch("focus", node),
96
+ toggleNode: (node) => dispatch("toggle", node),
97
+ });
98
+
99
+ onMount(() => {
100
+ const firstFocusableNode = ref.querySelector(
101
+ "li.bx--tree-node:not(.bx--tree-node--disabled)"
102
+ );
103
+
104
+ if (firstFocusableNode != null) {
105
+ firstFocusableNode.tabIndex = "0";
106
+ }
107
+ });
108
+
109
+ /**
110
+ * @param {Array<TreeNode & { children?: TreeNode[] }>} children
111
+ */
112
+ function traverse(children) {
113
+ let nodes = [];
114
+
115
+ children.forEach((node) => {
116
+ nodes.push(node);
117
+
118
+ if (Array.isArray(node.children)) {
119
+ nodes = [...nodes, ...traverse(node.children)];
120
+ }
121
+ });
122
+
123
+ return nodes;
124
+ }
125
+
126
+ $: nodes = traverse(children);
127
+ $: nodeIds = nodes.map((node) => node.id);
128
+ $: activeNodeId.set(activeId);
129
+ $: selectedNodeIds.set(selectedIds);
130
+ $: expandedNodeIds.set(expandedIds);
131
+ $: if (ref) {
132
+ treeWalker = document.createTreeWalker(ref, NodeFilter.SHOW_ELEMENT, {
133
+ acceptNode: (node) => {
134
+ if (node.classList.contains("bx--tree-node--disabled"))
135
+ return NodeFilter.FILTER_REJECT;
136
+ if (node.matches("li.bx--tree-node")) return NodeFilter.FILTER_ACCEPT;
137
+ return NodeFilter.FILTER_SKIP;
138
+ },
139
+ });
140
+ }
141
+</script>
142
+
143
+{#if !hideLabel}
144
+ <!-- svelte-ignore a11y-label-has-associated-control -->
145
+ <label id={labelId} class:bx--label={true}>
146
+ <slot name="labelText">{labelText}</slot>
147
+ </label>
148
+{/if}
149
+
150
+<ul
151
+ {...$$restProps}
152
+ role="tree"
153
+ bind:this={ref}
154
+ class:bx--tree={true}
155
+ class:bx--tree--default={size === "default"}
156
+ class:bx--tree--compact={size === "compact"}
157
+ aria-label={hideLabel ? labelText : undefined}
158
+ aria-labelledby={!hideLabel ? labelId : undefined}
159
+ aria-multiselectable={selectedIds.length > 1 || undefined}
160
+ on:keydown
161
+>
162
+ <TreeViewNodeList root {children} />
163
+</ul>
src/components/FileTree/TreeViewNode.svelte
new
+125
@@ -0,0 +1,125 @@
1
+<script context="module">
2
+ export function computeTreeLeafDepth(node) {
3
+ let depth = 0;
4
+
5
+ if (node == null) return depth;
6
+
7
+ let parentNode = node.parentNode;
8
+
9
+ while (parentNode != null && parentNode.getAttribute("role") !== "tree") {
10
+ parentNode = parentNode.parentNode;
11
+ if (parentNode.tagName === "LI") depth++;
12
+ }
13
+
14
+ return depth;
15
+ }
16
+
17
+ function findParentTreeNode(node) {
18
+ if (node.classList.contains("bx--tree-parent-node")) return node;
19
+ if (node.classList.contains("bx--tree")) return null;
20
+ return findParentTreeNode(node.parentNode);
21
+ }
22
+</script>
23
+
24
+<script>
25
+ /**
26
+ * @typedef {string | number} TreeNodeId
27
+ */
28
+
29
+ export let leaf = false;
30
+
31
+ /** @type {TreeNodeId} */
32
+ export let id = "";
33
+ export let name = "";
34
+ export let disabled = false;
35
+ export let path = "";
36
+
37
+ /**
38
+ * Specify the icon to render
39
+ * @type {typeof import("svelte").SvelteComponent}
40
+ */
41
+ export let icon = undefined;
42
+
43
+ import { afterUpdate, getContext } from "svelte";
44
+ import RenameModel from "../Modal/RenameModel.svelte";
45
+ import LeafNodeMenu from "./LeafNodeMenu.svelte";
46
+
47
+ let ref = null;
48
+ let refLabel = null;
49
+ let prevActiveId = undefined;
50
+
51
+ const { activeNodeId, selectedNodeIds, clickNode, selectNode, focusNode } =
52
+ getContext("TreeView");
53
+ const offset = () =>
54
+ computeTreeLeafDepth(refLabel) + (leaf && icon ? 2 : 2.5);
55
+
56
+ afterUpdate(() => {
57
+ if (id === $activeNodeId && prevActiveId !== $activeNodeId) {
58
+ if (!$selectedNodeIds.includes(id)) selectNode(node);
59
+ }
60
+
61
+ prevActiveId = $activeNodeId;
62
+ });
63
+
64
+ $: node = { id, name: name, expanded: false, leaf, path };
65
+ $: if (refLabel) {
66
+ refLabel.style.marginLeft = `-${offset()}rem`;
67
+ refLabel.style.paddingLeft = `${offset()}rem`;
68
+ }
69
+
70
+ let open = false;
71
+</script>
72
+
73
+<li
74
+ bind:this={ref}
75
+ role="treeitem"
76
+ {id}
77
+ tabindex={disabled ? undefined : -1}
78
+ aria-current={id === $activeNodeId || undefined}
79
+ aria-selected={disabled ? undefined : $selectedNodeIds.includes(id)}
80
+ aria-disabled={disabled}
81
+ class:bx--tree-node={true}
82
+ class:bx--tree-leaf-node={true}
83
+ class:bx--tree-node--active={id === $activeNodeId}
84
+ class:bx--tree-node--selected={$selectedNodeIds.includes(id)}
85
+ class:bx--tree-node--disabled={disabled}
86
+ class:bx--tree-node--with-icon={icon}
87
+ on:click|stopPropagation={() => {
88
+ if (disabled) return;
89
+ clickNode(node);
90
+ }}
91
+ on:keydown={(e) => {
92
+ if (
93
+ e.key === "ArrowLeft" ||
94
+ e.key === "ArrowRight" ||
95
+ e.key === "F2"
96
+ ) {
97
+ e.stopPropagation();
98
+ }
99
+ if (e.key === "ArrowLeft") {
100
+ const parentNode = findParentTreeNode(ref.parentNode);
101
+ if (parentNode) parentNode.focus();
102
+ }
103
+ if (e.key === "F2") {
104
+ e.preventDefault();
105
+ open = true;
106
+ }
107
+ }}
108
+ on:focus={() => {
109
+ focusNode(node);
110
+ }}
111
+>
112
+ <div bind:this={refLabel} class:bx--tree-node__label={true}>
113
+ <svelte:component this={icon} class="bx--tree-node__icon" />
114
+ {name}
115
+ </div>
116
+</li>
117
+<LeafNodeMenu target={ref} filename={name} path={path}></LeafNodeMenu>
118
+<RenameModel bind:open bind:filename={name} bind:path={path} />
119
+
120
+<style>
121
+ :global(.bx--tree-node__label) {
122
+ white-space: nowrap;
123
+ cursor: pointer;
124
+ }
125
+</style>
\ No newline at end of file
src/components/FileTree/TreeViewNodeList.svelte
new
+151
@@ -0,0 +1,151 @@
1
+<script>
2
+ /**
3
+ * @typedef {string | number} TreeNodeId
4
+ * @typedef {{ id: TreeNodeId; name: string; disabled?: boolean; expanded?: boolean; path?: string; }} TreeNode
5
+ */
6
+
7
+ /** @type {Array<TreeNode & { children?: TreeNode[] }>} */
8
+ export let children = [];
9
+ export let expanded = false;
10
+ export let root = false;
11
+
12
+ /** @type {string | number} */
13
+ export let id = "";
14
+ export let name = "";
15
+ export let disabled = false;
16
+ export let path = "";
17
+
18
+ /**
19
+ * Specify the icon to render
20
+ * @type {typeof import("svelte").SvelteComponent}
21
+ */
22
+ export let icon = undefined;
23
+
24
+ import { afterUpdate, getContext } from "svelte";
25
+ import CaretDown from "carbon-icons-svelte/lib/CaretDown.svelte";
26
+ import TreeViewNode, { computeTreeLeafDepth } from "./TreeViewNode.svelte";
27
+ import ParentNodeMenu from "./ParentNodeMenu.svelte";
28
+
29
+ let ref = null;
30
+ let refLabel = null;
31
+ let prevActiveId = undefined;
32
+
33
+ const {
34
+ activeNodeId,
35
+ selectedNodeIds,
36
+ expandedNodeIds,
37
+ clickNode,
38
+ selectNode,
39
+ expandNode,
40
+ focusNode,
41
+ toggleNode,
42
+ } = getContext("TreeView");
43
+
44
+ const offset = () => {
45
+ const depth = computeTreeLeafDepth(refLabel);
46
+
47
+ if (parent) return depth + 1;
48
+ if (icon) return depth + 2;
49
+ return depth + 2.5;
50
+ };
51
+
52
+ afterUpdate(() => {
53
+ if (id === $activeNodeId && prevActiveId !== $activeNodeId) {
54
+ if (!$selectedNodeIds.includes(id)) selectNode(node);
55
+ }
56
+
57
+ prevActiveId = $activeNodeId;
58
+ });
59
+
60
+ $: parent = Array.isArray(children);
61
+ $: node = { id, name, expanded, leaf: !parent, path };
62
+ $: if (refLabel) {
63
+ refLabel.style.marginLeft = `-${offset()}rem`;
64
+ refLabel.style.paddingLeft = `${offset()}rem`;
65
+ }
66
+ $: expanded = $expandedNodeIds.includes(id);
67
+</script>
68
+
69
+{#if root}
70
+ {#each children as child (child.id)}
71
+ {#if Array.isArray(child.children)}
72
+ <svelte:self {...child} />
73
+ {:else}
74
+ <TreeViewNode leaf {...child} />
75
+ {/if}
76
+ {/each}
77
+{:else}
78
+ <li
79
+ bind:this={ref}
80
+ role="treeitem"
81
+ {id}
82
+ tabindex={disabled ? undefined : -1}
83
+ aria-current={id === $activeNodeId || undefined}
84
+ aria-selected={disabled ? undefined : $selectedNodeIds.includes(id)}
85
+ aria-disabled={disabled}
86
+ class:bx--tree-node={true}
87
+ class:bx--tree-parent-node={true}
88
+ class:bx--tree-node--active={id === $activeNodeId}
89
+ class:bx--tree-node--selected={$selectedNodeIds.includes(id)}
90
+ class:bx--tree-node--disabled={disabled}
91
+ class:bx--tree-node--with-icon={icon}
92
+ aria-expanded={expanded}
93
+ on:keydown={(e) => {
94
+ if (
95
+ e.key === "ArrowLeft" ||
96
+ e.key === "ArrowRight"
97
+ ) {
98
+ e.stopPropagation();
99
+ }
100
+ if (parent && e.key === "ArrowLeft") {
101
+ expanded = false;
102
+ expandNode(node, false);
103
+ toggleNode(node);
104
+ }
105
+ if (parent && e.key === "ArrowRight") {
106
+ if (expanded) {
107
+ ref.lastChild.firstElementChild?.focus();
108
+ } else {
109
+ expanded = true;
110
+ expandNode(node, true);
111
+ toggleNode(node);
112
+ }
113
+ }
114
+ }}
115
+ on:focus={() => {
116
+ focusNode(node);
117
+ }}
118
+ >
119
+ <div class:bx--tree-node__label={true} bind:this={refLabel}
120
+ on:click={() => {
121
+ if (disabled) return;
122
+ expanded = !expanded;
123
+ expandNode(node, expanded);
124
+ toggleNode(node);
125
+ }}>
126
+ <span class:bx--tree-parent-node__toggle={true} {disabled}>
127
+ <CaretDown
128
+ class="bx--tree-parent-node__toggle-icon {expanded &&
129
+ 'bx--tree-parent-node__toggle-icon--expanded'}"
130
+ />
131
+ </span>
132
+ <span class:bx--tree-node__label__details={true}>
133
+ <svelte:component this={icon} class="bx--tree-node__icon" />
134
+ {name}
135
+ </span>
136
+ </div>
137
+ {#if expanded}
138
+ <ul role="group" class:bx--tree-node__children={true}>
139
+ {#each children as child (child.id)}
140
+ {#if Array.isArray(child.children)}
141
+ <svelte:self {...child} />
142
+ {:else}
143
+ <TreeViewNode leaf {...child} />
144
+ {/if}
145
+ {/each}
146
+ </ul>
147
+ {/if}
148
+ </li>
149
+{/if}
150
+
151
+<ParentNodeMenu target={[refLabel]} filename={name} path={path}></ParentNodeMenu>
\ No newline at end of file