-
-
Notifications
You must be signed in to change notification settings - Fork 26
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #266 from lqt93/moai-tree
Feat(core): Add Tree Component
- Loading branch information
Showing
13 changed files
with
447 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
}; |
Oops, something went wrong.
5226e55
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Successfully deployed to the following URLs: