diff --git a/src/TreeView/TreeNode.luau b/src/TreeView/TreeNode.luau index 4fc378d8..6a4f7888 100644 --- a/src/TreeView/TreeNode.luau +++ b/src/TreeView/TreeNode.luau @@ -1,14 +1,16 @@ local React = require("@pkg/React") local Sprite = require("@root/Common/Sprite") -local treeViewTypes = require("@root/TreeView/types") +local types = require("@root/TreeView/types") +local useTreeNodeIcon = require("@root/TreeView/useTreeNodeIcon") local useTheme = require("@root/Common/useTheme") local assets = require("@root/assets") local useCallback = React.useCallback local useMemo = React.useMemo +local useState = React.useState -type TreeNode = treeViewTypes.TreeNode +type TreeNode = types.TreeNode export type Props = { node: TreeNode, @@ -18,12 +20,15 @@ export type Props = { local function TreeNode(props: Props) local theme = useTheme() + local icon, iconColor = useTreeNodeIcon(props.node.icon) + + local isExpanded, setIsExpanded = useState(props.node.isExpanded) local children = useMemo(function() local elements: { [string]: React.Node } = {} if props.node.children then for index, child in props.node.children do - elements[child.id] = React.createElement(TreeNode, { + elements[child.label] = React.createElement(TreeNode, { layoutOrder = index, node = child, }) @@ -36,18 +41,23 @@ local function TreeNode(props: Props) if props.onActivated then props.onActivated() end + + setIsExpanded(function(prev) + return not prev + end) end, { props.onActivated }) return React.createElement("ImageButton", { LayoutOrder = props.layoutOrder, AutoButtonColor = false, AutomaticSize = Enum.AutomaticSize.XY, + ClipsDescendants = true, + BackgroundTransparency = 1, [React.Event.Activated] = onActivated, }, { Layout = React.createElement("UIListLayout", { SortOrder = Enum.SortOrder.LayoutOrder, - Padding = theme.paddingSmall, }), Node = React.createElement("Frame", { @@ -62,9 +72,14 @@ local function TreeNode(props: Props) HorizontalFlex = Enum.UIFlexAlignment.Fill, }), + Padding = React.createElement("UIPadding", { + PaddingTop = theme.paddingSmall, + PaddingBottom = theme.paddingSmall, + }), + Icon = React.createElement(Sprite, { - image = assets.Component, - color = theme.story, + image = icon, + color = iconColor, layoutOrder = 1, size = UDim2.fromOffset(16, 16), }), @@ -85,35 +100,43 @@ local function TreeNode(props: Props) }), }), - Toggle = if props.node.children - then React.createElement("ImageButton", { + Toggle = if #props.node.children > 0 + then React.createElement("Frame", { LayoutOrder = 3, BackgroundTransparency = 1, - Size = UDim2.fromOffset(16, 16), + AutomaticSize = Enum.AutomaticSize.XY, }, { - Icon = React.createElement(Sprite, { - image = assets.ChevronRight, - color = theme.text, - size = UDim2.fromScale(1, 1), + RotationWrapper = React.createElement("Frame", { + BackgroundTransparency = 1, + AutomaticSize = Enum.AutomaticSize.XY, + Rotation = if isExpanded then 90 else 0, + }, { + Icon = React.createElement(Sprite, { + image = assets.ChevronRight, + color = theme.text, + size = UDim2.fromOffset(16, 16), + }), }), }) else nil, }), - Children = React.createElement("Frame", { - LayoutOrder = 2, - AutomaticSize = Enum.AutomaticSize.XY, - BackgroundTransparency = 1, - }, { - Padding = React.createElement("UIPadding", { - PaddingLeft = theme.padding, - }), + Children = if isExpanded + then React.createElement("Frame", { + LayoutOrder = 2, + AutomaticSize = Enum.AutomaticSize.XY, + BackgroundTransparency = 1, + }, { - Layout = React.createElement("UIListLayout", { - SortOrder = Enum.SortOrder.LayoutOrder, - Padding = theme.paddingSmall, - }), - }, children), + Padding = React.createElement("UIPadding", { + PaddingLeft = theme.padding, + }), + + Layout = React.createElement("UIListLayout", { + SortOrder = Enum.SortOrder.LayoutOrder, + }), + }, children) + else nil, }) end diff --git a/src/TreeView/TreeView.luau b/src/TreeView/TreeView.luau index 9b9d4cbc..cc04ced2 100644 --- a/src/TreeView/TreeView.luau +++ b/src/TreeView/TreeView.luau @@ -1,28 +1,27 @@ local React = require("@pkg/React") -local useTheme = require("@root/Common/useTheme") -local treeViewTypes = require("@root/TreeView/types") +local types = require("@root/TreeView/types") local TreeNode = require("@root/TreeView/TreeNode") +local createTreeNodesFromPartials = require("@root/TreeView/createTreeNodesFromPartials") local useCallback = React.useCallback local useMemo = React.useMemo -type TreeNode = treeViewTypes.TreeNode -type Tree = treeViewTypes.Tree +type PartialTreeNode = types.PartialTreeNode +type TreeNode = types.TreeNode +type Tree = types.Tree export type Props = { - roots: { TreeNode }, + roots: { PartialTreeNode }, expandedNodes: { TreeNode }?, onActivated: ((node: TreeNode) -> ())?, filter: ((node: TreeNode) -> boolean)?, } local function TreeView(props: Props) - local theme = useTheme() - - local nodesById = useMemo(function() end, { props.roots }) - - local leafNodes = useMemo(function() end, { props.roots }) + local roots = useMemo(function(): { TreeNode } + return createTreeNodesFromPartials(props.roots) + end, { props.roots }) local onNodeActivated = useCallback(function(node: TreeNode) if props.onActivated then @@ -31,7 +30,7 @@ local function TreeView(props: Props) end, { props.onActivated }) local children: { [string]: React.Node } = {} - for index, node in props.roots do + for index, node in roots do children[node.label] = React.createElement(TreeNode, { layoutOrder = index, node = node, @@ -47,7 +46,6 @@ local function TreeView(props: Props) }, { Layout = React.createElement("UIListLayout", { SortOrder = Enum.SortOrder.LayoutOrder, - Padding = theme.paddingSmall, }), }, children) end diff --git a/src/TreeView/TreeView.story.luau b/src/TreeView/TreeView.story.luau index 33214ac1..86f83aae 100644 --- a/src/TreeView/TreeView.story.luau +++ b/src/TreeView/TreeView.story.luau @@ -5,29 +5,95 @@ local MockPlugin = require("@root/Testing/MockPlugin") local TreeView = require("./TreeView") local types = require("./types") +type PartialTreeNode = types.PartialTreeNode + return { story = function() - local tree: types.TreeNode = { - id = "1", - label = "Top", - children = { - { - id = "2", - label = "Child", - isExpanded = true, - children = {}, + local roots: { PartialTreeNode } = { + { + label = "Pinned Storybooks", + icon = types.TreeNodeIcon.None, + isExpanded = true, + children = { + { + label = "Storybook 1", + icon = types.TreeNodeIcon.Storybook, + isExpanded = false, + children = { + -- ... + }, + }, + }, + }, + { + label = "Storybook 1", + icon = types.TreeNodeIcon.Storybook, + isExpanded = false, + children = { + -- ... }, + }, - { - id = "3", - label = "Sibling", - children = { - { - id = "4", - label = "Descendant", - isExpanded = false, - children = {}, + { + label = "Storybook 2", + icon = types.TreeNodeIcon.Storybook, + isExpanded = true, + children = { + { + label = "Folder", + icon = types.TreeNodeIcon.Folder, + isExpanded = true, + children = { + { + label = "Story 1", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 2", + icon = types.TreeNodeIcon.Story, + }, }, + } :: PartialTreeNode, + { + label = "Story 1", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 2", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 3", + icon = types.TreeNodeIcon.Story, + }, + }, + }, + { + label = "Storybook 3", + icon = types.TreeNodeIcon.Storybook, + children = { + -- ... + }, + }, + { + label = "Unnamed Storybook", + icon = types.TreeNodeIcon.Storybook, + children = { + -- ... + }, + }, + { + label = "Unknown Stories", + icon = types.TreeNodeIcon.Storybook, + isExpanded = true, + children = { + { + label = "Story 1", + icon = types.TreeNodeIcon.Story, + }, + { + label = "Story 2", + icon = types.TreeNodeIcon.Story, }, }, }, @@ -35,10 +101,15 @@ return { return React.createElement(ContextProviders, { plugin = MockPlugin.new(), }, { - TreeView = React.createElement(TreeView, { - roots = { - tree, - }, + Wrapper = React.createElement("Frame", { + Size = UDim2.fromOffset(300, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + }, { + + TreeView = React.createElement(TreeView, { + roots = roots, + }), }), }) end, diff --git a/src/TreeView/createTreeNodesFromPartials.luau b/src/TreeView/createTreeNodesFromPartials.luau new file mode 100644 index 00000000..51b8af8c --- /dev/null +++ b/src/TreeView/createTreeNodesFromPartials.luau @@ -0,0 +1,36 @@ +local Sift = require("@pkg/Sift") + +local types = require("./types") + +type PartialTreeNode = types.PartialTreeNode +type TreeNode = types.TreeNode + +local function createTreeNodesFromPartials(partialRoots: { PartialTreeNode }): { TreeNode } + local function process(partials: { PartialTreeNode }, parent: TreeNode?): { TreeNode } + local siblings: { TreeNode } = {} + + for _, partial in partials do + local base: TreeNode = { + label = "Unknown", + icon = "none", + children = {}, + parent = parent, + isExpanded = false, + } + + local node = Sift.Dictionary.join(base, partial) + + if partial.children then + node.children = if partial.children then process(partial.children, node) else {} + end + + table.insert(siblings, node) + end + + return siblings + end + + return process(partialRoots) +end + +return createTreeNodesFromPartials diff --git a/src/TreeView/createTreeNodesFromPartials.spec.luau b/src/TreeView/createTreeNodesFromPartials.spec.luau new file mode 100644 index 00000000..613e334b --- /dev/null +++ b/src/TreeView/createTreeNodesFromPartials.spec.luau @@ -0,0 +1,105 @@ +local JestGlobals = require("@pkg/JestGlobals") +local createTreeNodesFromPartials = require("./createTreeNodesFromPartials") +local types = require("./types") + +local expect = JestGlobals.expect +local test = JestGlobals.test + +type PartialTreeNode = types.PartialTreeNode + +test("top-level nodes with no children", function() + local partials: { PartialTreeNode } = { + { + label = "Node 1", + }, + { + label = "Node 2", + }, + { + label = "Node 3", + }, + } + + expect(createTreeNodesFromPartials(partials)).toEqual({ + { + label = "Node 1", + icon = "none", + isExpanded = false, + children = {}, + }, + { + label = "Node 2", + icon = "none", + isExpanded = false, + children = {}, + }, + { + label = "Node 3", + icon = "none", + isExpanded = false, + children = {}, + }, + }) +end) + +test("nodes with children", function() + local partials: { PartialTreeNode } = { + { + label = "Node 1", + children = { + { + label = "Child A 1", + children = { + { + label = "Child B 1", + }, + }, + } :: PartialTreeNode, + { + label = "Child A 2", + } :: PartialTreeNode, + }, + }, + { + label = "Node 2", + }, + } + + expect(createTreeNodesFromPartials(partials)).toEqual({ + { + label = "Node 1", + icon = "none", + isExpanded = false, + children = { + { + label = "Child A 1", + parent = expect.anything(), + icon = "none", + isExpanded = false, + children = { + { + label = "Child B 1", + parent = expect.anything(), + icon = "none", + isExpanded = false, + children = {}, + }, + }, + }, + { + label = "Child A 2", + parent = expect.anything(), + icon = "none", + isExpanded = false, + children = {}, + }, + }, + }, + { + label = "Node 2", + icon = "none", + isExpanded = false, + children = {}, + }, + }) +end) diff --git a/src/TreeView/init.luau b/src/TreeView/init.luau new file mode 100644 index 00000000..4ca486bc --- /dev/null +++ b/src/TreeView/init.luau @@ -0,0 +1,11 @@ +local types = require("./types") + +export type PartialTreeNode = types.PartialTreeNode + +return { + -- Enums + TreeNodeIcon = types.TreeNodeIcon, + + -- Components + TreeView = require("./TreeView"), +} diff --git a/src/TreeView/types.luau b/src/TreeView/types.luau index 6cc4e03d..a9ee3546 100644 --- a/src/TreeView/types.luau +++ b/src/TreeView/types.luau @@ -1,16 +1,33 @@ -export type TreeNode = { - id: string, +local types = {} + +export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" + +types.TreeNodeIcon = { + None = "none" :: "none", + Story = "story" :: "story", + Storybook = "storybook" :: "storybook", + Folder = "folder" :: "folder", +} + +export type PartialTreeNode = { label: string, - icon: string?, + icon: TreeNodeIcon?, isExpanded: boolean?, - children: { TreeNode }?, + children: { PartialTreeNode }?, +} + +export type TreeNode = { + label: string, + icon: TreeNodeIcon, + isExpanded: boolean, + children: { TreeNode }, parent: TreeNode?, } export type Tree = { - root: TreeNode, + root: { PartialTreeNode }, leafNodes: { TreeNode }, nodesById: { [string]: TreeNode }, } -return nil +return types diff --git a/src/TreeView/useTreeNodeIcon.luau b/src/TreeView/useTreeNodeIcon.luau new file mode 100644 index 00000000..92525320 --- /dev/null +++ b/src/TreeView/useTreeNodeIcon.luau @@ -0,0 +1,27 @@ +local assets = require("@root/assets") +local useTheme = require("@root/Common/useTheme") +local types = require("./types") + +type TreeNodeIcon = types.TreeNodeIcon + +type Sprite = { + Image: string, + ImageRectOffset: Vector2, + ImageRectSize: Vector2, +} + +local function useTreeNodeIcon(icon: TreeNodeIcon): (Sprite, Color3) + local theme = useTheme() + + if icon == types.TreeNodeIcon.Story then + return assets.Component, theme.story + elseif icon == types.TreeNodeIcon.Storybook then + return assets.Storybook, theme.textFaded + elseif icon == types.TreeNodeIcon.Folder then + return assets.Folder, theme.directory + else + return assets.Folder, theme.textFaded + end +end + +return useTreeNodeIcon