Skip to content

Commit

Permalink
Merge pull request #266 from lqt93/moai-tree
Browse files Browse the repository at this point in the history
Feat(core): Add Tree Component
  • Loading branch information
Thien Do authored Aug 11, 2021
2 parents d64c0cd + ce0d0ab commit 5226e55
Show file tree
Hide file tree
Showing 13 changed files with 447 additions and 0 deletions.
1 change: 1 addition & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ export * from "./text/text";
export * from "./time-input/time-input";
export * from "./toast/toast";
export * from "./tooltip/tooltip";
export * from "./tree/tree";
26 changes: 26 additions & 0 deletions core/src/tree/row/actions/actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ButtonMenu } from "../../../button-menu/button-menu";
import { Button } from "../../../button/button";
import { coreIcons } from "../../../icons/icons";
import { TreeProps } from "../../tree";

export const TreeRowActions = (props: TreeProps): JSX.Element | null => {
const actions = props.getRowActions?.(props.node) ?? [];
if (actions.length === 0) return null;
return (
<div
onClick={(event) => {
event.stopPropagation();
}}
>
<ButtonMenu
button={{
icon: coreIcons.kebab,
iconLabel: "More actions",
style: Button.styles.flat,
size: Button.sizes.small,
}}
items={actions}
/>
</div>
);
};
28 changes: 28 additions & 0 deletions core/src/tree/row/row.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.container {
display: flex;
align-items: center;
padding-left: 8px;
cursor: pointer;
}

/* Turn off transition on purpose to make the hover feels faster */
.container:not(.x) {
transition: none;
}
/* Also turn off in the toggle button */
.container:not(.x) .toggle button {
transition: none;
}

.tab,
.toggle,
.actions {
flex: 0 0 auto;
}

.label {
white-space: nowrap;
flex: 1 1 0px;
overflow: hidden;
text-overflow: ellipsis;
}
69 changes: 69 additions & 0 deletions core/src/tree/row/row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Button } from "../../button/button";
import { coreIcons } from "../../icons/icons";
import { TreeProps } from "../tree";
import { isTreeLeaf } from "../utils/leaf";
import { TreeRowActions } from "./actions/actions";
import s from "./row.module.css";

// For indentation, like in source code
const Tab = () => (
<div className={[Button.sizes.smallIcon.mainClassName, s.tab].join(" ")} />
);

const toggle = async (props: TreeProps): Promise<void> => {
const expanded = new Set(props.expanded);
if (expanded.has(props.node.id)) {
expanded.delete(props.node.id);
} else {
expanded.add(props.node.id);
}
props.setExpanded(expanded);
};

export const TreeRow = (props: TreeProps): JSX.Element => {
const expanded = props.expanded.has(props.node.id);
const selected = props.selected.has(props.node.id);
const isLeaf = isTreeLeaf(props.node);
return (
<div
className={[
s.container,
Button.styles.flat.mainClassName,
selected ? Button.colors.none.flat.selectedClassName : "",
].join(" ")}
// @TODO: Handle a11y properly
onClick={() => {
if (isLeaf || props.parentMode === "select") {
props.setSelected(new Set([props.node.id]));
} else {
toggle(props);
}
}}
>
{[...Array(props.level ?? 0)].map((_v, i) => (
<Tab key={i} />
))}
<div className={s.toggle}>
{isLeaf === false ? (
<Button
icon={
expanded
? coreIcons.chevronDown
: coreIcons.chevronRight
}
iconLabel={expanded ? "Collapse group" : "Expand group"}
onClick={() => toggle(props)}
style={Button.styles.flat}
size={Button.sizes.smallIcon}
/>
) : (
<Tab />
)}
</div>
<div className={s.label}>{props.node.label}</div>
<div className={s.actions}>
<TreeRowActions {...props} />
</div>
</div>
);
};
Empty file added core/src/tree/tree.module.css
Empty file.
96 changes: 96 additions & 0 deletions core/src/tree/tree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useEffect } from "react";
import { MenuItemAction } from "../menu/menu";
import { TreeRow } from "./row/row";

export interface TreeNode {
id: string;
label: string;
/**
* May be undefined when:
* - Node is a leaf
* - Node is not fetched in case of async Tree
*/
children?: TreeNode[];
/**
* Required in case async Tree (i.e. props.loadChildren is defined) since
* parent nodes also have undefined children before loading
*/
isLeaf?: boolean;
}

export interface TreeProps {
/**
* Nested level. This is only used to render the left padding of nested
* nodes. The consumer usually should not set this since they are passing
* the root node.
*/
level?: number;
/**
* A tree's shape is completely controlled. The Tree component cannot
* change the tree shape (root) on its own.
*/
node: TreeNode;
/**
* Because Tree is a controlled component, it can only ask the host to
* load children and update the root on their side.
*
* This returns "void" since it expects the "root" prop will be update and
* thus leads to a new render altogether.
*/
loadChildren?: (node: TreeNode) => Promise<void>;
/**
* Actions to display for a given node/row. Return an empty array will
* be the same as not define this prop.
*/
getRowActions?: (node: TreeNode) => MenuItemAction[];
/**
* Selected nodes in controlled mode
*/
selected: Set<string>;
/**
* Handler to set selected nodes in controlled mode
*/
setSelected: (set: Set<string>) => void;
/**
* Expanded nodes in controlled mode
*/
expanded: Set<string>;
/**
* Handler to set expanded nodes in controlled mode
*/
setExpanded: (set: Set<string>) => void;
/**
* Whether clicking on a parent's title will select or expand it. If set to
* "select", clicking on the chevron arrow will expand it.
*/
parentMode: "select" | "expand";
}

