* feat(tree-view): add TreeView

* fix(tree-view): select initial active node, correct typedefs

* docs(tree-view): update examples

* chore(tree-view): add test for types

* docs(tree-view): rename example

* docs(tree-view): improve docs

* docs(tree-view): refine examples

* docs: fix invalid syntax

* chore: rebuild component index/api

* docs(layout): increase height of sidenav menu [ci skip]
This commit is contained in:
Eric Liu 2021-07-05 09:11:15 -07:00 committed by GitHub
commit 6ed4aaa86e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1176 additions and 4 deletions

View file

@ -0,0 +1,120 @@
<script>
// TODO: add function to programmatically expand/collapse parent nodes
/**
* @typedef {string | number} TreeNodeId
* @typedef {{ id: TreeNodeId; text: string; icon?: typeof import("carbon-icons-svelte").CarbonIcon; disabled?: boolean; expanded?: boolean; }} TreeNode
* @event {TreeNode & { expanded: boolean; leaf: boolean; }} select
* @event {TreeNode & { expanded: boolean; leaf: boolean; }} toggle
* @event {TreeNode & { expanded: boolean; leaf: boolean; }} focus
*/
/**
* Provide an array of children nodes to render
* @type {Array<TreeNode & { children?: TreeNode[] }>}
*/
export let children = [];
/**
* Set the current active node id
* Only one node can be active
* @type {TreeNodeId}
*/
export let activeId = "";
/**
* Set the node ids to be selected
* @type {TreeNodeId[]}
*/
export let selectedIds = [];
/**
* Specify the TreeView size
* @type {"default" | "compact"}
*/
export let size = "default";
/** Specify the label text */
export let labelText = "";
/** Set to `true` to visually hide the label text */
export let hideLabel = false;
import { createEventDispatcher, setContext } from "svelte";
import { writable } from "svelte/store";
import TreeViewNodeList from "./TreeViewNodeList.svelte";
const dispatch = createEventDispatcher();
const labelId = `label-${Math.random().toString(36)}`;
const activeNodeId = writable(activeId);
const selectedNodeIds = writable(selectedIds);
let ref = null;
let treeWalker = null;
setContext("TreeView", {
activeNodeId,
selectedNodeIds,
clickNode: (node) => {
activeId = node.id;
selectedIds = [node.id];
dispatch("select", node);
},
selectNode: (node) => {
selectedIds = [node.id];
},
focusNode: (node) => dispatch("focus", node),
toggleNode: (node) => dispatch("toggle", node),
});
function handleKeyDown(e) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") e.preventDefault();
treeWalker.currentNode = e.target;
let node = null;
if (e.key === "ArrowUp") node = treeWalker.previousNode();
if (e.key === "ArrowDown") node = treeWalker.nextNode();
if (node && node !== e.target) {
node.tabIndex = "0";
node.focus();
}
}
$: activeNodeId.set(activeId);
$: selectedNodeIds.set(selectedIds);
$: if (ref) {
treeWalker = document.createTreeWalker(ref, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
if (node.classList.contains("bx--tree-node--disabled"))
return NodeFilter.FILTER_REJECT;
if (node.matches("li.bx--tree-node")) return NodeFilter.FILTER_ACCEPT;
return NodeFilter.FILTER_SKIP;
},
});
}
</script>
{#if !hideLabel}
<!-- svelte-ignore a11y-label-has-associated-control -->
<label id="{labelId}" class:bx--label="{true}">
<slot name="labelText">{labelText}</slot>
</label>
{/if}
<ul
{...$$restProps}
role="tree"
bind:this="{ref}"
class:bx--tree="{true}"
class:bx--tree--default="{size === 'default'}"
class:bx--tree--compact="{size === 'compact'}"
aria-label="{hideLabel ? labelText : undefined}"
aria-labelledby="{!hideLabel ? labelId : undefined}"
aria-multiselectable="{selectedIds.length > 1 || undefined}"
on:keydown
on:keydown|stopPropagation="{handleKeyDown}"
>
<TreeViewNodeList root children="{children}" />
</ul>

View file

@ -0,0 +1,125 @@
<script context="module">
/**
* Computes the depth of a tree leaf node relative to <ul role="tree" />
* @param {HTMLLIElement} node
* @returns {number} depth
*/
export function computeTreeLeafDepth(node) {
let depth = 0;
if (node == null) return depth;
let parentNode = node.parentNode;
while (parentNode != null && parentNode.getAttribute("role") !== "tree") {
parentNode = parentNode.parentNode;
if (parentNode.tagName === "LI") depth++;
}
return depth;
}
/**
* Finds the nearest parent tree node
* @param {HTMLElement} node
* @returns {null | HTMLElement}
*/
function findParentTreeNode(node) {
if (node.classList.contains("bx--tree-parent-node")) return node;
if (node.classList.contains("bx--tree")) return null;
return findParentTreeNode(node.parentNode);
}
</script>
<script>
/**
* @typedef {string | number} TreeNodeId
*/
export let leaf = false;
/** @type {TreeNodeId} */
export let id = "";
export let text = "";
export let disabled = false;
/**
* Specify the icon from `carbon-icons-svelte` to render
* @type {typeof import("carbon-icons-svelte").CarbonIcon}
*/
export let icon = undefined;
import { afterUpdate, getContext } from "svelte";
let ref = null;
let refLabel = null;
let prevActiveId = undefined;
const {
activeNodeId,
selectedNodeIds,
clickNode,
selectNode,
focusNode,
} = getContext("TreeView");
const offset = () =>
computeTreeLeafDepth(refLabel) + (leaf && icon ? 2 : 2.5);
afterUpdate(() => {
if (id === $activeNodeId && prevActiveId !== $activeNodeId) {
if (!$selectedNodeIds.includes(id)) selectNode(node);
}
prevActiveId = $activeNodeId;
});
$: node = { id, text, expanded: false, leaf };
$: if (refLabel) {
refLabel.style.marginLeft = `-${offset()}rem`;
refLabel.style.paddingLeft = `${offset()}rem`;
}
</script>
<li
bind:this="{ref}"
role="treeitem"
id="{id}"
tabindex="{disabled ? undefined : -1}"
aria-current="{id === $activeNodeId || undefined}"
aria-selected="{disabled ? undefined : $selectedNodeIds.includes(id)}"
aria-disabled="{disabled}"
class:bx--tree-node="{true}"
class:bx--tree-leaf-node="{true}"
class:bx--tree-node--active="{id === $activeNodeId}"
class:bx--tree-node--selected="{$selectedNodeIds.includes(id)}"
class:bx--tree-node--disabled="{disabled}"
class:bx--tree-node--with-icon="{icon}"
on:click|stopPropagation="{() => {
if (disabled) return;
clickNode(node);
}}"
on:keydown="{(e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') {
e.stopPropagation();
}
if (e.key === 'ArrowLeft') {
const parentNode = findParentTreeNode(ref.parentNode);
if (parentNode) parentNode.focus();
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (disabled) return;
clickNode(node);
}
}}"
on:focus="{() => {
focusNode(node);
}}"
>
<div bind:this="{refLabel}" class:bx--tree-node__label="{true}">
<svelte:component this="{icon}" class="bx--tree-node__icon" />
{text}
</div>
</li>

