/* * (c) Copyright 2026 Palantir Technologies Inc. All rights reserved. */ import type { Meta, StoryObj } from "@storybook/react-vite"; import { StoryLabel } from "@storybook-common"; import { type MouseEvent, useCallback, useReducer } from "react"; import { expect, waitFor } from "storybook/test"; import { Home } from "@blueprintjs/icons"; import { Flex } from "@blueprintjs/labs"; import { Tree } from "./tree"; import type { TreeNodeInfo } from "./treeTypes"; type NodePath = number[]; type TreeAction = | { type: "SET_IS_EXPANDED"; payload: { path: NodePath; isExpanded: boolean } } | { type: "DESELECT_ALL" } | { type: "SET_IS_SELECTED"; payload: { path: NodePath; isSelected: boolean } }; function forEachNode(nodes: TreeNodeInfo[] | undefined, callback: (node: TreeNodeInfo) => void) { if (nodes === undefined) { return; } for (const node of nodes) { callback(node); forEachNode(node.childNodes, callback); } } function forNodeAtPath(nodes: TreeNodeInfo[], path: NodePath, callback: (node: TreeNodeInfo) => void) { callback(Tree.nodeFromPath(path, nodes)); } function treeExampleReducer(state: TreeNodeInfo[], action: TreeAction) { switch (action.type) { case "DESELECT_ALL": { const newState = structuredClone(state); forEachNode(newState, node => (node.isSelected = false)); return newState; } case "SET_IS_EXPANDED": { const newState = structuredClone(state); forNodeAtPath(newState, action.payload.path, node => (node.isExpanded = action.payload.isExpanded)); return newState; } case "SET_IS_SELECTED": { const newState = structuredClone(state); forNodeAtPath(newState, action.payload.path, node => (node.isSelected = action.payload.isSelected)); return newState; } default: return state; } } const SAMPLE_CONTENTS: TreeNodeInfo[] = [ { id: 0, hasCaret: true, icon: "folder-close", label: "Folder 0", isExpanded: true, childNodes: [ { id: 1, icon: "document", label: "Item 0" }, { id: 2, icon: "document", label: "Item 1" }, { id: 3, hasCaret: true, icon: "folder-close", label: "Folder 1", isExpanded: false, childNodes: [ { id: 4, icon: "document", label: "Nested Item 0" }, { id: 5, icon: "document", label: "Nested Item 1" }, ], }, ], }, { id: 6, hasCaret: true, icon: "folder-close", label: "Folder 2", isExpanded: false, childNodes: [ { id: 7, icon: "document", label: "Item 2" }, { id: 8, icon: "document", label: "Item 3" }, ], }, { id: 9, icon: "document", label: "Item 4" }, ]; const SELECTED_CONTENTS: TreeNodeInfo[] = [ { id: 0, hasCaret: true, icon: "folder-close", label: "Folder 0", isExpanded: true, childNodes: [ { id: 1, icon: "document", label: "Item 0", isSelected: true }, { id: 2, icon: "document", label: "Item 1" }, ], }, { id: 3, icon: "document", label: "Item 2" }, ]; const ELEMENT_ICON_CONTENTS: TreeNodeInfo[] = [ { id: 0, icon: "home", label: "String icon" }, { id: 1, icon: , label: "Element icon" }, ]; const DISABLED_CONTENTS: TreeNodeInfo[] = [ { id: 0, hasCaret: true, icon: "folder-close", label: "Folder 0 (disabled)", isExpanded: true, disabled: true, childNodes: [ { id: 1, icon: "document", label: "Item 0", disabled: true }, { id: 2, icon: "document", label: "Item 1", disabled: true }, ], }, { id: 3, icon: "document", label: "Item 2" }, ]; const meta: Meta = { title: "Core/Tree", component: Tree, decorators: [ Story => (
), ], tags: ["autodocs"], args: { contents: SAMPLE_CONTENTS, compact: false, }, argTypes: { compact: { control: "boolean" }, onNodeClick: { action: "nodeClick" }, onNodeCollapse: { action: "nodeCollapse" }, onNodeExpand: { action: "nodeExpand" }, onNodeContextMenu: { action: "nodeContextMenu" }, onNodeDoubleClick: { action: "nodeDoubleClick" }, onNodeMouseEnter: { action: "nodeMouseEnter" }, onNodeMouseLeave: { action: "nodeMouseLeave" }, }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = {}; /** * Use the `compact` prop for a denser tree layout. */ export const CompactExample: Story = { name: "Compact", argTypes: { compact: { table: { disable: true } }, }, args: { compact: true, }, }; /** * Tree nodes support `isSelected`, `disabled`, and `isExpanded` states. */ export const StateExample: Story = { name: "State", render: args => (
), }; /** * All tree states displayed together for visual comparison. */ export const AllStates: Story = { name: "All States", render: args => (
), }; /** * `TreeNodeInfo.icon` accepts either a string icon name or a React element. */ export const ElementIconExample: Story = { name: "Element icon", args: { contents: ELEMENT_ICON_CONTENTS }, }; /** * Interactive playground with node click, expand, and collapse handlers. */ export const Playground: Story = { render: function Render(args) { const [nodes, dispatch] = useReducer(treeExampleReducer, SAMPLE_CONTENTS); const handleNodeClick = useCallback((node: TreeNodeInfo, nodePath: NodePath, e: MouseEvent) => { const originallySelected = node.isSelected; if (!e.shiftKey) { dispatch({ type: "DESELECT_ALL" }); } dispatch({ payload: { isSelected: originallySelected == null ? true : !originallySelected, path: nodePath, }, type: "SET_IS_SELECTED", }); }, []); const handleNodeCollapse = useCallback((_node: TreeNodeInfo, nodePath: NodePath) => { dispatch({ payload: { isExpanded: false, path: nodePath }, type: "SET_IS_EXPANDED", }); }, []); const handleNodeExpand = useCallback((_node: TreeNodeInfo, nodePath: NodePath) => { dispatch({ payload: { isExpanded: true, path: nodePath }, type: "SET_IS_EXPANDED", }); }, []); return ( ); }, }; /** * Clicking a node selects it. Clicking again deselects it. */ export const SelectNode: Story = { name: "Select Node", ...Playground, play: async ({ canvas, userEvent, step }) => { const item0 = canvas.getByText("Item 0"); const item1 = canvas.getByText("Item 1"); await step("Click node to select it", async () => { await userEvent.click(item0); await expect(item0.closest(".bp6-tree-node")).toHaveClass("bp6-tree-node-selected"); }); await step("Click another node — selection moves", async () => { await userEvent.click(item1); await expect(item1.closest(".bp6-tree-node")).toHaveClass("bp6-tree-node-selected"); await expect(item0.closest(".bp6-tree-node")).not.toHaveClass("bp6-tree-node-selected"); }); await step("Click same node again to deselect", async () => { await userEvent.click(item1); await expect(item1.closest(".bp6-tree-node")).not.toHaveClass("bp6-tree-node-selected"); }); }, }; /** * Clicking the caret expands a collapsed folder and reveals its children. */ export const ExpandNode: Story = { name: "Expand Node", ...Playground, play: async ({ canvas, userEvent, step }) => { await step("Collapsed folder hides children", async () => { await expect(canvas.queryByText("Nested Item 0")).toBeNull(); }); await step("Click caret to expand folder", async () => { const folder1Caret = canvas .getByText("Folder 1") .closest(".bp6-tree-node")! .querySelector(".bp6-tree-node-caret")!; await userEvent.click(folder1Caret); await expect(canvas.getByText("Nested Item 0")).toBeVisible(); await expect(canvas.getByText("Nested Item 1")).toBeVisible(); }); }, }; /** * Clicking the caret on an expanded folder collapses it and hides its children. */ export const CollapseNode: Story = { name: "Collapse Node", ...Playground, play: async ({ canvas, userEvent, step }) => { await step("Expanded folder shows children", async () => { await expect(canvas.getByText("Item 0")).toBeVisible(); await expect(canvas.getByText("Item 1")).toBeVisible(); }); await step("Click caret to collapse folder", async () => { const folder0Caret = canvas .getByText("Folder 0") .closest(".bp6-tree-node")! .querySelector(".bp6-tree-node-caret")!; await userEvent.click(folder0Caret); await waitFor(() => expect(canvas.queryByText("Item 0")).toBeNull()); }); }, }; /** * Disabled nodes do not respond to click interactions. */ export const DisabledInteraction: Story = { name: "Disabled Interaction", render: function Render(args) { const [nodes, dispatch] = useReducer(treeExampleReducer, DISABLED_CONTENTS); const handleNodeClick = useCallback((node: TreeNodeInfo, nodePath: NodePath, e: MouseEvent) => { const originallySelected = node.isSelected; if (!e.shiftKey) { dispatch({ type: "DESELECT_ALL" }); } dispatch({ payload: { isSelected: originallySelected == null ? true : !originallySelected, path: nodePath, }, type: "SET_IS_SELECTED", }); }, []); return ; }, play: async ({ canvas, userEvent, step }) => { await step("Click disabled node — no selection", async () => { const disabledItem = canvas.getByText("Item 0"); await userEvent.click(disabledItem); await expect(disabledItem.closest(".bp6-tree-node")).not.toHaveClass("bp6-tree-node-selected"); }); await step("Click enabled node — becomes selected", async () => { const enabledItem = canvas.getByText("Item 2"); await userEvent.click(enabledItem); await expect(enabledItem.closest(".bp6-tree-node")).toHaveClass("bp6-tree-node-selected"); }); }, };