const renderChild = (treeProps: TreeProps) => (child: TreeNode) =>
(
<Tree
{...treeProps}
key={child.id}
level={(treeProps.level ?? 0) + 1}
node={child}
/>
);

export const Tree = (props: TreeProps): JSX.Element => {
const expanded = props.expanded.has(props.node.id);

const { loadChildren, node } = props;
useEffect(() => {
if (expanded) loadChildren?.(node);
}, [loadChildren, expanded, node]);

const body = (
<>
<TreeRow {...props} />
{props.node.children && expanded && (
<>{props.node.children.map(renderChild(props))}</>
)}
</>
);
return props.level === 0 ? <div>{body}</div> : body;
};
44 changes: 44 additions & 0 deletions core/src/tree/utils/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { TreeNode } from "../tree";
import { isTreeLeaf } from "./leaf";

interface Params {
/** Start from here */
node: TreeNode;
/** Id of the parent node to add to */
id: string;
/** The new node to be added */
addNode: TreeNode;
/** If set, sort the "children" of "id" after add node */
sort: boolean;
}

const compareNode = (a: TreeNode, b: TreeNode): number =>
a.id.localeCompare(b.id);

/**
* Add a node in the tree.
*
* - If "id" is leaf: throws error
* - If "id" does not exist: throws error
* - If "id" is not loaded (false isLeaf and undefined children): skip
*
* This is a naive implementation that simply traverses all nodes O(n) to
* check for "id". If your "id"s can represent the path, use the optimized
* version which can skip branches O(logN).
*/
export const addTreeNode = ({ node, id, addNode, sort }: Params): TreeNode => {
if (node.id === id) {
if (isTreeLeaf(node)) throw Error("Cannot add node to a leaf");
if (node.children === undefined) return node; // Skip as not loaded
const children: TreeNode[] = [...node.children, addNode];
if (sort) children.sort(compareNode);
return { ...node, children };
} else {
if (isTreeLeaf(node)) return node;
if (node.children === undefined) return node; // Skip as not loaded
const children = node.children.map((child) => {
return addTreeNode({ node: child, id, addNode, sort });
});
return { ...node, children };
}
};
26 changes: 26 additions & 0 deletions core/src/tree/utils/exist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TreeNode } from "../tree";
import { isTreeLeaf } from "./leaf";

interface Params {
/** Start from here */
node: TreeNode;
/** Id of the node to find */
id: string;
}

/**
* Check if a node is in the tree by its "id".
*
* This is a naive implementation that simply traverses all nodes O(n) to
* check for "id". If your "id"s can represent the path, use the optimized
* version which can skip branches O(logN).
*/
export const isTreeNodeExist = (params: Params): boolean => {
const { node, id } = params;
if (node.id === id) return true;
if (isTreeLeaf(node)) return false;
if (node.children === undefined) return false;
return node.children.some((child) => {
return isTreeNodeExist({ node: child, id });
});
};
9 changes: 9 additions & 0 deletions core/src/tree/utils/leaf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { TreeNode } from "../tree";

export const isTreeLeaf = (node: TreeNode): boolean => {
if (node.isLeaf === undefined) {
return node.children === undefined; // Sync
} else {
return node.isLeaf; // Async
}
};
26 changes: 26 additions & 0 deletions core/src/tree/utils/refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TreeNode } from "../tree";
import { isTreeLeaf } from "./leaf";

interface Params {
node: TreeNode;
loadChildren: (node: TreeNode) => Promise<TreeNode[]>;
}

export const refreshTree = async (params: Params): Promise<TreeNode> => {
const { loadChildren, node } = params;

if (isTreeLeaf(node)) return node;
if (node.children === undefined) return node; // Not expanded

const prevs = new Map(node.children.map((child) => [child.id, child]));
const currs = await loadChildren(node);
const promises = currs.map(async (child) => {
const prev = prevs.get(child.id);
const target = prev === undefined ? child : prev;
const next = await refreshTree({ loadChildren, node: target });
return next;
});
const nexts = await Promise.all(promises);

return { ...node, children: nexts };
};
28 changes: 28 additions & 0 deletions core/src/tree/utils/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { TreeNode } from "../tree";
import { isTreeLeaf } from "./leaf";

interface Params {
tree: TreeNode;
deleteId: string;
}

/**
* Remove a node from the tree.
*
* This is a naive implementation that simply traverses all nodes O(n) to
* check for "deleteId". If your "id"s can represent the path, use the
* optimized version which can skip branches O(logN).
*/
export const removeTreeNode = (params: Params): TreeNode => {
const { tree, deleteId } = params;

if (isTreeLeaf(tree)) return tree;
if (tree.children === undefined) return tree;

// @TODO: The performance here is worst
const children = tree.children
.filter((child) => child.id !== deleteId)
.map((child) => removeTreeNode({ tree: child, deleteId }));

return { ...tree, children };
};
Loading

1 comment on commit 5226e55

@vercel
Copy link

@vercel vercel bot commented on 5226e55 Aug 11, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.