View file

@ -0,0 +1,160 @@
<script>
/**
* @typedef {string | number} TreeNodeId
* @typedef {{ id: TreeNodeId; text: string; disabled?: boolean; expanded?: boolean; }} TreeNode
*/
/** @type {Array<TreeNode & { children?: TreeNode[] }>} */
export let children = [];
export let expanded = false;
export let root = false;
/** @type {string | number} */
export let id = "";
export let text = "";
export let disabled = false;
/**
* Specify the icon from `carbon-icons-svelte` to render
* @type {typeof import("carbon-icons-svelte").CarbonIcon}
*/
export let icon = undefined;
import { afterUpdate, getContext } from "svelte";
import CaretDown16 from "carbon-icons-svelte/lib/CaretDown16/CaretDown16.svelte";
import TreeViewNode, { computeTreeLeafDepth } from "./TreeViewNode.svelte";
let ref = null;
let refLabel = null;
let prevActiveId = undefined;
const {
activeNodeId,
selectedNodeIds,
clickNode,
selectNode,
focusNode,
toggleNode,
} = getContext("TreeView");
const offset = () => {
const depth = computeTreeLeafDepth(refLabel);
if (parent) return depth + 1;
if (icon) return depth + 2;
return depth + 2.5;
};
afterUpdate(() => {
if (id === $activeNodeId && prevActiveId !== $activeNodeId) {
if (!$selectedNodeIds.includes(id)) selectNode(node);
}
prevActiveId = $activeNodeId;
});
$: parent = Array.isArray(children);
$: node = { id, text, expanded, leaf: !parent };
$: if (refLabel) {
refLabel.style.marginLeft = `-${offset()}rem`;
refLabel.style.paddingLeft = `${offset()}rem`;
}
</script>
{#if root}
{#each children as child (child.id)}
{#if Array.isArray(child.children)}
<svelte:self {...child} />
{:else}
<TreeViewNode leaf {...child} />
{/if}
{/each}
{:else}
<li
bind:this="{ref}"
role="treeitem"
id="{id}"
tabindex="{disabled ? undefined : -1}"
aria-current="{id === $activeNodeId || undefined}"
aria-selected="{disabled ? undefined : $selectedNodeIds.includes(id)}"
aria-disabled="{disabled}"
class:bx--tree-node="{true}"
class:bx--tree-parent-node="{true}"
class:bx--tree-node--active="{id === $activeNodeId}"
class:bx--tree-node--selected="{$selectedNodeIds.includes(id)}"
class:bx--tree-node--disabled="{disabled}"
class:bx--tree-node--with-icon="{icon}"
aria-expanded="{expanded}"
on:click|stopPropagation="{() => {
if (disabled) return;
clickNode(node);
}}"
on:keydown="{(e) => {
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'Enter'
) {
e.stopPropagation();
}
if (parent && e.key === 'ArrowLeft') {
expanded = false;
toggleNode(node);
}
if (parent && e.key === 'ArrowRight') {
if (expanded) {
ref.lastChild.firstChild.focus();
} else {
expanded = true;
toggleNode(node);
}
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (disabled) return;
expanded = !expanded;
toggleNode(node);
clickNode(node);
ref.focus();
}
}}"
on:focus="{() => {
focusNode(node);
}}"
>
<div class:bx--tree-node__label="{true}" bind:this="{refLabel}">
<span
class:bx--tree-parent-node__toggle="{true}"
disabled="{disabled}"
on:click="{() => {
if (disabled) return;
expanded = !expanded;
toggleNode(node);
}}"
>
<CaretDown16
class="bx--tree-parent-node__toggle-icon {expanded &&
'bx--tree-parent-node__toggle-icon--expanded'}"
/>
</span>
<span class:bx--tree-node__label__details="{true}">
<svelte:component this="{icon}" class="bx--tree-node__icon" />
{text}
</span>
</div>
{#if expanded}
<ul role="group" class:bx--tree-node__children="{true}">
{#each children as child (child.id)}
{#if Array.isArray(child.children)}
<svelte:self {...child} />
{:else}
<TreeViewNode leaf {...child} />
{/if}
{/each}
</ul>
{/if}
</li>
{/if}

1
src/TreeView/index.js Normal file
View file

@ -0,0 +1 @@
export { default as TreeView } from "./TreeView.svelte";