chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,645 @@
import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import { motion } from "framer-motion";
import type { MutableRefObject, RefObject } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { cn } from "~/utils/cn";
import type { NodeState, NodesState } from "./reducer";
import { reducer } from "./reducer";
import { concreteStateFromInput, selectedIdFromState } from "./utils";
export type TreeViewProps<TData> = {
tree: FlatTree<TData>;
parentClassName?: string;
renderNode: (params: {
node: FlatTreeItem<TData>;
state: NodeState;
index: number;
virtualizer: Virtualizer<HTMLElement, Element>;
virtualItem: VirtualItem;
}) => React.ReactNode;
nodes: UseTreeStateOutput["nodes"];
autoFocus?: boolean;
virtualizer: Virtualizer<HTMLElement, Element>;
parentRef?: MutableRefObject<HTMLElement | null>;
scrollRef?: MutableRefObject<HTMLElement | null>;
onScroll?: (scrollTop: number) => void;
} & Pick<UseTreeStateOutput, "getTreeProps" | "getNodeProps">;
export type GetTreePropsFn = UseTreeStateOutput["getTreeProps"];
export type GetNodePropsFn = UseTreeStateOutput["getNodeProps"];
export function TreeView<TData>({
tree,
renderNode,
nodes,
autoFocus = false,
getTreeProps,
getNodeProps,
parentClassName,
virtualizer,
parentRef,
scrollRef,
onScroll,
}: TreeViewProps<TData>) {
useEffect(() => {
if (autoFocus) {
parentRef?.current?.focus();
}
}, [autoFocus, parentRef?.current]);
const virtualItems = virtualizer.getVirtualItems();
// id -> node lookup so each virtual row resolves in O(1) instead of
// scanning the whole tree per row.
const nodesById = useMemo(() => {
const map = new Map<string, FlatTreeItem<TData>>();
for (const node of tree) {
map.set(node.id, node);
}
return map;
}, [tree]);
const scrollCallback = useCallback(
(event: Event) => {
if (!onScroll) return;
const target = event.target as HTMLElement;
onScroll?.(target.scrollTop);
},
[onScroll]
);
useEffect(() => {
//subscribe to scrollRef scroll event
if (!scrollRef?.current || onScroll === undefined) return;
scrollRef.current.addEventListener("scroll", scrollCallback);
return () => scrollRef.current?.removeEventListener("scroll", scrollCallback);
}, [scrollRef?.current]);
return (
<motion.div
ref={(element) => {
if (parentRef) {
parentRef.current = element;
}
if (scrollRef) {
scrollRef.current = element;
}
}}
className={cn(
"w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-within:outline-hidden",
parentClassName
)}
layoutScroll
{...getTreeProps()}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
overflowY: "visible",
}}
>
<div
style={{
position: "absolute",
overflowY: "visible",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItems.at(0)?.start ?? 0}px)`,
}}
>
{virtualItems.map((virtualItem) => {
const node = nodesById.get(virtualItem.key as string);
if (!node) return null;
const state = nodes[node.id];
if (!state) return null;
if (!state.visible) return null;
return (
<div
key={node.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="overflow-clip"
{...getNodeProps(node.id)}
>
{renderNode({
node,
state,
index: virtualItem.index,
virtualizer: virtualizer,
virtualItem,
})}
</div>
);
})}
</div>
</div>
</motion.div>
);
}
export type Filter<TData, TFilterValue> = {
value?: TFilterValue;
fn: (value: TFilterValue, node: FlatTreeItem<TData>) => boolean;
};
type TreeStateHookProps<TData, TFilterValue> = {
tree: FlatTree<TData>;
selectedId?: string;
collapsedIds?: string[];
onSelectedIdChanged?: (selectedId: string | undefined) => void;
estimatedRowHeight: (params: {
node: FlatTreeItem<TData>;
state: NodeState;
index: number;
}) => number;
parentRef: RefObject<any>;
filter?: Filter<TData, TFilterValue>;
};
//this is so Framer Motion can be used to render the components
type HTMLAttributes = Omit<
React.HTMLAttributes<HTMLElement>,
"onAnimationStart" | "onDragStart" | "onDragEnd" | "onDrag"
>;
export type UseTreeStateOutput = {
selected: string | undefined;
nodes: NodesState;
virtualizer: Virtualizer<HTMLElement, Element>;
getTreeProps: () => HTMLAttributes;
getNodeProps: (id: string) => HTMLAttributes;
selectNode: (id: string, scrollToNode?: boolean) => void;
deselectNode: (id: string) => void;
deselectAllNodes: () => void;
toggleNodeSelection: (id: string, scrollToNode?: boolean) => void;
expandNode: (id: string, scrollToNode?: boolean) => void;
collapseNode: (id: string) => void;
toggleExpandNode: (id: string, scrollToNode?: boolean) => void;
expandAllBelowDepth: (depth: number) => void;
collapseAllBelowDepth: (depth: number) => void;
expandLevel: (level: number) => void;
collapseLevel: (level: number) => void;
toggleExpandLevel: (level: number) => void;
selectFirstVisibleNode: (scrollToNode?: boolean) => void;
selectLastVisibleNode: (scrollToNode?: boolean) => void;
selectNextVisibleNode: (scrollToNode?: boolean) => void;
selectPreviousVisibleNode: (scrollToNode?: boolean) => void;
selectParentNode: (scrollToNode?: boolean) => void;
scrollToNode: (id: string) => void;
};
export function useTree<TData, TFilterValue>({
tree,
selectedId,
collapsedIds,
onSelectedIdChanged,
parentRef,
estimatedRowHeight,
filter,
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
const previousNodeCount = useRef(tree.length);
const previousSelectedId = useRef<string | undefined>(selectedId);
const [state, dispatch] = useReducer(
reducer,
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
);
// id -> index lookup so getNodeProps resolves in O(1) instead of scanning
// the whole tree per rendered row.
const treeIndexById = useMemo(() => {
const map = new Map<string, number>();
tree.forEach((node, index) => {
map.set(node.id, index);
});
return map;
}, [tree]);
//sync external selectedId prop into internal state
useEffect(() => {
const internalSelectedId = selectedIdFromState(state.nodes);
if (selectedId !== internalSelectedId) {
if (selectedId === undefined) {
dispatch({ type: "DESELECT_ALL_NODES" });
} else {
dispatch({
type: "SELECT_NODE",
payload: { id: selectedId, scrollToNode: false, scrollToNodeFn },
});
}
}
}, [selectedId]);
//fire onSelectedIdChanged()
useEffect(() => {
const selectedId = selectedIdFromState(state.nodes);
if (selectedId !== previousSelectedId.current) {
previousSelectedId.current = selectedId;
onSelectedIdChanged?.(selectedId);
}
}, [state.changes.selectedId]);
//update tree when the number of nodes changes
useEffect(() => {
if (tree.length !== previousNodeCount.current) {
previousNodeCount.current = tree.length;
dispatch({ type: "UPDATE_TREE", payload: { tree } });
}
}, [previousNodeCount.current, tree.length]);
//update the filter, if it's changed
const previousFilter = useRef(filter);
useEffect(() => {
//check if the value (not reference) of the filter is the same
const previousValue = previousFilter.current
? JSON.stringify(previousFilter.current.value)
: undefined;
const newValue = filter ? JSON.stringify(filter.value) : undefined;
previousFilter.current = filter;
if (previousValue !== newValue) {
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
}
}, [filter?.value]);
const virtualizer = useVirtualizer({
count: state.visibleNodeIds.length,
getItemKey: (index) => state.visibleNodeIds[index],
getScrollElement: () => parentRef.current,
estimateSize: (index: number) => {
const treeItem = tree[index];
if (!treeItem) return 0;
return estimatedRowHeight({
node: treeItem,
state: state.nodes[treeItem.id],
index,
});
},
overscan: 50,
});
const scrollToNodeFn = useCallback(
(id: string) => {
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
if (itemIndex !== -1) {
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
}
},
[state]
);
const selectNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "SELECT_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const deselectNode = useCallback(
(id: string) => {
dispatch({ type: "DESELECT_NODE", payload: { id } });
},
[state]
);
const deselectAllNodes = useCallback(() => {
dispatch({ type: "DESELECT_ALL_NODES" });
}, [state]);
const toggleNodeSelection = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_NODE_SELECTION", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const expandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const collapseNode = useCallback(
(id: string) => {
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
},
[state]
);
const toggleExpandNode = useCallback(
(id: string, scrollToNode = true) => {
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
},
[state]
);
const selectFirstVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_FIRST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectLastVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_LAST_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[tree, state]
);
const selectNextVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_NEXT_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectPreviousVisibleNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PREVIOUS_VISIBLE_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const selectParentNode = useCallback(
(scrollToNode = true) => {
dispatch({
type: "SELECT_PARENT_NODE",
payload: { scrollToNode, scrollToNodeFn },
});
},
[state]
);
const expandAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const collapseAllBelowDepth = useCallback(
(depth: number) => {
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
},
[state]
);
const expandLevel = useCallback(
(level: number) => {
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
},
[state]
);
const collapseLevel = useCallback(
(level: number) => {
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
},
[state]
);
const toggleExpandLevel = useCallback(
(level: number) => {
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
},
[state]
);
const getTreeProps = useCallback(() => {
return {
role: "tree",
"aria-multiselectable": true,
tabIndex: -1,
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
if (e.defaultPrevented) {
return; // Do nothing if the event was already processed
}
switch (e.key) {
case "Home": {
selectFirstVisibleNode(true);
e.preventDefault();
break;
}
case "End": {
selectLastVisibleNode(true);
e.preventDefault();
break;
}
case "Down":
case "ArrowDown": {
selectNextVisibleNode(true);
e.preventDefault();
break;
}
case "Up":
case "ArrowUp": {
selectPreviousVisibleNode(true);
e.preventDefault();
break;
}
case "Left":
case "ArrowLeft": {
if (e.metaKey) {
return;
}
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
collapseLevel(treeNode.level);
}
break;
}
const shouldCollapse =
treeNode && treeNode.hasChildren && state.nodes[selected].expanded;
if (shouldCollapse) {
collapseNode(selected);
} else {
selectParentNode(true);
}
}
break;
}
case "Right":
case "ArrowRight": {
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
if (selected) {
const treeNode = tree.find((node) => node.id === selected);
if (e.altKey) {
if (treeNode && treeNode.hasChildren) {
expandLevel(treeNode.level);
}
break;
}
expandNode(selected, true);
}
break;
}
case "Escape": {
deselectAllNodes();
e.preventDefault();
break;
}
}
},
};
}, [state]);
const getNodeProps = useCallback(
(id: string) => {
const node = state.nodes[id];
if (!node) return {};
const treeItemIndex = treeIndexById.get(id) ?? -1;
const treeItem = tree[treeItemIndex];
return {
"aria-expanded": node.expanded,
"aria-level": treeItem.level + 1,
role: "treeitem",
tabIndex: node.selected ? -1 : undefined,
};
},
[state, treeIndexById]
);
return {
selected: selectedIdFromState(state.nodes),
nodes: state.nodes,
getTreeProps,
getNodeProps,
selectNode,
deselectNode,
deselectAllNodes,
toggleNodeSelection,
expandNode,
collapseNode,
toggleExpandNode,
expandAllBelowDepth,
collapseAllBelowDepth,
expandLevel,
collapseLevel,
toggleExpandLevel,
selectFirstVisibleNode,
selectLastVisibleNode,
selectNextVisibleNode,
selectPreviousVisibleNode,
selectParentNode,
scrollToNode: scrollToNodeFn,
virtualizer,
};
}
/** An actual tree structure with custom data */
export type Tree<TData> = {
id: string;
runId?: string;
children?: Tree<TData>[];
data: TData;
};
/** A tree but flattened so it can easily be used for DOM elements */
export type FlatTreeItem<TData> = {
id: string;
parentId?: string | undefined;
runId?: string;
children: string[];
hasChildren: boolean;
/** The indentation level, the root is 0 */
level: number;
data: TData;
};
export type FlatTree<TData> = FlatTreeItem<TData>[];
export function flattenTree<TData>(tree: Tree<TData>): FlatTree<TData> {
const flatTree: FlatTree<TData> = [];
function flattenNode(node: Tree<TData>, parentId: string | undefined, level: number) {
const children = node.children?.map((child) => child.id) ?? [];
flatTree.push({
id: node.id,
parentId,
runId: node.runId,
children,
hasChildren: children.length > 0,
level,
data: node.data,
});
node.children?.forEach((child) => {
flattenNode(child, node.id, level + 1);
});
}
flattenNode(tree, undefined, 0);
return flatTree;
}
type FlatTreeWithoutChildren<TData> = {
id: string;
parentId: string | undefined;
runId?: string;
data: TData;
};
export function createTreeFromFlatItems<TData>(
withoutChildren: FlatTreeWithoutChildren<TData>[],
rootId: string
): Tree<TData> | undefined {
// Index items by id
const indexedItems: { [id: string]: Tree<TData> } = withoutChildren.reduce(
(acc, item) => {
acc[item.id] = { id: item.id, runId: item.runId, data: item.data, children: [] };
return acc;
},
{} as { [id: string]: Tree<TData> }
);
// Add items to parent's children array
withoutChildren.forEach((item) => {
const indexedItem = indexedItems[item.id];
if (item.parentId !== undefined) {
const parentItem = indexedItems[item.parentId];
if (parentItem) {
// If parent ID doesn't exist, this is also a root item
parentItem.children?.push(indexedItem);
}
}
});
return indexedItems[rootId];
}
@@ -0,0 +1,553 @@
import assertNever from "assert-never";
import type { Filter, FlatTree } from "./TreeView";
import {
applyFilterToState,
applyVisibility,
collapsedIdsFromState,
concreteStateFromInput,
firstVisibleNode,
generateChanges,
lastVisibleNode,
selectedIdFromState,
visibleNodes,
} from "./utils";
export type NodeState = {
selected: boolean;
expanded: boolean;
visible: boolean;
};
export type Changes = {
selectedId: string | undefined;
};
export type TreeState = {
tree: FlatTree<any>;
nodes: NodesState;
filteredNodes: NodesState;
changes: Changes;
filter: Filter<any, any> | undefined;
visibleNodeIds: string[];
};
export type NodesState = Record<string, NodeState>;
type ScrollToNodeFn = (id: string) => void;
type WithScrollToNode = {
scrollToNode: boolean;
scrollToNodeFn: ScrollToNodeFn;
};
type UpdateTreeAction = {
type: "UPDATE_TREE";
payload: {
tree: FlatTree<any>;
};
};
type SelectNodeAction = {
type: "SELECT_NODE";
payload: {
id: string;
} & WithScrollToNode;
};
type DeselectNodeAction = {
type: "DESELECT_NODE";
payload: {
id: string;
};
};
type DeselectAllNodesAction = {
type: "DESELECT_ALL_NODES";
};
type ToggleNodeSelection = {
type: "TOGGLE_NODE_SELECTION";
payload: {
id: string;
} & WithScrollToNode;
};
type ExpandNodeAction = {
type: "EXPAND_NODE";
payload: {
id: string;
} & WithScrollToNode;
};
type CollapseNodeAction = {
type: "COLLAPSE_NODE";
payload: {
id: string;
};
};
type ToggleExpandNodeAction = {
type: "TOGGLE_EXPAND_NODE";
payload: {
id: string;
} & WithScrollToNode;
};
type ExpandAllBelowDepthAction = {
type: "EXPAND_ALL_BELOW_DEPTH";
payload: {
depth: number;
};
};
type CollapseAllBelowDepthAction = {
type: "COLLAPSE_ALL_BELOW_DEPTH";
payload: {
depth: number;
};
};
type ExpandLevelAction = {
type: "EXPAND_LEVEL";
payload: {
level: number;
};
};
type CollapseLevelAction = {
type: "COLLAPSE_LEVEL";
payload: {
level: number;
};
};
type ToggleExpandLevelAction = {
type: "TOGGLE_EXPAND_LEVEL";
payload: {
level: number;
};
};
type SelectFirstVisibleNodeAction = {
type: "SELECT_FIRST_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectLastVisibleNodeAction = {
type: "SELECT_LAST_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectNextVisibleNodeAction = {
type: "SELECT_NEXT_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectPreviousVisibleNodeAction = {
type: "SELECT_PREVIOUS_VISIBLE_NODE";
payload: {} & WithScrollToNode;
};
type SelectParentNodeAction = {
type: "SELECT_PARENT_NODE";
payload: {} & WithScrollToNode;
};
type UpdateFilterAction = {
type: "UPDATE_FILTER";
payload: {
filter: Filter<any, any> | undefined;
};
};
export type Action =
| UpdateTreeAction
| SelectNodeAction
| DeselectNodeAction
| DeselectAllNodesAction
| ToggleNodeSelection
| ExpandNodeAction
| CollapseNodeAction
| ToggleExpandNodeAction
| ExpandAllBelowDepthAction
| CollapseAllBelowDepthAction
| ExpandLevelAction
| CollapseLevelAction
| ToggleExpandLevelAction
| SelectFirstVisibleNodeAction
| SelectLastVisibleNodeAction
| SelectNextVisibleNodeAction
| SelectPreviousVisibleNodeAction
| SelectParentNodeAction
| UpdateFilterAction;
export function reducer(state: TreeState, action: Action): TreeState {
switch (action.type) {
case "SELECT_NODE": {
//if the node was already selected, do nothing. The user needs to use deselectNode to deselect
const alreadySelected = state.nodes[action.payload.id]?.selected ?? false;
if (alreadySelected) {
return state;
}
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
);
newNodes[action.payload.id] = { ...newNodes[action.payload.id], selected: true };
if (action.payload.scrollToNode) {
action.payload.scrollToNodeFn(action.payload.id);
}
return applyFilterToState({
...state,
tree: state.tree,
nodes: newNodes,
changes: generateChanges(state.nodes, newNodes),
});
}
case "DESELECT_NODE": {
const nodes = {
...state.nodes,
[action.payload.id]: { ...state.nodes[action.payload.id], selected: false },
};
return applyFilterToState({
...state,
nodes,
changes: generateChanges(state.nodes, nodes),
});
}
case "DESELECT_ALL_NODES": {
const nodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
);
return applyFilterToState({
...state,
nodes,
changes: generateChanges(state.nodes, nodes),
});
}
case "TOGGLE_NODE_SELECTION": {
const currentlySelected = state.nodes[action.payload.id]?.selected ?? false;
if (currentlySelected) {
return reducer(state, { type: "DESELECT_NODE", payload: { id: action.payload.id } });
}
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: action.payload.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
case "EXPAND_NODE": {
const newNodes = {
...state.nodes,
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: true },
};
if (action.payload.scrollToNode) {
action.payload.scrollToNodeFn(action.payload.id);
}
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_NODE": {
const visibleNodes = applyVisibility(state.tree, {
...state.nodes,
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: false },
});
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "TOGGLE_EXPAND_NODE": {
const currentlyExpanded = state.nodes[action.payload.id]?.expanded ?? true;
if (currentlyExpanded) {
return reducer(state, {
type: "COLLAPSE_NODE",
payload: { id: action.payload.id },
});
}
return reducer(state, {
type: "EXPAND_NODE",
payload: {
id: action.payload.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
case "EXPAND_ALL_BELOW_DEPTH": {
const nodesToExpand = state.tree.filter(
(n) => n.level >= action.payload.depth && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_ALL_BELOW_DEPTH": {
const nodesToCollapse = state.tree.filter(
(n) => n.level >= action.payload.depth && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "EXPAND_LEVEL": {
const nodesToExpand = state.tree.filter(
(n) => n.level <= action.payload.level && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToExpand.find((n) => n.id === key) ? true : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "COLLAPSE_LEVEL": {
const nodesToCollapse = state.tree.filter(
(n) => n.level === action.payload.level && n.hasChildren
);
const newNodes = Object.fromEntries(
Object.entries(state.nodes).map(([key, value]) => [
key,
{
...value,
expanded: nodesToCollapse.find((n) => n.id === key) ? false : value.expanded,
},
])
);
const visibleNodes = applyVisibility(state.tree, newNodes);
return applyFilterToState({
...state,
nodes: visibleNodes,
changes: generateChanges(state.nodes, visibleNodes),
});
}
case "TOGGLE_EXPAND_LEVEL": {
//first get the first item at that level in the tree. If it is expanded, collapse all nodes at that level
//if it is collapsed, expand all nodes at that level
const nodesAtLevel = state.tree.filter(
(n) => n.level === action.payload.level && n.hasChildren
);
const firstNode = nodesAtLevel[0];
if (!firstNode) {
return state;
}
const currentlyExpanded = state.nodes[firstNode.id]?.expanded ?? true;
const currentVisible = state.nodes[firstNode.id]?.visible ?? true;
if (currentlyExpanded && currentVisible) {
return reducer(state, {
type: "COLLAPSE_LEVEL",
payload: {
level: action.payload.level,
},
});
}
return reducer(state, {
type: "EXPAND_LEVEL",
payload: {
level: action.payload.level,
},
});
}
case "SELECT_FIRST_VISIBLE_NODE": {
const node = firstVisibleNode(state.tree, state.filteredNodes);
if (node) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: node.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_LAST_VISIBLE_NODE": {
const node = lastVisibleNode(state.tree, state.filteredNodes);
if (node) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: node.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_NEXT_VISIBLE_NODE": {
const selected = selectedIdFromState(state.nodes);
if (!selected) {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const visible = visibleNodes(state.tree, state.filteredNodes);
const selectedIndex = visible.findIndex((node) => node.id === selected);
const nextNode = visible[selectedIndex + 1];
if (nextNode) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: nextNode.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_PREVIOUS_VISIBLE_NODE": {
const selected = selectedIdFromState(state.nodes);
if (!selected) {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const visible = visibleNodes(state.tree, state.filteredNodes);
const selectedIndex = visible.findIndex((node) => node.id === selected);
const previousNode = visible[Math.max(0, selectedIndex - 1)];
if (previousNode) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: previousNode.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "SELECT_PARENT_NODE": {
const selected = selectedIdFromState(state.nodes);
if (!selected) {
return reducer(state, {
type: "SELECT_FIRST_VISIBLE_NODE",
payload: {
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
const selectedNode = state.tree.find((node) => node.id === selected);
if (!selectedNode) {
return state;
}
const parentNode = state.tree.find((node) => node.id === selectedNode.parentId);
if (parentNode) {
return reducer(state, {
type: "SELECT_NODE",
payload: {
id: parentNode.id,
scrollToNode: action.payload.scrollToNode,
scrollToNodeFn: action.payload.scrollToNodeFn,
},
});
}
return state;
}
case "UPDATE_TREE": {
//update the tree but try and keep the selected and expanded states
const selectedId = selectedIdFromState(state.nodes);
const collapsedIds = collapsedIdsFromState(state.nodes);
const newState = concreteStateFromInput({
...state,
tree: action.payload.tree,
selectedId,
collapsedIds,
});
return newState;
}
case "UPDATE_FILTER": {
const newState = applyFilterToState({
...state,
filter: action.payload.filter,
});
return newState;
}
default: {
assertNever(action);
}
}
throw new Error(`Unhandled action type: ${(action as any).type}`);
}
@@ -0,0 +1,251 @@
import type { Filter, FlatTree } from "./TreeView";
import type { Changes, NodeState, NodesState, TreeState } from "./reducer";
type PartialNodeState = Record<string, Partial<NodeState>>;
const defaultSelected = false;
const defaultExpanded = true;
export function concreteStateFromInput({
tree,
filter,
selectedId,
collapsedIds,
}: {
tree: FlatTree<any>;
filter: Filter<any, any> | undefined;
selectedId: string | undefined;
collapsedIds: string[] | undefined;
}): TreeState {
const state: PartialNodeState = {};
collapsedIds?.forEach((id) => {
const hasTreeItem = tree.some((item) => item.id === id);
if (hasTreeItem) {
state[id] = { expanded: false };
}
});
if (selectedId) {
const selectedNode = tree.find((node) => node.id === selectedId);
if (selectedNode) {
state[selectedId] = { selected: true };
//make sure all parents are expanded
let parentId = selectedNode.parentId;
while (parentId) {
state[parentId] = { expanded: true };
const parent = tree.find((node) => node.id === parentId);
parentId = parent?.parentId;
}
}
}
const nodes = concreteStateFromPartialState(tree, state);
return applyFilterToState({
tree,
nodes,
changes: { selectedId },
filter,
filteredNodes: nodes,
visibleNodeIds: [],
});
}
export function concreteStateFromPartialState<TData>(
tree: FlatTree<TData>,
state: PartialNodeState
): NodesState {
const concreteState = tree.reduce(
(acc, node) => {
acc[node.id] = {
selected: acc[node.id]?.selected ?? defaultSelected,
expanded: acc[node.id]?.expanded ?? defaultExpanded,
visible: acc[node.id]?.visible ?? true,
};
return acc;
},
state as Record<string, NodeState>
);
return applyVisibility(tree, concreteState);
}
export function applyVisibility<TData>(tree: FlatTree<TData>, state: NodesState): NodesState {
const newState = tree.reduce((acc, node) => {
//groups are open by default
const nodeState = state[node.id] ?? {
selected: defaultSelected,
expanded: node.hasChildren ? defaultExpanded : !defaultExpanded,
};
const parent = node.parentId
? acc[node.parentId]
: { selected: defaultSelected, expanded: defaultExpanded, visible: true };
const visible = parent.expanded && parent.visible === true ? true : false;
acc[node.id] = { ...nodeState, visible };
return acc;
}, {} as NodesState);
return newState;
}
export function selectedIdFromState(state: NodesState): string | undefined {
const selected = Object.entries(state).find(([id, node]) => node.selected);
return selected?.[0];
}
export function applyFilterToState<_TData>({
tree,
nodes,
filter,
visibleNodeIds,
changes,
}: TreeState): TreeState {
if (!filter || !filter.value) {
return {
tree,
nodes,
filteredNodes: nodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
}
//we need to do two passes, first collect all the nodes that are results
const newFilteredOut = new Set<string>();
for (const node of tree) {
if (!filter.fn(filter.value, node)) {
newFilteredOut.add(node.id);
}
}
//nothing is filtered out
if (newFilteredOut.size === 0) {
return {
tree,
nodes,
filteredNodes: nodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
}
//copy of nodes
const filteredNodes = { ...nodes };
const selected = selectedIdFromState(filteredNodes);
const visible = new Set<string>();
const expanded = new Set<string>();
//figure out the state of each node
for (const node of tree) {
const shouldDisplay = !newFilteredOut.has(node.id);
//if the node is visible, make all the parents visible and expanded
if (shouldDisplay) {
//should be visible
visible.add(node.id);
//if it has children it should be expanded
if (node.hasChildren) {
expanded.add(node.id);
}
//parents need to be both visible and expanded
let parentId = node.parentId;
while (parentId) {
visible.add(parentId);
expanded.add(parentId);
parentId = tree.find((node) => node.id === parentId)?.parentId;
}
//children should be visible and if they have children expanded
if (node.hasChildren) {
const children = tree.filter((child) => child.parentId === node.id);
for (const child of children) {
visible.add(child.id);
if (child.hasChildren) {
expanded.add(child.id);
}
}
}
}
}
const allItems = new Set(tree.map((node) => node.id));
const hidden = difference(allItems, visible);
const collapsed = difference(visible, expanded);
//now set the visibility and expanded state
for (const id of hidden) {
filteredNodes[id] = { ...filteredNodes[id], visible: false };
}
for (const id of visible) {
filteredNodes[id] = { ...filteredNodes[id], visible: true };
}
for (const id of collapsed) {
filteredNodes[id] = { ...filteredNodes[id], expanded: false };
}
for (const id of expanded) {
filteredNodes[id] = { ...filteredNodes[id], expanded: true };
}
if (selected) {
if (visible.has(selected)) {
filteredNodes[selected] = { ...filteredNodes[selected], selected: true };
} else {
filteredNodes[selected] = { ...filteredNodes[selected], selected: false };
}
}
return {
tree,
nodes,
filteredNodes,
changes,
filter,
visibleNodeIds: visibleNodes(tree, filteredNodes).map((node) => node.id),
};
}
export function visibleNodes(tree: FlatTree<any>, nodes: NodesState) {
return tree.filter((node) => nodes[node.id].visible === true);
}
export function firstVisibleNode(tree: FlatTree<any>, nodes: NodesState) {
return tree.find((node) => nodes[node.id].visible === true);
}
export function lastVisibleNode(tree: FlatTree<any>, nodes: NodesState) {
return tree
.slice()
.reverse()
.find((node) => nodes[node.id].visible === true);
}
function difference<T>(a: Set<T>, b: Set<T>): Set<T> {
return new Set([...a].filter((x) => !b.has(x)));
}
export function collapsedIdsFromState(state: NodesState): string[] {
return Object.entries(state)
.filter(([_, s]) => s.expanded === false)
.map(([id]) => id);
}
export function generateChanges(a: NodesState, b: NodesState): Changes {
//if selected === defaultSelected, remove it
//if expanded === defaultExpanded, remove it
//if both are default, remove the node
const selectedIdA = selectedIdFromState(a);
const selectedIdB = selectedIdFromState(b);
const collapsedIdsA = new Set(collapsedIdsFromState(a));
const collapsedIdsB = new Set(collapsedIdsFromState(b));
const _collapsedChanges = [...difference(collapsedIdsA, collapsedIdsB)];
return {
selectedId: selectedIdA !== selectedIdB ? selectedIdB : undefined,
};
}