From a2af4adc6a0d9438e025eadd12eb7eb513131a90 Mon Sep 17 00:00:00 2001 From: Segun Adebayo Date: Mon, 28 Oct 2024 22:35:32 +0000 Subject: [PATCH] refactor: treeview component --- .changeset/empty-shirts-approve.md | 8 + e2e/models/tree-view.model.ts | 8 +- e2e/tree-view.e2e.ts | 6 - examples/next-ts/pages/tree-view.tsx | 145 ++++-- examples/nuxt-ts/components/TreeNode.vue | 52 ++ examples/nuxt-ts/pages/tree-view.vue | 107 +++-- examples/solid-ts/src/routes/tree-view.tsx | 144 ++++-- examples/svelte-ts/package.json | 2 +- .../svelte-ts/src/routes/tree-view.svelte | 136 ++++-- packages/machines/tree-view/package.json | 1 + packages/machines/tree-view/src/index.ts | 8 +- .../tree-view/src/tree-view.anatomy.ts | 1 + .../tree-view/src/tree-view.collection.ts | 14 + .../tree-view/src/tree-view.connect.ts | 258 +++++----- .../machines/tree-view/src/tree-view.dom.ts | 167 +------ .../tree-view/src/tree-view.machine.ts | 447 ++++++----------- .../machines/tree-view/src/tree-view.props.ts | 7 +- .../machines/tree-view/src/tree-view.types.ts | 99 ++-- .../machines/tree-view/src/tree-view.utils.ts | 20 + packages/utilities/collection/package.json | 3 +- packages/utilities/collection/src/index.ts | 4 + .../collection/src/list-collection.ts | 2 - .../collection/src/tree-collection.ts | 364 ++++++++++---- .../utilities/collection/src/tree-visit.ts | 453 ++++++++++++++++++ .../collection/tests/tree-collection.test.ts | 26 +- .../collection/tests/tree-skip.test.ts | 150 ++++++ .../utilities/collection/tests/tree.test.ts | 368 -------------- packages/utilities/core/src/array.ts | 4 +- packages/utilities/dom-query/src/get-by-id.ts | 14 +- .../utilities/dom-query/src/get-by-text.ts | 10 +- .../dom-query/src/get-by-typeahead.ts | 8 +- pnpm-lock.yaml | 61 +-- shared/src/css/tree-view.css | 62 ++- website/data/components/tree-view.mdx | 37 ++ 34 files changed, 1839 insertions(+), 1357 deletions(-) create mode 100644 .changeset/empty-shirts-approve.md create mode 100644 examples/nuxt-ts/components/TreeNode.vue create mode 100644 packages/machines/tree-view/src/tree-view.collection.ts create mode 100644 packages/machines/tree-view/src/tree-view.utils.ts create mode 100644 packages/utilities/collection/src/tree-visit.ts create mode 100644 packages/utilities/collection/tests/tree-skip.test.ts delete mode 100644 packages/utilities/collection/tests/tree.test.ts create mode 100644 website/data/components/tree-view.mdx diff --git a/.changeset/empty-shirts-approve.md b/.changeset/empty-shirts-approve.md new file mode 100644 index 0000000000..49b6f5dd0b --- /dev/null +++ b/.changeset/empty-shirts-approve.md @@ -0,0 +1,8 @@ +--- +"@zag-js/collection": minor +"@zag-js/dom-query": minor +"@zag-js/tree-view": minor +"@zag-js/utils": minor +--- + +Refactor treeview to use the new tree collection for better rendering and logic management. diff --git a/e2e/models/tree-view.model.ts b/e2e/models/tree-view.model.ts index 59c2467270..8c644c6889 100644 --- a/e2e/models/tree-view.model.ts +++ b/e2e/models/tree-view.model.ts @@ -18,12 +18,12 @@ export class TreeViewModel extends Model { return this.page.getByRole("treeitem", { name }) } - private branch(name: string) { - return this.page.locator(`[role=treeitem][data-branch="${name}"]`) + private branch(value: string) { + return this.page.locator(`[data-part=branch][data-value="${value}"]`) } - private branchTrigger(name: string) { - return this.page.locator(`[role=button][data-branch="${name}"]`) + private branchTrigger(value: string) { + return this.page.locator(`[data-part=branch-control][data-value="${value}"]`) } private button(name: string) { diff --git a/e2e/tree-view.e2e.ts b/e2e/tree-view.e2e.ts index 2ed8dbecd9..beb6fc9f28 100644 --- a/e2e/tree-view.e2e.ts +++ b/e2e/tree-view.e2e.ts @@ -29,12 +29,6 @@ test.describe("tree view / basic", () => { await I.seeItemIsTabbable("panda.config.ts") }) - test("Interaction outside should reset focused node", async ({ page }) => { - await I.focusItem("panda.config.ts") - await page.click("text=My Documents") - await I.seeBranchIsTabbable("node_modules") - }) - test("expand/collapse all button", async () => { await I.clickButton("Expand all") await I.seeBranchIsExpanded(["node_modules", "src", "node_modules/@types"]) diff --git a/examples/next-ts/pages/tree-view.tsx b/examples/next-ts/pages/tree-view.tsx index 264cd80da2..e36d51ec68 100644 --- a/examples/next-ts/pages/tree-view.tsx +++ b/examples/next-ts/pages/tree-view.tsx @@ -1,15 +1,100 @@ import { normalizeProps, useMachine } from "@zag-js/react" import { treeviewControls } from "@zag-js/shared" import * as tree from "@zag-js/tree-view" +import { FileIcon, FolderIcon, ChevronRightIcon } from "lucide-react" import { useId } from "react" import { StateVisualizer } from "../components/state-visualizer" import { Toolbar } from "../components/toolbar" import { useControls } from "../hooks/use-controls" +interface Node { + id: string + name: string + children?: Node[] +} + +const collection = tree.collection({ + nodeToValue: (node) => node.id, + nodeToString: (node) => node.name, + rootNode: { + id: "ROOT", + name: "", + children: [ + { + id: "node_modules", + name: "node_modules", + children: [ + { id: "node_modules/zag-js", name: "zag-js" }, + { id: "node_modules/pandacss", name: "panda" }, + { + id: "node_modules/@types", + name: "@types", + children: [ + { id: "node_modules/@types/react", name: "react" }, + { id: "node_modules/@types/react-dom", name: "react-dom" }, + ], + }, + ], + }, + { + id: "src", + name: "src", + children: [ + { id: "src/app.tsx", name: "app.tsx" }, + { id: "src/index.ts", name: "index.ts" }, + ], + }, + { id: "panda.config", name: "panda.config.ts" }, + { id: "package.json", name: "package.json" }, + { id: "renovate.json", name: "renovate.json" }, + { id: "readme.md", name: "README.md" }, + ], + }, +}) + +interface TreeNodeProps { + node: Node + indexPath: number[] + api: tree.Api +} + +const TreeNode = (props: TreeNodeProps): JSX.Element => { + const { node, indexPath, api } = props + + const nodeProps = { indexPath, node } + const nodeState = api.getNodeState(nodeProps) + + if (nodeState.isBranch) { + return ( +
+
+ + {node.name} + + + +
+
+
+ {node.children?.map((childNode, index) => ( + + ))} +
+
+ ) + } + + return ( +
+ {node.name} +
+ ) +} + export default function Page() { const controls = useControls(treeviewControls) - const [state, send] = useMachine(tree.machine({ id: useId() }), { + const [state, send] = useMachine(tree.machine({ id: useId(), collection }), { context: controls.context, }) @@ -20,58 +105,26 @@ export default function Page() {

My Documents

-
+
- - - - + {controls.context.selectionMode === "multiple" && ( + <> + + + + )} +
+
+ {collection.rootNode.children?.map((node, index) => ( + + ))}
- -
    -
  • -
    - 📂 node_modules -
    - -
      -
    • 📄 zag-js
    • -
    • 📄 panda
    • - -
    • -
      - 📂 @types -
      - -
        -
      • 📄 react
      • -
      • 📄 react-dom
      • -
      -
    • -
    -
  • - -
  • -
    - 📂 src -
    - -
      -
    • 📄 app.tsx
    • -
    • 📄 index.ts
    • -
    -
  • - -
  • 📄 panda.config.ts
  • -
  • 📄 package.json
  • -
  • 📄 renovate.json
  • -
  • 📄 README.md
  • -
- + ) diff --git a/examples/nuxt-ts/components/TreeNode.vue b/examples/nuxt-ts/components/TreeNode.vue new file mode 100644 index 0000000000..0e817dc0f6 --- /dev/null +++ b/examples/nuxt-ts/components/TreeNode.vue @@ -0,0 +1,52 @@ + + + diff --git a/examples/nuxt-ts/pages/tree-view.vue b/examples/nuxt-ts/pages/tree-view.vue index 8590c786d7..e0f45c9906 100644 --- a/examples/nuxt-ts/pages/tree-view.vue +++ b/examples/nuxt-ts/pages/tree-view.vue @@ -5,7 +5,52 @@ import { normalizeProps, useMachine } from "@zag-js/vue" const controls = useControls(treeviewControls) -const [state, send] = useMachine(tree.machine({ id: "1" }), { +interface Node { + id: string + name: string + children?: Node[] +} + +const collection = tree.collection({ + nodeToValue: (node) => node.id, + nodeToString: (node) => node.name, + rootNode: { + id: "ROOT", + name: "", + children: [ + { + id: "node_modules", + name: "node_modules", + children: [ + { id: "node_modules/zag-js", name: "zag-js" }, + { id: "node_modules/pandacss", name: "panda" }, + { + id: "node_modules/@types", + name: "@types", + children: [ + { id: "node_modules/@types/react", name: "react" }, + { id: "node_modules/@types/react-dom", name: "react-dom" }, + ], + }, + ], + }, + { + id: "src", + name: "src", + children: [ + { id: "src/app.tsx", name: "app.tsx" }, + { id: "src/index.ts", name: "index.ts" }, + ], + }, + { id: "panda.config", name: "panda.config.ts" }, + { id: "package.json", name: "package.json" }, + { id: "renovate.json", name: "renovate.json" }, + { id: "readme.md", name: "README.md" }, + ], + }, +}) + +const [state, send] = useMachine(tree.machine({ id: "1", collection }), { context: controls.context, }) @@ -16,58 +61,28 @@ const api = computed(() => tree.connect(state.value, send, normalizeProps))

My Documents

-
+
- - - - + +
+
+
- -
    -
  • -
    - 📂 node_modules -
    - -
      -
    • 📄 zag-js
    • -
    • 📄 panda
    • - -
    • -
      - 📂 @types -
      - -
        -
      • 📄 react
      • -
      • 📄 react-dom
      • -
      -
    • -
    -
  • - -
  • -
    - 📂 src -
    - -
      -
    • 📄 app.tsx
    • -
    • 📄 index.ts
    • -
    -
  • - -
  • 📄 panda.config.ts
  • -
  • 📄 package.json
  • -
  • 📄 renovate.json
  • -
  • 📄 README.md
  • -
- + diff --git a/examples/solid-ts/src/routes/tree-view.tsx b/examples/solid-ts/src/routes/tree-view.tsx index 3130b69db4..d8c815ba14 100644 --- a/examples/solid-ts/src/routes/tree-view.tsx +++ b/examples/solid-ts/src/routes/tree-view.tsx @@ -1,15 +1,99 @@ import { treeviewControls } from "@zag-js/shared" import { normalizeProps, useMachine } from "@zag-js/solid" import * as tree from "@zag-js/tree-view" -import { createMemo, createUniqueId } from "solid-js" +import { ChevronRightIcon, FileIcon, FolderIcon } from "lucide-solid" +import { Accessor, createMemo, createUniqueId, Index, JSX, Show } from "solid-js" import { StateVisualizer } from "~/components/state-visualizer" import { Toolbar } from "~/components/toolbar" import { useControls } from "~/hooks/use-controls" +interface Node { + id: string + name: string + children?: Node[] +} + +const collection = tree.collection({ + nodeToValue: (node) => node.id, + nodeToString: (node) => node.name, + rootNode: { + id: "ROOT", + name: "", + children: [ + { + id: "node_modules", + name: "node_modules", + children: [ + { id: "node_modules/zag-js", name: "zag-js" }, + { id: "node_modules/pandacss", name: "panda" }, + { + id: "node_modules/@types", + name: "@types", + children: [ + { id: "node_modules/@types/react", name: "react" }, + { id: "node_modules/@types/react-dom", name: "react-dom" }, + ], + }, + ], + }, + { + id: "src", + name: "src", + children: [ + { id: "src/app.tsx", name: "app.tsx" }, + { id: "src/index.ts", name: "index.ts" }, + ], + }, + { id: "panda.config", name: "panda.config.ts" }, + { id: "package.json", name: "package.json" }, + { id: "renovate.json", name: "renovate.json" }, + { id: "readme.md", name: "README.md" }, + ], + }, +}) + +interface TreeNodeProps { + node: Node + indexPath: number[] + api: Accessor +} + +const TreeNode = (props: TreeNodeProps): JSX.Element => { + const { node, indexPath, api } = props + const nodeProps = { indexPath, node } + const nodeState = createMemo(() => api().getNodeState(nodeProps)) + return ( + + {node.name} +
+ } + > +
+
+ + {node.name} + + + +
+
+
+ + {(childNode, index) => } + +
+
+ + ) +} + export default function Page() { const controls = useControls(treeviewControls) - const [state, send] = useMachine(tree.machine({ id: createUniqueId() }), { + const [state, send] = useMachine(tree.machine({ id: createUniqueId(), collection }), { context: controls.context, }) @@ -20,58 +104,24 @@ export default function Page() {

My Documents

-
+
- - - - + + + + +
+
+ + {(node, index) => } +
- -
    -
  • -
    - 📂 node_modules -
    - -
      -
    • 📄 zag-js
    • -
    • 📄 panda
    • - -
    • -
      - 📂 @types -
      - -
        -
      • 📄 react
      • -
      • 📄 react-dom
      • -
      -
    • -
    -
  • - -
  • -
    - 📂 src -
    - -
      -
    • 📄 app.tsx
    • -
    • 📄 index.ts
    • -
    -
  • - -
  • 📄 panda.config.ts
  • -
  • 📄 package.json
  • -
  • 📄 renovate.json
  • -
  • 📄 README.md
  • -
- + ) diff --git a/examples/svelte-ts/package.json b/examples/svelte-ts/package.json index a171ebe22c..d22faee706 100644 --- a/examples/svelte-ts/package.json +++ b/examples/svelte-ts/package.json @@ -93,7 +93,7 @@ "@sveltejs/vite-plugin-svelte": "4.0.0-next.3", "@tsconfig/svelte": "5.0.4", "@types/form-serialize": "0.7.4", - "svelte": "5.0.0-next.262", + "svelte": "5.1.2", "svelte-check": "4.0.4", "tslib": "2.7.0", "typescript": "5.6.2", diff --git a/examples/svelte-ts/src/routes/tree-view.svelte b/examples/svelte-ts/src/routes/tree-view.svelte index 81949b4834..c3721ada96 100644 --- a/examples/svelte-ts/src/routes/tree-view.svelte +++ b/examples/svelte-ts/src/routes/tree-view.svelte @@ -5,66 +5,112 @@ import { treeviewControls } from "@zag-js/shared" import { normalizeProps, useMachine } from "@zag-js/svelte" import * as tree from "@zag-js/tree-view" + import { FileIcon, FolderIcon, ChevronRightIcon } from "lucide-svelte" const controls = useControls(treeviewControls) - const [snapshot, send] = useMachine(tree.machine({ id: "1" }), { + interface Node { + id: string + name: string + children?: Node[] + } + + interface TreeNodeProps { + node: Node + indexPath: number[] + api: tree.Api + } + + const collection = tree.collection({ + nodeToValue: (node) => node.id, + nodeToString: (node) => node.name, + rootNode: { + id: "ROOT", + name: "", + children: [ + { + id: "node_modules", + name: "node_modules", + children: [ + { id: "node_modules/zag-js", name: "zag-js" }, + { id: "node_modules/pandacss", name: "panda" }, + { + id: "node_modules/@types", + name: "@types", + children: [ + { id: "node_modules/@types/react", name: "react" }, + { id: "node_modules/@types/react-dom", name: "react-dom" }, + ], + }, + ], + }, + { + id: "src", + name: "src", + children: [ + { id: "src/app.tsx", name: "app.tsx" }, + { id: "src/index.ts", name: "index.ts" }, + ], + }, + { id: "panda.config", name: "panda.config.ts" }, + { id: "package.json", name: "package.json" }, + { id: "renovate.json", name: "renovate.json" }, + { id: "readme.md", name: "README.md" }, + ], + }, + }) + + const [snapshot, send] = useMachine(tree.machine({ id: "1", collection }), { context: controls.context, }) const api = $derived(tree.connect(snapshot, send, normalizeProps)) +{#snippet treeNode(nodeProps: TreeNodeProps)} + {@const { node, indexPath, api } = nodeProps} + {@const nodeState = api.getNodeState({ indexPath, node })} + + {#if nodeState.isBranch} +
+
+ + {node.name} + + + +
+
+
+ {#each node.children || [] as childNode, index} + {@render treeNode({ node: childNode, indexPath: [...indexPath, index], api })} + {/each} +
+
+ {:else} +
+ + {node.name} +
+ {/if} +{/snippet} +

My Documents

-
+
- - - - + {#if controls.context.selectionMode === "multiple"} + + + {/if} +
+
+ {#each collection.rootNode.children || [] as node, index} + {@render treeNode({ node, indexPath: [index], api })} + {/each}
- -
    -
  • -
    - 📂 node_modules -
    - -
      -
    • 📄 zag-js
    • -
    • 📄 panda
    • - -
    • -
      - 📂 @types -
      - -
        -
      • 📄 react
      • -
      • 📄 react-dom
      • -
      -
    • -
    -
  • - -
  • -
    - 📂 src -
    - -
      -
    • 📄 app.tsx
    • -
    • 📄 index.ts
    • -
    -
  • - -
  • 📄 panda.config.ts
  • -
  • 📄 package.json
  • -
  • 📄 renovate.json
  • -
  • 📄 README.md
  • -
diff --git a/packages/machines/tree-view/package.json b/packages/machines/tree-view/package.json index da023889b5..1b374f4093 100644 --- a/packages/machines/tree-view/package.json +++ b/packages/machines/tree-view/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@zag-js/anatomy": "workspace:*", + "@zag-js/collection": "workspace:*", "@zag-js/core": "workspace:*", "@zag-js/dom-query": "workspace:*", "@zag-js/dom-event": "workspace:*", diff --git a/packages/machines/tree-view/src/index.ts b/packages/machines/tree-view/src/index.ts index c0f66bddb2..696b851106 100644 --- a/packages/machines/tree-view/src/index.ts +++ b/packages/machines/tree-view/src/index.ts @@ -1,16 +1,16 @@ export { anatomy } from "./tree-view.anatomy" +export { collection, filePathCollection } from "./tree-view.collection" export { connect } from "./tree-view.connect" export { machine } from "./tree-view.machine" export type { MachineApi as Api, - BranchProps, - BranchState, UserDefinedContext as Context, ElementIds, ExpandedChangeDetails, FocusChangeDetails, - ItemProps, - ItemState, + NodeProps, + NodeState, SelectionChangeDetails, Service, + TreeNode, } from "./tree-view.types" diff --git a/packages/machines/tree-view/src/tree-view.anatomy.ts b/packages/machines/tree-view/src/tree-view.anatomy.ts index 6f310ddb48..dd765c3b65 100644 --- a/packages/machines/tree-view/src/tree-view.anatomy.ts +++ b/packages/machines/tree-view/src/tree-view.anatomy.ts @@ -13,6 +13,7 @@ export const anatomy = createAnatomy("tree-view").parts( "branchContent", "branchText", "branchIndicator", + "branchIndentGuide", ) export const parts = anatomy.build() diff --git a/packages/machines/tree-view/src/tree-view.collection.ts b/packages/machines/tree-view/src/tree-view.collection.ts new file mode 100644 index 0000000000..847d999ef0 --- /dev/null +++ b/packages/machines/tree-view/src/tree-view.collection.ts @@ -0,0 +1,14 @@ +import { type FilePathTreeNode, TreeCollection, type TreeCollectionOptions, filePathToTree } from "@zag-js/collection" +import { ref } from "@zag-js/core" + +export const collection = (options: TreeCollectionOptions): TreeCollection => { + return ref(new TreeCollection(options)) +} + +collection.empty = (): TreeCollection => { + return ref(new TreeCollection({ rootNode: { children: [] } })) +} + +export function filePathCollection(paths: string[]): TreeCollection { + return ref(filePathToTree(paths)) +} diff --git a/packages/machines/tree-view/src/tree-view.connect.ts b/packages/machines/tree-view/src/tree-view.connect.ts index 4c734eeb89..5e57f9a70a 100644 --- a/packages/machines/tree-view/src/tree-view.connect.ts +++ b/packages/machines/tree-view/src/tree-view.connect.ts @@ -1,88 +1,90 @@ import { getEventKey, isModifierKey, type EventKeyMap } from "@zag-js/dom-event" -import { contains, dataAttr, getEventTarget, isComposingEvent, isEditableElement } from "@zag-js/dom-query" +import { dataAttr, getEventTarget, isComposingEvent, isEditableElement } from "@zag-js/dom-query" import type { NormalizeProps, PropTypes } from "@zag-js/types" +import { add, isEqual, remove, uniq } from "@zag-js/utils" import { parts } from "./tree-view.anatomy" import { dom } from "./tree-view.dom" -import type { BranchProps, BranchState, ItemProps, ItemState, MachineApi, Send, State } from "./tree-view.types" +import type { MachineApi, NodeProps, NodeState, Send, State } from "./tree-view.types" +import { getVisibleNodes } from "./tree-view.utils" export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi { + const collection = state.context.collection const expandedValue = Array.from(state.context.expandedValue) const selectedValue = Array.from(state.context.selectedValue) const isTypingAhead = state.context.isTypingAhead const focusedValue = state.context.focusedValue - function getItemState(props: ItemProps): ItemState { + function getNodeState(props: NodeProps): NodeState { + const { node, indexPath } = props + const value = collection.getNodeValue(node) return { - value: props.value, - disabled: Boolean(props.disabled), - focused: focusedValue === props.value, - selected: selectedValue.includes(props.value), - } - } - - function getBranchState(props: BranchProps): BranchState { - return { - value: props.value, - disabled: Boolean(props.disabled), - focused: focusedValue === props.value, - expanded: expandedValue.includes(props.value), - selected: selectedValue.includes(props.value), + value, + valuePath: collection.getValuePath(indexPath), + disabled: Boolean(node.disabled), + focused: focusedValue == null ? isEqual(indexPath, [0]) : focusedValue === value, + selected: selectedValue.includes(value), + expanded: expandedValue.includes(value), + depth: indexPath.length, + isBranch: collection.isBranchNode(node), } } return { - expandedValue: expandedValue, - selectedValue: selectedValue, + collection, + expandedValue, + selectedValue, expand(value) { - if (!value) { - send({ type: "EXPANDED.ALL" }) - return - } - const nextValue = new Set(expandedValue) - value.forEach((id) => nextValue.add(id)) - send({ type: "EXPANDED.SET", value: nextValue, src: "expand" }) + if (!value) return send({ type: "EXPANDED.ALL" }) + const _expandedValue = uniq(expandedValue.concat(...value)) + send({ type: "EXPANDED.SET", value: _expandedValue, src: "expand" }) }, collapse(value) { - if (!value) { - send({ type: "EXPANDED.SET", value: new Set([]), src: "collapseAll" }) - return - } - const nextValue = new Set(expandedValue) - value.forEach((id) => nextValue.delete(id)) - send({ type: "EXPANDED.SET", value: nextValue, src: "collapse" }) + if (!value) return send({ type: "EXPANDED.SET", value: [], src: "collapseAll" }) + const _expandedValue = uniq(remove(expandedValue, ...value)) + send({ type: "EXPANDED.SET", value: _expandedValue, src: "collapse" }) }, deselect(value) { - if (!value) { - send({ type: "SELECTED.SET", value: new Set([]), src: "deselectAll" }) - return - } - const nextValue = new Set(selectedValue) - value.forEach((id) => nextValue.delete(id)) - send({ type: "SELECTED.SET", value: nextValue, src: "deselect" }) + if (!value) return send({ type: "SELECTED.SET", value: [], src: "deselectAll" }) + const _selectedValue = uniq(remove(selectedValue, ...value)) + send({ type: "SELECTED.SET", value: _selectedValue, src: "deselect" }) }, select(value) { - if (!value) { - send({ type: "SELECTED.ALL" }) - return - } - const nextValue = new Set() + if (!value) return send({ type: "SELECTED.ALL" }) + const nextValue: string[] = [] if (state.context.selectionMode === "single") { // For single selection, only add the last item - if (value.length > 0) { - nextValue.add(value[value.length - 1]) - } + if (value.length > 0) nextValue.push(value[value.length - 1]) } else { // For multiple selection, add all items - value.forEach((id) => nextValue.add(id)) - selectedValue.forEach((id) => nextValue.add(id)) + nextValue.push(...selectedValue, ...value) } send({ type: "SELECTED.SET", value: nextValue, src: "select" }) }, - focusBranch(id) { - dom.getBranchControlEl(state.context, id)?.focus() + getVisibleNodes() { + return getVisibleNodes(state.context) + }, + focus(value) { + dom.focusNode(state.context, value) + }, + selectParent(value) { + const parentNode = collection.getParentNode(value) + if (!parentNode) return + const _selectedValue = add(selectedValue, collection.getNodeValue(parentNode)) + send({ type: "SELECTED.SET", value: _selectedValue, src: "select.parent" }) + }, + expandParent(value) { + const parentNode = collection.getParentNode(value) + if (!parentNode) return + const _expandedValue = add(expandedValue, collection.getNodeValue(parentNode)) + send({ type: "EXPANDED.SET", value: _expandedValue, src: "expand.parent" }) }, - focusItem(id) { - dom.getItemEl(state.context, id)?.focus() + setExpandedValue(value) { + const _expandedValue = uniq(value) + send({ type: "EXPANDED.SET", value: _expandedValue }) + }, + setSelectedValue(value) { + const _selectedValue = uniq(value) + send({ type: "SELECTED.SET", value: _selectedValue }) }, getRootProps() { @@ -110,6 +112,7 @@ export function connect(state: State, send: Send, normalize "aria-label": "Tree View", "aria-labelledby": dom.getLabelId(state.context), "aria-multiselectable": state.context.selectionMode === "multiple" || undefined, + tabIndex: -1, onKeyDown(event) { if (event.defaultPrevented) return if (isComposingEvent(event)) return @@ -118,47 +121,48 @@ export function connect(state: State, send: Send, normalize // allow typing in input elements within the tree if (isEditableElement(target)) return - const node = target?.closest("[role=treeitem]") + const node = target?.closest("[data-part=branch-control], [data-part=item]") if (!node) return - const nodeId = dom.getNodeId(node) + const nodeId = node.dataset.value + if (nodeId == null) { - console.warn(`Node id not found for node`, node) + console.warn(`[zag-js/tree-view] Node id not found for node`, node) return } - const isBranchNode = !!target?.dataset.branch + const isBranchNode = node.matches("[data-part=branch-control]") const keyMap: EventKeyMap = { ArrowDown(event) { if (isModifierKey(event)) return event.preventDefault() - send({ type: "ITEM.ARROW_DOWN", id: nodeId, shiftKey: event.shiftKey }) + send({ type: "NODE.ARROW_DOWN", id: nodeId, shiftKey: event.shiftKey }) }, ArrowUp(event) { if (isModifierKey(event)) return event.preventDefault() - send({ type: "ITEM.ARROW_UP", id: nodeId, shiftKey: event.shiftKey }) + send({ type: "NODE.ARROW_UP", id: nodeId, shiftKey: event.shiftKey }) }, ArrowLeft(event) { if (isModifierKey(event) || node.dataset.disabled) return event.preventDefault() - send({ type: isBranchNode ? "BRANCH.ARROW_LEFT" : "ITEM.ARROW_LEFT", id: nodeId }) + send({ type: isBranchNode ? "BRANCH_NODE.ARROW_LEFT" : "NODE.ARROW_LEFT", id: nodeId }) }, ArrowRight(event) { if (!isBranchNode || node.dataset.disabled) return event.preventDefault() - send({ type: "BRANCH.ARROW_RIGHT", id: nodeId }) + send({ type: "BRANCH_NODE.ARROW_RIGHT", id: nodeId }) }, Home(event) { if (isModifierKey(event)) return event.preventDefault() - send({ type: "ITEM.HOME", id: nodeId, shiftKey: event.shiftKey }) + send({ type: "NODE.HOME", id: nodeId, shiftKey: event.shiftKey }) }, End(event) { if (isModifierKey(event)) return event.preventDefault() - send({ type: "ITEM.END", id: nodeId, shiftKey: event.shiftKey }) + send({ type: "NODE.END", id: nodeId, shiftKey: event.shiftKey }) }, Space(event) { if (node.dataset.disabled) return @@ -175,17 +179,17 @@ export function connect(state: State, send: Send, normalize const isLink = target?.closest("a[href]") if (!isLink) event.preventDefault() - send({ type: isBranchNode ? "BRANCH.CLICK" : "ITEM.CLICK", id: nodeId, src: "keyboard" }) + send({ type: isBranchNode ? "BRANCH_NODE.CLICK" : "NODE.CLICK", id: nodeId, src: "keyboard" }) }, "*"(event) { if (node.dataset.disabled) return event.preventDefault() - send({ type: "EXPAND.SIBLINGS", id: nodeId }) + send({ type: "SIBLINGS.EXPAND", id: nodeId }) }, a(event) { if (!event.metaKey || node.dataset.disabled) return event.preventDefault() - send({ type: "SELECTED.ALL", preventScroll: true, moveFocus: true }) + send({ type: "SELECTED.ALL", moveFocus: true }) }, } @@ -205,21 +209,20 @@ export function connect(state: State, send: Send, normalize send({ type: "TREE.TYPEAHEAD", key: event.key, id: nodeId }) event.preventDefault() }, - onBlur(event) { - if (contains(event.currentTarget, event.relatedTarget)) return - send({ type: "TREE.BLUR" }) - }, }) }, - getItemState, + getNodeState, + getItemProps(props) { - const itemState = getItemState(props) + const itemState = getNodeState(props) return normalize.element({ ...parts.item.attrs, + id: dom.getNodeId(state.context, itemState.value), dir: state.context.dir, "data-ownedby": dom.getTreeId(state.context), - "data-item": itemState.value, + "data-path": props.indexPath.join("/"), + "data-value": itemState.value, tabIndex: itemState.focused ? 0 : -1, "data-focus": dataAttr(itemState.focused), role: "treeitem", @@ -228,19 +231,19 @@ export function connect(state: State, send: Send, normalize "data-selected": dataAttr(itemState.selected), "aria-disabled": itemState.disabled, "data-disabled": dataAttr(itemState.disabled), - "aria-level": props.depth, - "data-depth": props.depth, + "aria-level": itemState.depth, + "data-depth": itemState.depth, style: { - "--depth": props.depth, + "--depth": itemState.depth, }, onFocus(event) { event.stopPropagation() - send({ type: "ITEM.FOCUS", id: itemState.value }) + send({ type: "NODE.FOCUS", id: itemState.value }) }, onClick(event) { if (itemState.disabled) return const isMetaKey = event.metaKey || event.ctrlKey - send({ type: "ITEM.CLICK", id: itemState.value, shiftKey: event.shiftKey, ctrlKey: isMetaKey }) + send({ type: "NODE.CLICK", id: itemState.value, shiftKey: event.shiftKey, ctrlKey: isMetaKey }) event.stopPropagation() const isLink = event.currentTarget.matches("a[href]") @@ -250,7 +253,7 @@ export function connect(state: State, send: Send, normalize }, getItemTextProps(props) { - const itemState = getItemState(props) + const itemState = getNodeState(props) return normalize.element({ ...parts.itemText.attrs, "data-disabled": dataAttr(itemState.disabled), @@ -260,7 +263,7 @@ export function connect(state: State, send: Send, normalize }, getItemIndicatorProps(props) { - const itemState = getItemState(props) + const itemState = getNodeState(props) return normalize.element({ ...parts.itemIndicator.attrs, "aria-hidden": true, @@ -271,104 +274,113 @@ export function connect(state: State, send: Send, normalize }) }, - getBranchState, getBranchProps(props) { - const branchState = getBranchState(props) + const nodeState = getNodeState(props) return normalize.element({ ...parts.branch.attrs, - "data-depth": props.depth, + "data-depth": nodeState.depth, dir: state.context.dir, - "data-branch": branchState.value, + "data-branch": nodeState.value, role: "treeitem", "data-ownedby": dom.getTreeId(state.context), - "aria-level": props.depth, - "aria-selected": branchState.disabled ? undefined : branchState.selected, - "data-selected": dataAttr(branchState.selected), - "aria-expanded": branchState.expanded, - "data-state": branchState.expanded ? "open" : "closed", - "aria-disabled": branchState.disabled, - "data-disabled": dataAttr(branchState.disabled), + "data-value": nodeState.value, + "aria-level": nodeState.depth, + "aria-selected": nodeState.disabled ? undefined : nodeState.selected, + "data-path": props.indexPath.join("/"), + "data-selected": dataAttr(nodeState.selected), + "aria-expanded": nodeState.expanded, + "data-state": nodeState.expanded ? "open" : "closed", + "aria-disabled": nodeState.disabled, + "data-disabled": dataAttr(nodeState.disabled), style: { - "--depth": props.depth, + "--depth": nodeState.depth, }, }) }, getBranchIndicatorProps(props) { - const branchState = getBranchState(props) + const nodeState = getNodeState(props) return normalize.element({ ...parts.branchIndicator.attrs, "aria-hidden": true, - "data-state": branchState.expanded ? "open" : "closed", - "data-disabled": dataAttr(branchState.disabled), - "data-selected": dataAttr(branchState.selected), - "data-focus": dataAttr(branchState.focused), + "data-state": nodeState.expanded ? "open" : "closed", + "data-disabled": dataAttr(nodeState.disabled), + "data-selected": dataAttr(nodeState.selected), + "data-focus": dataAttr(nodeState.focused), }) }, getBranchTriggerProps(props) { - const branchState = getBranchState(props) + const nodeState = getNodeState(props) return normalize.element({ ...parts.branchTrigger.attrs, role: "button", dir: state.context.dir, - "data-disabled": dataAttr(branchState.disabled), - "data-state": branchState.expanded ? "open" : "closed", + "data-disabled": dataAttr(nodeState.disabled), + "data-state": nodeState.expanded ? "open" : "closed", + "data-value": nodeState.value, onClick(event) { - if (branchState.disabled) return - send({ type: "BRANCH_TOGGLE.CLICK", id: branchState.value }) + if (nodeState.disabled) return + send({ type: "BRANCH_TOGGLE.CLICK", id: nodeState.value }) event.stopPropagation() }, }) }, getBranchControlProps(props) { - const branchState = getBranchState(props) + const nodeState = getNodeState(props) return normalize.element({ ...parts.branchControl.attrs, role: "button", + id: dom.getNodeId(state.context, nodeState.value), dir: state.context.dir, - tabIndex: branchState.focused ? 0 : -1, - "data-state": branchState.expanded ? "open" : "closed", - "data-disabled": dataAttr(branchState.disabled), - "data-selected": dataAttr(branchState.selected), - "data-branch": branchState.value, - "data-depth": props.depth, + tabIndex: nodeState.focused ? 0 : -1, + "data-path": props.indexPath.join("/"), + "data-state": nodeState.expanded ? "open" : "closed", + "data-disabled": dataAttr(nodeState.disabled), + "data-selected": dataAttr(nodeState.selected), + "data-focus": dataAttr(nodeState.focused), + "data-value": nodeState.value, + "data-depth": nodeState.depth, onFocus(event) { - send({ type: "ITEM.FOCUS", id: branchState.value }) + send({ type: "NODE.FOCUS", id: nodeState.value }) event.stopPropagation() }, onClick(event) { - if (branchState.disabled) return - + if (nodeState.disabled) return const isMetaKey = event.metaKey || event.ctrlKey - send({ type: "BRANCH.CLICK", id: branchState.value, shiftKey: event.shiftKey, ctrlKey: isMetaKey }) - + send({ type: "BRANCH_NODE.CLICK", id: nodeState.value, shiftKey: event.shiftKey, ctrlKey: isMetaKey }) event.stopPropagation() }, }) }, getBranchTextProps(props) { - const branchState = getBranchState(props) + const nodeState = getNodeState(props) return normalize.element({ ...parts.branchText.attrs, dir: state.context.dir, - "data-branch": branchState.value, - "data-disabled": dataAttr(branchState.disabled), - "data-state": branchState.expanded ? "open" : "closed", + "data-disabled": dataAttr(nodeState.disabled), + "data-state": nodeState.expanded ? "open" : "closed", }) }, getBranchContentProps(props) { - const branchState = getBranchState(props) + const nodeState = getNodeState(props) return normalize.element({ ...parts.branchContent.attrs, role: "group", dir: state.context.dir, - "data-branch": branchState.value, - "data-state": branchState.expanded ? "open" : "closed", - hidden: !branchState.expanded, + "data-state": nodeState.expanded ? "open" : "closed", + hidden: !nodeState.expanded, + }) + }, + + getBranchIndentGuideProps(props) { + const nodeState = getNodeState(props) + return normalize.element({ + ...parts.branchIndentGuide.attrs, + "data-depth": nodeState.depth, }) }, } diff --git a/packages/machines/tree-view/src/tree-view.dom.ts b/packages/machines/tree-view/src/tree-view.dom.ts index 12020777cf..2bb4162843 100644 --- a/packages/machines/tree-view/src/tree-view.dom.ts +++ b/packages/machines/tree-view/src/tree-view.dom.ts @@ -1,159 +1,28 @@ -import { createScope, getByTypeahead, isHTMLElement, isHiddenElement, query, queryAll } from "@zag-js/dom-query" +import { createScope, getByTypeahead } from "@zag-js/dom-query" import type { MachineContext as Ctx } from "./tree-view.types" - -interface TreeWalkerOpts { - skipHidden?: boolean | undefined - root?: HTMLElement | null | undefined -} +import { getVisibleNodes } from "./tree-view.utils" export const dom = createScope({ - getRootId: (ctx: Ctx) => ctx.ids?.root ?? `tree-root:${ctx.id}`, - getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `tree-label:${ctx.id}`, - getTreeId: (ctx: Ctx) => ctx.ids?.tree ?? `tree-tree:${ctx.id}`, - - getNodeId(node: Node | null | undefined) { - if (!isHTMLElement(node)) return null - return node.dataset.branch ?? node.dataset.item ?? null - }, - - getNodeEl(ctx: Ctx, id: string) { - const node = dom.getItemEl(ctx, id) ?? dom.getBranchEl(ctx, id) - if (node?.dataset.part === "branch") { - return query(node, "[data-part=branch-control]") - } - return node - }, - - getTreeEl(ctx: Ctx) { - return dom.getById(ctx, dom.getTreeId(ctx)) - }, - - getBranchEl(ctx: Ctx, id: string) { - const selector = `[role=treeitem][data-branch="${id}"]` - return query(dom.getTreeEl(ctx), selector) - }, - getItemEl(ctx: Ctx, id: string) { - const selector = `[role=treeitem][data-item="${id}"]` - return query(dom.getTreeEl(ctx), selector) - }, - getBranchControlEl(ctx: Ctx, id: string) { - const selector = "[data-part=branch-control]" - return query(dom.getBranchEl(ctx, id), selector) - }, - - getFocusedEl(ctx: Ctx) { - if (!ctx.focusedValue) return null - return dom.getById(ctx, ctx.focusedValue) - }, - - focusNode(node: Node | Element | null | undefined, options?: FocusOptions) { - if (isHTMLElement(node)) node.focus(options) - }, - - getNodeDepth(node: HTMLElement | null) { - return node?.dataset.depth ? Number(node.dataset.depth) : -1 - }, - - getTreeWalker(ctx: Ctx, opts?: TreeWalkerOpts) { - const { skipHidden = true, root } = opts ?? {} - - const treeEl = root || dom.getTreeEl(ctx) - if (!treeEl) throw new Error("Tree or branch root not found") - - const doc = dom.getDoc(ctx) - - return doc.createTreeWalker(treeEl, NodeFilter.SHOW_ELEMENT, { - acceptNode(node: HTMLElement) { - if (skipHidden && isHiddenElement(node)) { - return NodeFilter.FILTER_REJECT - } - - if (node.role === "treeitem" && node.dataset.part !== "branch") { - return NodeFilter.FILTER_ACCEPT - } - - if (node.role === "button" && node.dataset.part === "branch-control") { - return NodeFilter.FILTER_ACCEPT - } - - return NodeFilter.FILTER_SKIP - }, - }) - }, - - getMatchingEl(ctx: Ctx, key: string) { - const walker = dom.getTreeWalker(ctx) - - const elements: HTMLElement[] = [] - let node = walker.firstChild() - - while (node) { - if (isHTMLElement(node)) elements.push(node) - node = walker.nextNode() - } - + getRootId: (ctx: Ctx) => ctx.ids?.root ?? `tree:${ctx.id}:root`, + getLabelId: (ctx: Ctx) => ctx.ids?.label ?? `tree:${ctx.id}:label`, + getNodeId: (ctx: Ctx, value: string) => ctx.ids?.node?.(value) ?? `tree:${ctx.id}:node:${value}`, + getTreeId: (ctx: Ctx) => ctx.ids?.tree ?? `tree:${ctx.id}:tree`, + getTreeEl: (ctx: Ctx) => dom.getById(ctx, dom.getTreeId(ctx)), + focusNode: (ctx: Ctx, value: string | null | undefined) => { + if (value == null) return + const nodeId = dom.getNodeId(ctx, value) + dom.getById(ctx, nodeId)?.focus({ preventScroll: true }) + }, + getMatchingNode(ctx: Ctx, key: string) { + const nodes = getVisibleNodes(ctx) + const elements = nodes.map(({ node }) => ({ + textContent: ctx.collection.stringifyNode(node), + id: ctx.collection.getNodeValue(node), + })) return getByTypeahead(elements, { state: ctx.typeaheadState, key, activeId: ctx.focusedValue, - itemToId: (v) => dom.getNodeId(v) ?? v.id, }) }, - - getTreeNodes(ctx: Ctx, options: TreeWalkerOpts = {}) { - const walker = dom.getTreeWalker(ctx, options) - - const nodes: HTMLElement[] = [] - let node = walker.firstChild() - - while (node) { - if (isHTMLElement(node)) { - nodes.push(node) - } - node = walker.nextNode() - } - - return nodes - }, - - getBranchNodes(ctx: Ctx, depth: number | null) { - if (depth === -1) return [] - return queryAll(dom.getTreeEl(ctx), `[role=treeitem][data-part=branch][data-depth="${depth}"]`) - }, - - getNodesInRange(nodes: HTMLElement[], startNode: HTMLElement, endNode: HTMLElement) { - const nextSet = new Set() - - nodes.forEach((node) => { - const nodeId = dom.getNodeId(node) - if (nodeId == null) return - - // compare node position with firstSelectedEl and focusedEl - // if node is between firstSelectedEl and focusedEl, add it to nextSet - if (node === startNode || node === endNode) { - nextSet.add(nodeId) - return - } - - // use node.compareDocumentPosition to compare node position - // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition - - const startPos = node.compareDocumentPosition(startNode) - const endPos = node.compareDocumentPosition(endNode) - - // if node is before firstSelectedEl and after focusedEl, add it to nextSet - if (startPos & Node.DOCUMENT_POSITION_FOLLOWING && endPos & Node.DOCUMENT_POSITION_PRECEDING) { - nextSet.add(nodeId) - return - } - - // if node is after firstSelectedEl and before focusedEl, add it to nextSet - if (startPos & Node.DOCUMENT_POSITION_PRECEDING && endPos & Node.DOCUMENT_POSITION_FOLLOWING) { - nextSet.add(nodeId) - return - } - }) - - return Array.from(nextSet) - }, }) diff --git a/packages/machines/tree-view/src/tree-view.machine.ts b/packages/machines/tree-view/src/tree-view.machine.ts index bcd9f6a268..ffe4dfe033 100644 --- a/packages/machines/tree-view/src/tree-view.machine.ts +++ b/packages/machines/tree-view/src/tree-view.machine.ts @@ -1,8 +1,10 @@ import { createMachine, guards } from "@zag-js/core" -import { getByTypeahead, isHTMLElement, observeChildren } from "@zag-js/dom-query" -import { compact } from "@zag-js/utils" +import { getByTypeahead } from "@zag-js/dom-query" +import { add, addOrRemove, compact, first, isEqual, remove, uniq } from "@zag-js/utils" +import { collection } from "./tree-view.collection" import { dom } from "./tree-view.dom" import type { MachineContext, MachineState, UserDefinedContext } from "./tree-view.types" +import { getVisibleNodes, skipFn } from "./tree-view.utils" const { and } = guards @@ -20,6 +22,7 @@ export function machine(userContext: UserDefinedContext) { selectionMode: "single", typeahead: true, ...ctx, + collection: ctx.collection ?? collection.empty(), typeaheadState: getByTypeahead.defaultOptions, }, @@ -37,11 +40,11 @@ export function machine(userContext: UserDefinedContext) { "SELECTED.ALL": [ { guard: and("isMultipleSelection", "moveFocus"), - actions: ["selectAllItems", "focusTreeLastItem"], + actions: ["selectAllNodes", "focusTreeLastNode"], }, { guard: "isMultipleSelection", - actions: ["selectAllItems"], + actions: ["selectAllNodes"], }, ], "EXPANDED.ALL": { @@ -49,114 +52,107 @@ export function machine(userContext: UserDefinedContext) { }, }, - activities: ["trackChildrenMutation"], - - entry: ["setFocusableNode"], - states: { idle: { on: { - "ITEM.FOCUS": { - actions: ["setFocusedItem"], + "NODE.FOCUS": { + actions: ["setFocusedNode"], }, - "ITEM.ARROW_DOWN": [ + "NODE.ARROW_DOWN": [ { guard: and("isShiftKey", "isMultipleSelection"), - actions: ["focusTreeNextItem", "extendSelectionToNextItem"], + actions: ["focusTreeNextNode", "extendSelectionToNextNode"], }, { - actions: ["focusTreeNextItem"], + actions: ["focusTreeNextNode"], }, ], - "ITEM.ARROW_UP": [ + "NODE.ARROW_UP": [ { guard: and("isShiftKey", "isMultipleSelection"), - actions: ["focusTreePrevItem", "extendSelectionToPrevItem"], + actions: ["focusTreePrevNode", "extendSelectionToPrevNode"], }, { - actions: ["focusTreePrevItem"], + actions: ["focusTreePrevNode"], }, ], - "ITEM.ARROW_LEFT": { - actions: ["focusBranchControl"], + "NODE.ARROW_LEFT": { + actions: ["focusBranchNode"], }, - "BRANCH.ARROW_LEFT": [ + "BRANCH_NODE.ARROW_LEFT": [ { guard: "isBranchExpanded", actions: ["collapseBranch"], }, { - actions: ["focusBranchControl"], + actions: ["focusBranchNode"], }, ], - "BRANCH.ARROW_RIGHT": [ + "BRANCH_NODE.ARROW_RIGHT": [ { guard: and("isBranchFocused", "isBranchExpanded"), - actions: ["focusBranchFirstItem"], + actions: ["focusBranchFirstNode"], }, { actions: ["expandBranch"], }, ], - "EXPAND.SIBLINGS": { + "SIBLINGS.EXPAND": { actions: ["expandSiblingBranches"], }, - "ITEM.HOME": [ + "NODE.HOME": [ { guard: and("isShiftKey", "isMultipleSelection"), - actions: ["extendSelectionToFirstItem", "focusTreeFirstItem"], + actions: ["extendSelectionToFirstNode", "focusTreeFirstNode"], }, { - actions: ["focusTreeFirstItem"], + actions: ["focusTreeFirstNode"], }, ], - "ITEM.END": [ + "NODE.END": [ { guard: and("isShiftKey", "isMultipleSelection"), - actions: ["extendSelectionToLastItem", "focusTreeLastItem"], + actions: ["extendSelectionToLastNode", "focusTreeLastNode"], }, { - actions: ["focusTreeLastItem"], + actions: ["focusTreeLastNode"], }, ], - "ITEM.CLICK": [ + "NODE.CLICK": [ { guard: and("isCtrlKey", "isMultipleSelection"), - actions: ["addOrRemoveItemFromSelection"], + actions: ["toggleNodeSelection"], }, { guard: and("isShiftKey", "isMultipleSelection"), - actions: ["extendSelectionToItem"], + actions: ["extendSelectionToNode"], }, { - actions: ["selectItem"], + actions: ["selectNode"], }, ], - "BRANCH.CLICK": [ + "BRANCH_NODE.CLICK": [ { guard: and("isCtrlKey", "isMultipleSelection"), - actions: ["addOrRemoveItemFromSelection"], + actions: ["toggleNodeSelection"], }, { guard: and("isShiftKey", "isMultipleSelection"), - actions: ["extendSelectionToItem"], + actions: ["extendSelectionToNode"], }, { guard: "openOnClick", - actions: ["selectItem", "toggleBranch"], + actions: ["selectNode", "toggleBranchNode"], }, { - actions: ["selectItem"], + actions: ["selectNode"], }, ], "BRANCH_TOGGLE.CLICK": { - actions: ["toggleBranch"], + actions: ["toggleBranchNode"], }, "TREE.TYPEAHEAD": { - actions: ["focusMatchedItem"], - }, - "TREE.BLUR": { - actions: ["clearFocusedItem", "setFocusableNode"], + actions: ["focusMatchedNode"], }, }, }, @@ -173,98 +169,27 @@ export function machine(userContext: UserDefinedContext) { moveFocus: (_ctx, evt) => !!evt.moveFocus, openOnClick: (ctx) => !!ctx.expandOnClick, }, - activities: { - trackChildrenMutation(ctx, _evt, { send }) { - const treeEl = dom.getTreeEl(ctx) - return observeChildren(treeEl, { - callback(records) { - const removedNodes = records - .flatMap((r) => Array.from(r.removedNodes)) - .filter((node) => { - if (!isHTMLElement(node)) return false - return node.matches("[role=treeitem]") || node.matches("[role=group]") - }) - - if (!removedNodes.length) return - - let elementToFocus: HTMLElement | null = null - records.forEach((record) => { - if (isHTMLElement(record.nextSibling)) { - elementToFocus = record.nextSibling - } else if (isHTMLElement(record.previousSibling)) { - elementToFocus = record.previousSibling - } - }) - - if (elementToFocus) { - dom.focusNode(elementToFocus) - } - - const removedIds: Set = new Set() - removedNodes.forEach((node) => { - const nodeId = dom.getNodeId(node) - if (isHTMLElement(node) && nodeId != null) { - removedIds.add(nodeId) - } - }) - - const nextSet = new Set(ctx.selectedValue) - removedIds.forEach((id) => nextSet.delete(id)) - send({ type: "SELECTED.SET", value: removedIds }) - }, - }) - }, - }, actions: { - setFocusableNode(ctx) { - if (ctx.focusedValue) return - - if (ctx.selectedValue.length > 0) { - const firstSelectedId = Array.from(ctx.selectedValue)[0] - ctx.focusedValue = firstSelectedId - return - } - - const walker = dom.getTreeWalker(ctx) - const firstItem = walker.firstChild() - - if (!isHTMLElement(firstItem)) return - // don't use set.focused here because it will trigger focusChange event - ctx.focusedValue = dom.getNodeId(firstItem) - }, - selectItem(ctx, evt) { + selectNode(ctx, evt) { set.selected(ctx, [evt.id]) }, - setFocusedItem(ctx, evt) { + setFocusedNode(ctx, evt) { set.focused(ctx, evt.id) }, - clearFocusedItem(ctx) { + clearFocusedNode(ctx) { set.focused(ctx, null) }, clearSelectedItem(ctx) { set.selected(ctx, []) }, - toggleBranch(ctx, evt) { - const nextSet = new Set(ctx.expandedValue) - - if (nextSet.has(evt.id)) { - nextSet.delete(evt.id) - // collapseEffect(ctx, evt) - } else { - nextSet.add(evt.id) - } - - set.expanded(ctx, Array.from(nextSet)) + toggleBranchNode(ctx, evt) { + set.expanded(ctx, addOrRemove(ctx.expandedValue, evt.id)) }, expandBranch(ctx, evt) { - const nextSet = new Set(ctx.expandedValue) - nextSet.add(evt.id) - set.expanded(ctx, Array.from(nextSet)) + set.expanded(ctx, add(ctx.expandedValue, evt.id)) }, collapseBranch(ctx, evt) { - const nextSet = new Set(ctx.expandedValue) - nextSet.delete(evt.id) - set.expanded(ctx, Array.from(nextSet)) + set.expanded(ctx, remove(ctx.expandedValue, evt.id)) }, setExpanded(ctx, evt) { set.expanded(ctx, evt.value) @@ -272,203 +197,144 @@ export function machine(userContext: UserDefinedContext) { setSelected(ctx, evt) { set.selected(ctx, evt.value) }, - focusTreeFirstItem(ctx) { - const walker = dom.getTreeWalker(ctx) - dom.focusNode(walker.firstChild()) + focusTreeFirstNode(ctx) { + const firstNode = ctx.collection.getFirstNode() + const firstValue = ctx.collection.getNodeValue(firstNode) + dom.focusNode(ctx, firstValue) }, - focusTreeLastItem(ctx, evt) { - const walker = dom.getTreeWalker(ctx) - dom.focusNode(walker.lastChild(), { preventScroll: evt.preventScroll }) + focusTreeLastNode(ctx) { + const lastNode = ctx.collection.getLastNode() + const lastValue = ctx.collection.getNodeValue(lastNode) + dom.focusNode(ctx, lastValue) }, - focusBranchFirstItem(ctx, evt) { - const focusedEl = dom.getNodeEl(ctx, evt.id) - if (!focusedEl) return - - const walker = dom.getTreeWalker(ctx) - - walker.currentNode = focusedEl - dom.focusNode(walker.nextNode()) + focusBranchFirstNode(ctx, evt) { + const branchNode = ctx.collection.findNode(evt.id) + const firstNode = ctx.collection.getFirstNode(branchNode) + const firstValue = ctx.collection.getNodeValue(firstNode) + dom.focusNode(ctx, firstValue) }, - focusTreeNextItem(ctx, evt) { - const focusedEl = dom.getNodeEl(ctx, evt.id) - if (!focusedEl) return - - const walker = dom.getTreeWalker(ctx) - - if (ctx.focusedValue) { - walker.currentNode = focusedEl - const nextNode = walker.nextNode() - dom.focusNode(nextNode) - } else { - dom.focusNode(walker.firstChild()) - } + focusTreeNextNode(ctx, evt) { + let nextNode = ctx.collection.getNextNode(evt.id, { skip: skipFn(ctx) }) + nextNode = nextNode ?? ctx.collection.getFirstNode() + const nextValue = ctx.collection.getNodeValue(nextNode) + dom.focusNode(ctx, nextValue) }, - focusTreePrevItem(ctx, evt) { - const focusedEl = dom.getNodeEl(ctx, evt.id) - if (!focusedEl) return - - const walker = dom.getTreeWalker(ctx) - - if (ctx.focusedValue) { - walker.currentNode = focusedEl - const prevNode = walker.previousNode() - dom.focusNode(prevNode) - } else { - dom.focusNode(walker.lastChild()) - } + focusTreePrevNode(ctx, evt) { + let prevNode = ctx.collection.getPreviousNode(evt.id, { skip: skipFn(ctx) }) + prevNode = prevNode ?? ctx.collection.getLastNode() + const prevValue = ctx.collection.getNodeValue(prevNode) + dom.focusNode(ctx, prevValue) }, - focusBranchControl(ctx, evt) { - const focusedEl = dom.getNodeEl(ctx, evt.id) - if (!focusedEl) return - - const parentDepth = Number(focusedEl.dataset.depth) - 1 - if (parentDepth < 0) return - - const branchSelector = `[data-part=branch][data-depth="${parentDepth}"]` - const closestBranch = focusedEl.closest(branchSelector) - - const branchControl = closestBranch?.querySelector("[data-part=branch-control]") - dom.focusNode(branchControl) + focusBranchNode(ctx, evt) { + const parentNode = ctx.collection.getParentNode(evt.id) + const parentValue = parentNode ? ctx.collection.getNodeValue(parentNode) : undefined + dom.focusNode(ctx, parentValue) }, - selectAllItems(ctx) { - const nextSet = new Set() - const walker = dom.getTreeWalker(ctx) - let node = walker.firstChild() - while (node) { - const nodeId = dom.getNodeId(node) - if (isHTMLElement(node) && nodeId != null) { - nextSet.add(nodeId) - } - node = walker.nextNode() - } - set.selected(ctx, Array.from(nextSet)) + selectAllNodes(ctx) { + set.selected(ctx, ctx.collection.getValues()) }, - focusMatchedItem(ctx, evt) { - dom.focusNode(dom.getMatchingEl(ctx, evt.key)) + focusMatchedNode(ctx, evt) { + const node = dom.getMatchingNode(ctx, evt.key) + dom.focusNode(ctx, node?.id) }, - addOrRemoveItemFromSelection(ctx, evt) { - const focusedEl = dom.getNodeEl(ctx, evt.id) - if (!focusedEl) return - - const nextSet = new Set(ctx.selectedValue) - - const nodeId = dom.getNodeId(focusedEl) - if (nodeId == null) return - - if (nextSet.has(nodeId)) { - nextSet.delete(nodeId) - } else { - nextSet.add(nodeId) - } - - set.selected(ctx, Array.from(nextSet)) + toggleNodeSelection(ctx, evt) { + const selectedValue = addOrRemove(ctx.selectedValue, evt.id) + set.selected(ctx, selectedValue) }, expandAllBranches(ctx) { - const nextSet = new Set() - const walker = dom.getTreeWalker(ctx, { skipHidden: false }) - while (walker.nextNode()) { - const node = walker.currentNode - const nodeId = dom.getNodeId(node) - if (isHTMLElement(node) && node.dataset.part === "branch-control" && nodeId != null) { - nextSet.add(nodeId) - } - } - set.expanded(ctx, Array.from(nextSet)) + const nextValue = ctx.collection.getBranchValues() + set.expanded(ctx, nextValue) }, expandSiblingBranches(ctx, evt) { - const focusedEl = dom.getNodeEl(ctx, evt.id) - const nodes = dom.getBranchNodes(ctx, dom.getNodeDepth(focusedEl)) - - const nextSet = new Set() - nodes.forEach((node) => { - const nodeId = dom.getNodeId(node) - if (nodeId == null) return - nextSet.add(nodeId) - }) - - set.expanded(ctx, Array.from(nextSet)) + const indexPath = ctx.collection.getIndexPath(evt.id) + if (!indexPath) return + const nodes = ctx.collection.getSiblingNodes(indexPath) + const values = nodes.map((node) => ctx.collection.getNodeValue(node)) + set.expanded(ctx, uniq(values)) }, - extendSelectionToItem(ctx, evt) { - const focusedEl = dom.getNodeEl(ctx, evt.id) - if (!focusedEl) return - - const nodes = dom.getTreeNodes(ctx) - const selectedIds = Array.from(ctx.selectedValue) - const anchorEl = dom.getNodeEl(ctx, selectedIds[0]) || nodes[0] - - const nextSet = dom.getNodesInRange(nodes, anchorEl, focusedEl) - - set.selected(ctx, nextSet) + extendSelectionToNode(ctx, evt) { + const anchorValue = first(ctx.selectedValue) || ctx.collection.getNodeValue(ctx.collection.getFirstNode()) + const targetValue = evt.id + + let values: string[] = [anchorValue, targetValue] + + let hits = 0 + const visibleNodes = getVisibleNodes(ctx) + visibleNodes.forEach(({ node }) => { + const nodeValue = ctx.collection.getNodeValue(node) + if (hits === 1) values.push(nodeValue) + if (nodeValue === anchorValue || nodeValue === targetValue) hits++ + }) + set.selected(ctx, uniq(values)) }, - extendSelectionToNextItem(ctx, evt) { - const nodeId = evt.id - - const currentNode = dom.getNodeEl(ctx, nodeId) - if (!currentNode) return - - const walker = dom.getTreeWalker(ctx) - walker.currentNode = currentNode - - const nextNode = walker.nextNode() - dom.focusNode(nextNode) + extendSelectionToNextNode(ctx, evt) { + const nextNode = ctx.collection.getNextNode(evt.id, { skip: skipFn(ctx) }) + if (!nextNode) return // extend selection to nextNode (preserve the anchor node) - const selectedIds = new Set(ctx.selectedValue) - const nextNodeId = dom.getNodeId(nextNode) + const values = new Set(ctx.selectedValue) + const nextValue = ctx.collection.getNodeValue(nextNode) - if (nextNodeId == null) return + if (nextValue == null) return - if (selectedIds.has(nodeId) && selectedIds.has(nextNodeId)) { - selectedIds.delete(nodeId) - } else if (!selectedIds.has(nextNodeId)) { - selectedIds.add(nextNodeId) + if (values.has(evt.id) && values.has(nextValue)) { + values.delete(evt.id) + } else if (!values.has(nextValue)) { + values.add(nextValue) } - set.selected(ctx, Array.from(selectedIds)) + set.selected(ctx, Array.from(values)) }, - extendSelectionToPrevItem(ctx, evt) { - const nodeId = evt.id - - const currentNode = dom.getNodeEl(ctx, nodeId) - if (!currentNode) return - - const walker = dom.getTreeWalker(ctx) - walker.currentNode = currentNode - - const prevNode = walker.previousNode() - dom.focusNode(prevNode) + extendSelectionToPrevNode(ctx, evt) { + const prevNode = ctx.collection.getPreviousNode(evt.id, { skip: skipFn(ctx) }) + if (!prevNode) return // extend selection to prevNode (preserve the anchor node) - const selectedIds = new Set(ctx.selectedValue) - const prevNodeId = dom.getNodeId(prevNode) + const values = new Set(ctx.selectedValue) + const prevValue = ctx.collection.getNodeValue(prevNode) - if (prevNodeId == null) return + if (prevValue == null) return - if (selectedIds.has(nodeId) && selectedIds.has(prevNodeId)) { - selectedIds.delete(nodeId) - } else if (!selectedIds.has(prevNodeId)) { - selectedIds.add(prevNodeId) + if (values.has(evt.id) && values.has(prevValue)) { + values.delete(evt.id) + } else if (!values.has(prevValue)) { + values.add(prevValue) } - set.selected(ctx, Array.from(selectedIds)) + set.selected(ctx, Array.from(values)) }, - extendSelectionToFirstItem(ctx) { - const nodes = dom.getTreeNodes(ctx) - - const anchorEl = dom.getNodeEl(ctx, [...ctx.selectedValue][0]) || nodes[0] - const focusedEl = nodes[0] + extendSelectionToFirstNode(ctx) { + const currentSelection = first(ctx.selectedValue) + const values: string[] = [] + + ctx.collection.visit({ + skip: skipFn(ctx), + onEnter: (node) => { + const nodeValue = ctx.collection.getNodeValue(node) + values.push(nodeValue) + if (nodeValue === currentSelection) { + return "stop" + } + }, + }) - const selectedIds = dom.getNodesInRange(nodes, anchorEl, focusedEl) - set.selected(ctx, selectedIds) + set.selected(ctx, values) }, - extendSelectionToLastItem(ctx) { - const nodes = dom.getTreeNodes(ctx) - - const anchorEl = dom.getNodeEl(ctx, [...ctx.selectedValue][0]) || nodes[0] - const focusedEl = nodes[nodes.length - 1] + extendSelectionToLastNode(ctx) { + const currentSelection = first(ctx.selectedValue) + const values: string[] = [] + let current = false + + ctx.collection.visit({ + skip: skipFn(ctx), + onEnter: (node) => { + const nodeValue = ctx.collection.getNodeValue(node) + if (nodeValue === currentSelection) current = true + if (current) values.push(nodeValue) + }, + }) - const selectedIds = dom.getNodesInRange(nodes, anchorEl, focusedEl) - set.selected(ctx, selectedIds) + set.selected(ctx, values) }, }, }, @@ -477,12 +343,12 @@ export function machine(userContext: UserDefinedContext) { const invoke = { focusChange(ctx: MachineContext) { - ctx.onFocusChange?.({ focusedValue: ctx.focusedValue! }) + ctx.onFocusChange?.({ focusedValue: ctx.focusedValue }) }, expandedChange(ctx: MachineContext) { ctx.onExpandedChange?.({ expandedValue: Array.from(ctx.expandedValue), - focusedValue: ctx.focusedValue!, + focusedValue: ctx.focusedValue, }) }, selectionChange(ctx: MachineContext) { @@ -495,14 +361,17 @@ const invoke = { const set = { selected(ctx: MachineContext, value: string[]) { + if (isEqual(ctx.selectedValue, value)) return ctx.selectedValue = value invoke.selectionChange(ctx) }, focused(ctx: MachineContext, value: string | null) { + if (isEqual(ctx.focusedValue, value)) return ctx.focusedValue = value invoke.focusChange(ctx) }, expanded(ctx: MachineContext, value: string[]) { + if (isEqual(ctx.expandedValue, value)) return ctx.expandedValue = value invoke.expandedChange(ctx) }, diff --git a/packages/machines/tree-view/src/tree-view.props.ts b/packages/machines/tree-view/src/tree-view.props.ts index 28b3946818..4833541395 100644 --- a/packages/machines/tree-view/src/tree-view.props.ts +++ b/packages/machines/tree-view/src/tree-view.props.ts @@ -1,9 +1,10 @@ import { createProps } from "@zag-js/types" import { createSplitProps } from "@zag-js/utils" -import type { ItemProps, UserDefinedContext } from "./tree-view.types" +import type { NodeProps, UserDefinedContext } from "./tree-view.types" export const props = createProps()([ "ids", + "collection", "dir", "expandedValue", "expandOnClick", @@ -20,6 +21,6 @@ export const props = createProps()([ export const splitProps = createSplitProps>(props) -export const itemProps = createProps()(["depth", "value", "disabled"]) +export const itemProps = createProps()(["node", "indexPath"]) -export const splitItemProps = createSplitProps(itemProps) +export const splitItemProps = createSplitProps(itemProps) diff --git a/packages/machines/tree-view/src/tree-view.types.ts b/packages/machines/tree-view/src/tree-view.types.ts index fea1b9ac83..e4cb9f33aa 100644 --- a/packages/machines/tree-view/src/tree-view.types.ts +++ b/packages/machines/tree-view/src/tree-view.types.ts @@ -1,3 +1,4 @@ +import type { TreeCollection, TreeNode } from "@zag-js/collection" import type { Machine, StateMachine as S } from "@zag-js/core" import type { TypeaheadState } from "@zag-js/dom-query" import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types" @@ -22,13 +23,18 @@ export type ElementIds = Partial<{ root: string tree: string label: string + node(value: string): string }> /* ----------------------------------------------------------------------------- * Machine context * -----------------------------------------------------------------------------*/ -interface PublicContext extends DirectionProperty, CommonProperties { +interface PublicContext extends DirectionProperty, CommonProperties { + /** + * The tree collection data + */ + collection: TreeCollection /** * The ids of the tree elements. Useful for composition. */ @@ -98,9 +104,9 @@ type ComputedContext = Readonly<{ isMultipleSelection: boolean }> -export type UserDefinedContext = RequiredBy +export type UserDefinedContext = RequiredBy -export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {} +export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {} export interface MachineState { value: "idle" @@ -116,28 +122,26 @@ export type Service = Machine * Component API * -----------------------------------------------------------------------------*/ -export interface ItemProps { - /** - * The depth of the item or branch - */ - depth: number +export interface NodeProps { /** - * The id of the item or branch + * The tree node */ - value: string + node: TreeNode /** - * Whether the item or branch is disabled + * The index path of the tree node */ - disabled?: boolean | undefined + indexPath: number[] } -export interface BranchProps extends ItemProps {} - -export interface ItemState { +export interface NodeState { /** * The value of the tree item */ value: string + /** + * The value path of the tree item + */ + valuePath: string[] /** * Whether the tree item is disabled */ @@ -150,24 +154,45 @@ export interface ItemState { * Whether the tree item is focused */ focused: boolean -} - -export interface BranchState extends ItemState { + /** + * The depth of the tree item + */ + depth: number /** * Whether the tree branch is expanded */ expanded: boolean + /** + * Whether the tree item is a branch + */ + isBranch: boolean } -export interface MachineApi { +export interface MachineApi { + /** + * The tree collection data + */ + collection: TreeCollection /** * The id of the expanded nodes */ expandedValue: string[] + /** + * Function to set the expanded value + */ + setExpandedValue(value: string[]): void /** * The id of the selected nodes */ selectedValue: string[] + /** + * Function to set the selected value + */ + setSelectedValue(value: string[]): void + /** + * Function to get the visible nodes + */ + getVisibleNodes(): V[] /** * Function to expand nodes. * If no value is provided, all nodes will be expanded @@ -189,26 +214,32 @@ export interface MachineApi { */ deselect(value?: string[]): void /** - * Function to focus a branch node + * Function to focus an item node */ - focusBranch(value: string): void + focus(value: string): void /** - * Function to focus an item node + * Function to select the parent node of the focused node */ - focusItem(value: string): void + selectParent(value: string): void + /** + * Function to expand the parent node of the focused node + */ + expandParent(value: string): void getRootProps(): T["element"] getLabelProps(): T["element"] getTreeProps(): T["element"] - getItemState(props: ItemProps): ItemState - getItemProps(props: ItemProps): T["element"] - getItemIndicatorProps(props: ItemProps): T["element"] - getItemTextProps(props: ItemProps): T["element"] - getBranchState(props: BranchProps): BranchState - getBranchProps(props: BranchProps): T["element"] - getBranchIndicatorProps(props: BranchProps): T["element"] - getBranchTriggerProps(props: BranchProps): T["element"] - getBranchControlProps(props: BranchProps): T["element"] - getBranchContentProps(props: BranchProps): T["element"] - getBranchTextProps(props: BranchProps): T["element"] + getNodeState(props: NodeProps): NodeState + getItemProps(props: NodeProps): T["element"] + getItemIndicatorProps(props: NodeProps): T["element"] + getItemTextProps(props: NodeProps): T["element"] + getBranchProps(props: NodeProps): T["element"] + getBranchIndicatorProps(props: NodeProps): T["element"] + getBranchTriggerProps(props: NodeProps): T["element"] + getBranchControlProps(props: NodeProps): T["element"] + getBranchContentProps(props: NodeProps): T["element"] + getBranchTextProps(props: NodeProps): T["element"] + getBranchIndentGuideProps(props: NodeProps): T["element"] } + +export type { TreeNode } from "@zag-js/collection" diff --git a/packages/machines/tree-view/src/tree-view.utils.ts b/packages/machines/tree-view/src/tree-view.utils.ts new file mode 100644 index 0000000000..c6c7f3a5af --- /dev/null +++ b/packages/machines/tree-view/src/tree-view.utils.ts @@ -0,0 +1,20 @@ +import type { TreeNode, TreeSkipFn } from "@zag-js/collection" +import type { MachineContext } from "./tree-view.types" + +export function skipFn(ctx: MachineContext): TreeSkipFn { + return function skip({ indexPath }) { + const paths = ctx.collection.getValuePath(indexPath).slice(0, -1) + return paths.some((value) => !ctx.expandedValue.includes(value)) + } +} + +export function getVisibleNodes(ctx: MachineContext) { + const nodes: { node: TreeNode; indexPath: number[] }[] = [] + ctx.collection.visit({ + skip: skipFn(ctx), + onEnter: (node, indexPath) => { + nodes.push({ node, indexPath }) + }, + }) + return nodes +} diff --git a/packages/utilities/collection/package.json b/packages/utilities/collection/package.json index 844ece0307..718a212bd6 100644 --- a/packages/utilities/collection/package.json +++ b/packages/utilities/collection/package.json @@ -30,8 +30,7 @@ "url": "https://github.com/chakra-ui/zag/issues" }, "dependencies": { - "@zag-js/utils": "workspace:*", - "tree-visit": "0.4.2" + "@zag-js/utils": "workspace:*" }, "clean-package": "../../../clean-package.config.json", "devDependencies": { diff --git a/packages/utilities/collection/src/index.ts b/packages/utilities/collection/src/index.ts index d2eb0135e0..3e4fa7478b 100644 --- a/packages/utilities/collection/src/index.ts +++ b/packages/utilities/collection/src/index.ts @@ -3,7 +3,11 @@ export { TreeCollection, type TreeCollectionOptions, type TreeCollectionMethods, + type TreeNode, + type FlatTreeNode, + type TreeSkipFn, filePathToTree, + type FilePathTreeNode, flattenedToTree, } from "./tree-collection" export { ListCollection } from "./list-collection" diff --git a/packages/utilities/collection/src/list-collection.ts b/packages/utilities/collection/src/list-collection.ts index 2e18de5d11..e6ccc73d45 100644 --- a/packages/utilities/collection/src/list-collection.ts +++ b/packages/utilities/collection/src/list-collection.ts @@ -269,9 +269,7 @@ export class ListCollection { reorder(fromIndex: number, toIndex: number) { if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return - const [removed] = this.items.splice(fromIndex, 1) - this.items.splice(toIndex, 0, removed) } diff --git a/packages/utilities/collection/src/tree-collection.ts b/packages/utilities/collection/src/tree-collection.ts index cfb42e1487..99d3366161 100644 --- a/packages/utilities/collection/src/tree-collection.ts +++ b/packages/utilities/collection/src/tree-collection.ts @@ -1,35 +1,56 @@ -import { compact, hasProp, isObject } from "@zag-js/utils" -import { access, find, findIndexPath, flatMap, insert, visit } from "tree-visit" - -export class TreeCollection { - items: T +import { compact, hasProp, isEqual, isObject } from "@zag-js/utils" +import { + access, + find, + findIndexPath, + flatMap, + insert, + visit, + type VisitOptions, + replace, + move, + compareIndexPaths, +} from "./tree-visit" + +export class TreeCollection { + rootNode: T constructor(private options: TreeCollectionOptions) { - this.items = options.items + this.rootNode = options.rootNode + } + + isEqual(other: TreeCollection) { + return isEqual(this.rootNode, other.rootNode) + } + + getNodeChildren = (node: T) => { + return this.options.nodeToChildren?.(node) ?? fallback.nodeToChildren(node) ?? [] } - getItemChildren = (node: T) => { - return this.options.itemToChildren?.(node) ?? fallback.itemToChildren(node) ?? [] + getNodeValue = (node: T) => { + return this.options.nodeToValue?.(node) ?? fallback.nodeToValue(node) } - getItemValue = (node: T) => { - return this.options.itemToValue?.(node) ?? fallback.itemToValue(node) + getNodeDisabled = (node: T) => { + return this.options.isNodeDisabled?.(node) ?? fallback.isNodeDisabled(node) } - getItemDisabled = (node: T) => { - return this.options.isItemDisabled?.(node) ?? fallback.isItemDisabled(node) + stringify = (value: string) => { + const node = this.findNode(value) + if (!node) return null + return this.stringifyNode(node) } - stringify = (node: T) => { - return this.options.itemToString?.(node) ?? fallback.itemToString(node) + stringifyNode = (node: T) => { + return this.options.nodeToString?.(node) ?? fallback.nodeToString(node) } - getFirstNode = (items = this.items): T | undefined => { + getFirstNode = (rootNode = this.rootNode): T | undefined => { let firstChild: T | undefined - visit(items, { - getChildren: this.getItemChildren, + visit(rootNode, { + getChildren: this.getNodeChildren, onEnter: (node, indexPath) => { - if (!firstChild && indexPath.length > 0 && !this.getItemDisabled(node)) { + if (!firstChild && indexPath.length > 0 && !this.getNodeDisabled(node)) { firstChild = node return "stop" } @@ -38,14 +59,15 @@ export class TreeCollection { return firstChild } - getLastNode = (items = this.items, opts: SkipProperty = {}): T | undefined => { + getLastNode = (rootNode = this.rootNode, opts: SkipProperty = {}): T | undefined => { let lastChild: T | undefined - visit(items, { - getChildren: this.getItemChildren, + visit(rootNode, { + getChildren: this.getNodeChildren, onEnter: (node, indexPath) => { - if (opts.skip?.(this.getItemValue(node), node, indexPath)) return "skip" + const nodeValue = this.getNodeValue(node) + if (opts.skip?.({ value: nodeValue, node, indexPath })) return "skip" if (indexPath.length > 1) return "skip" - if (!this.getItemDisabled(node)) { + if (!this.getNodeDisabled(node)) { lastChild = node } }, @@ -54,82 +76,113 @@ export class TreeCollection { } at(indexPath: number[]) { - return access(this.items, indexPath, { getChildren: this.getItemChildren }) + return access(this.rootNode, indexPath, { getChildren: this.getNodeChildren }) } - findNode = (value: string, items = this.items): T | undefined => { - return find(items, { - getChildren: this.getItemChildren, - predicate: (node) => this.getItemValue(node) === value, + findNode = (value: string, rootNode = this.rootNode): T | undefined => { + return find(rootNode, { + getChildren: this.getNodeChildren, + predicate: (node) => this.getNodeValue(node) === value, }) } + sort = (values: string[]) => { + return values + .reduce( + (acc, value) => { + const indexPath = this.getIndexPath(value) + if (indexPath != null) acc.push({ value, indexPath }) + return acc + }, + [] as { value: string; indexPath: number[] }[], + ) + .sort((a, b) => compareIndexPaths(a.indexPath, b.indexPath)) + .map(({ value }) => value) + } + getIndexPath = (value: string) => { - return findIndexPath(this.items, { - getChildren: this.getItemChildren, - predicate: (node) => this.getItemValue(node) === value, + return findIndexPath(this.rootNode, { + getChildren: this.getNodeChildren, + predicate: (node) => this.getNodeValue(node) === value, }) } - getValuePath = (value: string) => { - const indexPath = this.getIndexPath(value) + getValuePath = (indexPath: number[] | undefined) => { if (!indexPath) return [] - const valuePath: string[] = [] let currentPath = [...indexPath] - while (currentPath.length > 0) { const node = this.at(currentPath) - if (node) valuePath.unshift(this.getItemValue(node)) + if (node) valuePath.unshift(this.getNodeValue(node)) currentPath.pop() } - return valuePath } getDepth = (value: string) => { - const indexPath = findIndexPath(this.items, { - getChildren: this.getItemChildren, - predicate: (node) => this.getItemValue(node) === value, + const indexPath = findIndexPath(this.rootNode, { + getChildren: this.getNodeChildren, + predicate: (node) => this.getNodeValue(node) === value, }) return indexPath?.length ?? 0 } + isRootNode = (node: T) => { + return this.getNodeValue(node) === this.getNodeValue(this.rootNode) + } + + contains = (parentIndexPath: number[], valueIndexPath: number[]) => { + if (!parentIndexPath || !valueIndexPath) return false + return valueIndexPath.slice(0, parentIndexPath.length).every((_, i) => parentIndexPath[i] === valueIndexPath[i]) + } + getNextNode = (value: string, opts: SkipProperty = {}): T | undefined => { - let current = false + let found = false let nextNode: T | undefined - visit(this.items, { - getChildren: this.getItemChildren, - onEnter: (node, indexPath) => { - if (opts.skip?.(this.getItemValue(node), node, indexPath)) return "skip" - if (current && !this.getItemDisabled(node)) { + visit(this.rootNode, { + getChildren: this.getNodeChildren, + onEnter: (node, indexPath) => { + if (this.isRootNode(node)) return + const nodeValue = this.getNodeValue(node) + if (opts.skip?.({ value: nodeValue, node, indexPath })) { + if (nodeValue === value) { + found = true + } + return "skip" + } + if (found && !this.getNodeDisabled(node)) { nextNode = node return "stop" } - - if (this.getItemValue(node) === value) { - current = true + if (nodeValue === value) { + found = true } }, }) + return nextNode } getPreviousNode = (value: string, opts: SkipProperty = {}): T | undefined => { let previousNode: T | undefined let found = false - visit(this.items, { - getChildren: this.getItemChildren, + visit(this.rootNode, { + getChildren: this.getNodeChildren, onEnter: (node, indexPath) => { - if (opts.skip?.(this.getItemValue(node), node, indexPath)) return "skip" + if (this.isRootNode(node)) return + const nodeValue = this.getNodeValue(node) - if (this.getItemValue(node) === value) { + if (opts.skip?.({ value: nodeValue, node, indexPath })) { + return "skip" + } + + if (nodeValue === value) { found = true return "stop" } - if (!this.getItemDisabled(node)) { + if (!this.getNodeDisabled(node)) { previousNode = node } }, @@ -137,37 +190,88 @@ export class TreeCollection { return found ? previousNode : undefined } + getParentNodes = (values: string): T[] => { + const result: T[] = [] + let indexPath = this.getIndexPath(values) + while (indexPath && indexPath.length > 0) { + indexPath.pop() + const parentNode = this.at(indexPath) + if (parentNode && !this.isRootNode(parentNode)) { + result.unshift(parentNode) + } + } + return result + } + getParentNode = (value: string): T | undefined => { - const parent = findIndexPath(this.items, { - getChildren: this.getItemChildren, - predicate: (node) => this.getItemValue(node) === value, + const indexPath = this.getIndexPath(value) + return indexPath ? this.at(indexPath.slice(0, -1)) : undefined + } + + visit = (opts: Omit, "getChildren"> & SkipProperty) => { + const { skip, ...rest } = opts + visit(this.rootNode, { + ...rest, + getChildren: this.getNodeChildren, + onEnter: (node, indexPath) => { + if (this.isRootNode(node)) return + if (skip?.({ value: this.getNodeValue(node), node, indexPath })) return "skip" + return rest.onEnter?.(node, indexPath) + }, + }) + } + + getSiblingNodes = (indexPath: number[]): T[] => { + const parentPath = indexPath.slice(0, -1) + const parentNode = this.at(parentPath) + + if (!parentNode) return [] + + const depth = indexPath.length + const siblingNodes: T[] = [] + + visit(parentNode, { + getChildren: this.getNodeChildren, + onEnter: (node, path) => { + if (this.isRootNode(node)) return + if (isEqual(path, indexPath)) return + if (path.length === depth && this.isBranchNode(node)) { + siblingNodes.push(node) + } + return "skip" + }, }) - return parent ? this.at(parent.slice(0, -1)) : undefined + + return siblingNodes } - getValues = (items = this.items) => { - const values = flatMap(items, { - getChildren: this.getItemChildren, - transform: (node) => [this.getItemValue(node)], + getValues = (rootNode = this.rootNode): string[] => { + const values = flatMap(rootNode, { + getChildren: this.getNodeChildren, + transform: (node) => [this.getNodeValue(node)], }) // remove the root node return values.slice(1) } - private isSameDepth = (indexPath: number[], depth?: number) => { + private isSameDepth = (indexPath: number[], depth?: number): boolean => { if (depth == null) return true return indexPath.length === depth } - getBranchValues = (items = this.items, opts: SkipProperty & { depth?: number } = {}) => { + isBranchNode = (node: T) => { + return this.getNodeChildren(node).length > 0 + } + + getBranchValues = (rootNode = this.rootNode, opts: SkipProperty & { depth?: number } = {}): string[] => { let values: string[] = [] - visit(items, { - getChildren: this.getItemChildren, + visit(rootNode, { + getChildren: this.getNodeChildren, onEnter: (node, indexPath) => { - if (opts.skip?.(this.getItemValue(node), node, indexPath)) return "skip" - - if (this.getItemChildren(node).length > 0 && this.isSameDepth(indexPath, opts.depth)) { - values.push(this.getItemValue(node)) + const nodeValue = this.getNodeValue(node) + if (opts.skip?.({ value: nodeValue, node, indexPath })) return "skip" + if (this.getNodeChildren(node).length > 0 && this.isSameDepth(indexPath, opts.depth)) { + values.push(this.getNodeValue(node)) } }, }) @@ -175,31 +279,76 @@ export class TreeCollection { return values.slice(1) } - flatten = (items = this.items) => { - return flatMap(items, { - getChildren: this.getItemChildren, - transform: (node, indexPath): FlattedTreeItem[] => { - const children = this.getItemChildren(node).map((child) => this.getItemValue(child)) + flatten = (rootNode = this.rootNode): FlatTreeNode[] => { + const nodes = flatMap(rootNode, { + getChildren: this.getNodeChildren, + transform: (node, indexPath): FlatTreeNode[] => { + const children = this.getNodeChildren(node).map((child) => this.getNodeValue(child)) return [ compact({ - label: this.stringify(node), - value: this.getItemValue(node), + label: this.stringifyNode(node), + value: this.getNodeValue(node), indexPath, children: children.length > 0 ? children : undefined, }), ] }, }) + // remove the root node + return nodes.slice(1) + } + + private _create = (node: T, children: T[]) => { + return compact({ ...node, children: children }) + } + + private _insert = (rootNode: T, indexPath: number[], nodes: T[]) => { + return insert(rootNode, { at: indexPath, nodes, getChildren: this.getNodeChildren, create: this._create }) + } + + private _replace = (rootNode: T, indexPath: number[], node: T) => { + return replace(rootNode, { at: indexPath, node, getChildren: this.getNodeChildren, create: this._create }) + } + + private _move = (rootNode: T, indexPaths: number[][], to: number[]) => { + return move(rootNode, { indexPaths, to, getChildren: this.getNodeChildren, create: this._create }) + } + + replace = (indexPath: number[], node: T) => { + return this._replace(this.rootNode, indexPath, node) + } + + insertBefore = (indexPath: number[], ...nodes: T[]) => { + const parentIndexPath = indexPath.slice(0, -1) + const parentNode = this.at(parentIndexPath) + if (!parentNode) return + return this._insert(this.rootNode, indexPath, nodes) + } + + insertAfter = (indexPath: number[], ...nodes: T[]) => { + const parentIndexPath = indexPath.slice(0, -1) + const parentNode = this.at(parentIndexPath) + if (!parentNode) return + const nextIndex = [...parentIndexPath, indexPath[indexPath.length - 1] + 1] + return this._insert(this.rootNode, nextIndex, nodes) + } + + reorder = (toIndexPath: number[], ...fromIndexPaths: number[][]) => { + return this._move(this.rootNode, fromIndexPaths, toIndexPath) + } + + json() { + return this.getValues(this.rootNode) } } -export function flattenedToTree(items: FlattedTreeItem[]) { +export function flattenedToTree(nodes: FlatTreeNode[]) { let rootNode = { value: "ROOT", } - items.map((item) => { - const { indexPath, label, value } = item + nodes.map((node) => { + const { indexPath, label, value } = node if (!indexPath.length) { Object.assign(rootNode, { label, value, children: [] }) return @@ -216,12 +365,19 @@ export function flattenedToTree(items: FlattedTreeItem[]) { }) return new TreeCollection({ - items: rootNode, + rootNode: rootNode, }) } -export function filePathToTree(paths: string[]) { - const rootNode: any = { +export interface FilePathTreeNode { + label: string + value: string + children?: FilePathTreeNode[] +} + +export function filePathToTree(paths: string[]): TreeCollection { + const rootNode: FilePathTreeNode = { + label: "", value: "ROOT", children: [], } @@ -247,48 +403,52 @@ export function filePathToTree(paths: string[]) { }) return new TreeCollection({ - items: rootNode, + rootNode: rootNode, }) } export interface TreeCollectionMethods { - isItemDisabled: (node: T) => boolean - itemToValue: (node: T) => string - itemToString: (node: T) => string - itemToChildren: (node: T) => any[] + isNodeDisabled: (node: T) => boolean + nodeToValue: (node: T) => string + nodeToString: (node: T) => string + nodeToChildren: (node: T) => any[] } export interface TreeCollectionOptions extends Partial> { - items: T + rootNode: T } -interface FlattedTreeItem { +export type TreeNode = any + +export interface FlatTreeNode { label?: string | undefined - value: string | undefined + value: string indexPath: number[] children?: string[] | undefined } +export type TreeSkipFn = (opts: { value: string; node: T; indexPath: number[] }) => boolean | void + interface SkipProperty { - skip?: (value: string, node: T, indexPath: number[]) => boolean + skip?: TreeSkipFn } const fallback: TreeCollectionMethods = { - itemToValue(item) { - if (typeof item === "string") return item - if (isObject(item) && hasProp(item, "value")) return item.value + nodeToValue(node) { + if (typeof node === "string") return node + if (isObject(node) && hasProp(node, "value")) return node.value return "" }, - itemToString(item) { - if (typeof item === "string") return item - if (isObject(item) && hasProp(item, "label")) return item.label - return fallback.itemToValue(item) + nodeToString(node) { + if (typeof node === "string") return node + if (isObject(node) && hasProp(node, "label")) return node.label + return fallback.nodeToValue(node) }, - isItemDisabled(item) { - if (isObject(item) && hasProp(item, "disabled")) return !!item.disabled + isNodeDisabled(node) { + if (isObject(node) && hasProp(node, "disabled")) return !!node.disabled return false }, - itemToChildren(node) { + nodeToChildren(node) { return node.children }, } diff --git a/packages/utilities/collection/src/tree-visit.ts b/packages/utilities/collection/src/tree-visit.ts new file mode 100644 index 0000000000..3dcaace242 --- /dev/null +++ b/packages/utilities/collection/src/tree-visit.ts @@ -0,0 +1,453 @@ +// Credits: https://github.com/dabbott/tree-visit + +// Accessors + +export function access(node: T, indexPath: IndexPath, options: BaseOptions): T { + for (let i = 0; i < indexPath.length; i++) node = options.getChildren(node, indexPath.slice(i + 1))[indexPath[i]] + return node +} + +export function accessPath(node: T, indexPath: IndexPath, options: BaseOptions): T[] { + const result = [node] + for (let i = 0; i < indexPath.length; i++) { + node = options.getChildren(node, indexPath.slice(i + 1))[indexPath[i]] + result.push(node) + } + return result +} + +export function ancestorIndexPaths(indexPaths: IndexPath[]): IndexPath[] { + const result: IndexPath[] = [] + const seen = new Set() + for (const indexPath of indexPaths) { + for (let i = indexPath.length; i > 0; i--) { + const path = indexPath.slice(0, i) + const key = path.join() + if (seen.has(key)) break + seen.add(key) + result.push(path) + } + } + return result +} + +export function compareIndexPaths(a: IndexPath, b: IndexPath): number { + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] < b[i]) return -1 + if (a[i] > b[i]) return 1 + } + return a.length - b.length +} + +export function sortIndexPaths(indexPaths: IndexPath[]): IndexPath[] { + return indexPaths.sort(compareIndexPaths) +} + +// Operations + +export function find(node: T, options: FindOptions): T | undefined +export function find(node: T, options: FindOptionsTyped): S | undefined +export function find(node: T, options: FindOptions): T | undefined { + let found: T | undefined + visit(node, { + ...options, + onEnter: (child, indexPath) => { + if (options.predicate(child, indexPath)) { + found = child + return "stop" + } + }, + }) + return found +} + +export function findAll(node: T, options: FindOptions): T[] +export function findAll(node: T, options: FindOptionsTyped): S[] +export function findAll(node: T, options: FindOptions): T[] { + const found: T[] = [] + visit(node, { + onEnter: (child, indexPath) => { + if (options.predicate(child, indexPath)) found.push(child) + }, + getChildren: options.getChildren, + }) + return found +} + +export function findIndexPath(node: T, options: FindOptions): IndexPath | undefined { + let found: IndexPath | undefined + visit(node, { + onEnter: (child, indexPath) => { + if (options.predicate(child, indexPath)) { + found = [...indexPath] + return "stop" + } + }, + getChildren: options.getChildren, + }) + return found +} + +export function findAllIndexPaths(node: T, options: FindOptions): IndexPath[] { + let found: IndexPath[] = [] + visit(node, { + onEnter: (child, indexPath) => { + if (options.predicate(child, indexPath)) found.push([...indexPath]) + }, + getChildren: options.getChildren, + }) + return found +} + +export function reduce(node: T, options: ReduceOptions): R { + let result = options.initialResult + visit(node, { + ...options, + onEnter: (child, indexPath) => { + result = options.nextResult(result, child, indexPath) + }, + }) + return result +} + +export function flat(node: T, options: BaseOptions): T[] { + return reduce(node, { + ...options, + initialResult: [], + nextResult: (result, child) => { + result.push(child) + return result + }, + }) +} + +export function flatMap(node: T, options: FlatMapOptions): R[] { + return reduce(node, { + ...options, + initialResult: [], + nextResult: (result, child, indexPath) => { + result.push(...options.transform(child, indexPath)) + return result + }, + }) +} + +// Mutations + +function insertOperation(index: number, nodes: T[]): NodeOperation { + return { type: "insert", index, nodes } +} + +function removeOperation(indexes: number[]): NodeOperation { + return { type: "remove", indexes } +} + +function replaceOperation(): NodeOperation { + return { type: "replace" } +} + +type OperationMap = Map> + +function splitIndexPath(indexPath: IndexPath): [IndexPath, number] { + return [indexPath.slice(0, -1), indexPath[indexPath.length - 1]] +} + +function getInsertionOperations(indexPath: IndexPath, nodes: T[], operations: OperationMap = new Map()) { + const [parentIndexPath, index] = splitIndexPath(indexPath) + // Mark all parents for replacing + for (let i = parentIndexPath.length - 1; i >= 0; i--) { + const parentKey = parentIndexPath.slice(0, i).join() + switch (operations.get(parentKey)?.type) { + case "remove": + continue + } + operations.set(parentKey, replaceOperation()) + } + + const operation = operations.get(parentIndexPath.join()) + + // Mark insertion node + switch (operation?.type) { + case "remove": + operations.set(parentIndexPath.join(), { + type: "removeThenInsert", + removeIndexes: operation.indexes, + insertIndex: index, + insertNodes: nodes, + }) + break + default: + operations.set(parentIndexPath.join(), insertOperation(index, nodes)) + } + + return operations +} + +function getRemovalOperations(indexPaths: IndexPath[]) { + const _ancestorIndexPaths = ancestorIndexPaths(indexPaths) + const indexesToRemove = new Map() + for (const indexPath of _ancestorIndexPaths) { + const parentKey = indexPath.slice(0, -1).join() + const value = indexesToRemove.get(parentKey) ?? [] + value.push(indexPath[indexPath.length - 1]) + indexesToRemove.set(parentKey, value) + } + + const operations: OperationMap = new Map() + // Mark all parents for replacing + for (const indexPath of _ancestorIndexPaths) { + for (let i = indexPath.length - 1; i >= 0; i--) { + const parentKey = indexPath.slice(0, i).join() + operations.set(parentKey, replaceOperation()) + } + } + + // Mark all nodes for removal + for (const indexPath of _ancestorIndexPaths) { + const parentKey = indexPath.slice(0, -1).join() + operations.set(parentKey, removeOperation(indexesToRemove.get(parentKey) ?? [])) + } + + return operations +} + +function getReplaceOperations(indexPath: IndexPath, node: T) { + const operations: OperationMap = new Map() + const [parentIndexPath, index] = splitIndexPath(indexPath) + + // Mark all parents for replacing + for (let i = parentIndexPath.length - 1; i >= 0; i--) { + const parentKey = parentIndexPath.slice(0, i).join() + + operations.set(parentKey, replaceOperation()) + } + + operations.set(parentIndexPath.join(), { + type: "removeThenInsert", + removeIndexes: [index], + insertIndex: index, + insertNodes: [node], + }) + + return operations +} + +function mutate(node: T, operations: OperationMap, options: MutationBaseOptions) { + return map(node, { + ...options, + getChildren: (node, indexPath) => { + const key = indexPath.join() + const operation = operations.get(key) + switch (operation?.type) { + case "replace": + case "remove": + case "removeThenInsert": + case "insert": + return options.getChildren(node, indexPath) + default: + return [] + } + }, + transform: (node, children: T[], indexPath) => { + const key = indexPath.join() + const operation = operations.get(key) + switch (operation?.type) { + case "remove": + return options.create( + node, + children.filter((_, index) => !operation.indexes.includes(index)), + indexPath, + ) + case "removeThenInsert": + const updatedChildren = children.filter((_, index) => !operation.removeIndexes.includes(index)) + const adjustedIndex = operation.removeIndexes.reduce( + (index, removedIndex) => (removedIndex < index ? index - 1 : index), + operation.insertIndex, + ) + return options.create(node, splice(updatedChildren, adjustedIndex, 0, ...operation.insertNodes), indexPath) + case "insert": + return options.create(node, splice(children, operation.index, 0, ...operation.nodes), indexPath) + case "replace": + return options.create(node, children, indexPath) + default: + return node + } + }, + }) +} + +function splice(array: T[], start: number, deleteCount: number, ...items: T[]) { + return [...array.slice(0, start), ...items, ...array.slice(start + deleteCount)] +} + +export function map(node: T, options: MapOptions): U { + const childrenMap: Record = {} + visit(node, { + ...options, + onLeave: (child, indexPath) => { + // Add a 0 so we can always slice off the last element to get a unique parent key + const keyIndexPath = [0, ...indexPath] + const key = keyIndexPath.join() + const transformed = options.transform(child, childrenMap[key] ?? [], indexPath) + const parentKey = keyIndexPath.slice(0, -1).join() + const parentChildren = childrenMap[parentKey] ?? [] + parentChildren.push(transformed) + childrenMap[parentKey] = parentChildren + }, + }) + return childrenMap[""][0] +} + +export function insert(node: T, options: InsertOptions) { + const { nodes, at } = options + if (at.length === 0) throw new Error(`Can't insert nodes at the root`) + const state = getInsertionOperations(at, nodes) + return mutate(node, state, options) +} + +export function replace(node: T, options: ReplaceOptions) { + if (options.at.length === 0) return options.node + const operations = getReplaceOperations(options.at, options.node) + return mutate(node, operations, options) +} + +export function remove(node: T, options: RemoveOptions) { + if (options.indexPaths.length === 0) return node + for (const indexPath of options.indexPaths) { + if (indexPath.length === 0) throw new Error(`Can't remove the root node`) + } + const operations = getRemovalOperations(options.indexPaths) + return mutate(node, operations, options) +} + +export function move(node: T, options: MoveOptions) { + if (options.indexPaths.length === 0) return node + for (const indexPath of options.indexPaths) { + if (indexPath.length === 0) throw new Error(`Can't move the root node`) + } + if (options.to.length === 0) throw new Error(`Can't move nodes to the root`) + const _ancestorIndexPaths = ancestorIndexPaths(options.indexPaths) + const nodesToInsert = _ancestorIndexPaths.map((indexPath) => access(node, indexPath, options)) + const operations = getInsertionOperations(options.to, nodesToInsert, getRemovalOperations(_ancestorIndexPaths)) + return mutate(node, operations, options) +} + +export function visit(node: T, options: VisitOptions): void { + const { onEnter, onLeave, getChildren } = options + let indexPath: IndexPath = [] + let stack: VisitStack[] = [{ node }] + const getIndexPath = options.reuseIndexPath ? () => indexPath : () => indexPath.slice() + while (stack.length > 0) { + let wrapper = stack[stack.length - 1] + if (wrapper.state === undefined) { + const enterResult = onEnter?.(wrapper.node, getIndexPath()) + if (enterResult === "stop") return + wrapper.state = enterResult === "skip" ? -1 : 0 + } + const children = wrapper.children || getChildren(wrapper.node, getIndexPath()) + wrapper.children ||= children + if (wrapper.state !== -1) { + if (wrapper.state < children.length) { + let currentIndex = wrapper.state + + indexPath.push(currentIndex) + stack.push({ node: children[currentIndex] }) + + wrapper.state = currentIndex + 1 + + continue + } + + const leaveResult = onLeave?.(wrapper.node, getIndexPath()) + + if (leaveResult === "stop") return + } + indexPath.pop() + stack.pop() + } +} + +// Types + +export type NodeOperation = + | { type: "insert"; index: number; nodes: T[] } + | { type: "remove"; indexes: number[] } + | { type: "replace" } + | { type: "removeThenInsert"; removeIndexes: number[]; insertIndex: number; insertNodes: T[] } + +export type IndexPath = number[] + +export interface BaseOptions { + getChildren: (node: T, indexPath: IndexPath) => T[] + reuseIndexPath?: boolean +} + +export interface FindOptions extends BaseOptions { + predicate: (node: T, indexPath: IndexPath) => boolean +} + +export interface RemoveOptions extends MutationBaseOptions { + indexPaths: IndexPath[] +} + +export interface MapOptions extends BaseOptions { + transform: (node: T, transformedChildren: U[], indexPath: IndexPath) => U +} + +export interface FindOptionsTyped extends BaseOptions { + predicate: (node: T, indexPath: IndexPath) => node is S +} + +export interface ReduceOptions extends BaseOptions { + initialResult: R + nextResult: (result: R, node: T, indexPath: IndexPath) => R +} + +export interface FlatMapOptions extends BaseOptions { + transform: (node: T, indexPath: IndexPath) => R[] +} + +export interface MutationBaseOptions extends BaseOptions { + create: (node: T, children: T[], indexPath: IndexPath) => T +} + +export interface InsertOptions extends MutationBaseOptions { + nodes: T[] + at: IndexPath +} + +export interface MoveOptions extends MutationBaseOptions { + indexPaths: IndexPath[] + to: IndexPath +} + +export interface ReplaceOptions extends MutationBaseOptions { + at: IndexPath + node: T +} + +interface VisitStack { + node: T + + /** + * The current traversal state of the node. + * + * undefined => not visited + * -1 => skipped + * n => nth child + */ + state?: number + + /** + * Cached children, so we only call getChildren once per node + */ + children?: T[] +} + +export type EnterReturnValue = void | "skip" | "stop" +export type LeaveReturnValue = void | "stop" + +export interface VisitOptions extends BaseOptions { + onEnter?(node: T, indexPath: IndexPath): EnterReturnValue + onLeave?(node: T, indexPath: IndexPath): LeaveReturnValue +} diff --git a/packages/utilities/collection/tests/tree-collection.test.ts b/packages/utilities/collection/tests/tree-collection.test.ts index 81541cc4cf..eda06f8efd 100644 --- a/packages/utilities/collection/tests/tree-collection.test.ts +++ b/packages/utilities/collection/tests/tree-collection.test.ts @@ -4,8 +4,8 @@ let tree: TreeCollection<{ value: string; children: Array<{ value: string }> }> beforeEach(() => { tree = new TreeCollection({ - itemToChildren: (node) => node.children, - items: { + nodeToChildren: (node) => node.children, + rootNode: { value: "ROOT", children: [ { @@ -168,7 +168,9 @@ describe("tree", () => { ] `) - expect(tree.getValuePath("child1-2")).toMatchInlineSnapshot(` + const indexPath = tree.getIndexPath("child1-2") + + expect(tree.getValuePath(indexPath)).toMatchInlineSnapshot(` [ "branch1", "child1-2", @@ -177,16 +179,6 @@ describe("tree", () => { expect(tree.flatten()).toMatchInlineSnapshot(` [ - { - "children": [ - "branch1", - "child1", - "child2", - ], - "indexPath": [], - "label": "ROOT", - "value": "ROOT", - }, { "children": [ "child1-1", @@ -299,7 +291,7 @@ describe("tree", () => { indexPath: [2], value: "child2", }, - ]).items, + ]).rootNode, ).toMatchInlineSnapshot(` { "children": [ @@ -336,7 +328,7 @@ describe("tree", () => { } `) - expect(filePathToTree(["a/b/c", "a/b/d", "a/e", "f"]).items).toMatchInlineSnapshot(` + expect(filePathToTree(["a/b/c", "a/b/d", "a/e", "f"]).rootNode).toMatchInlineSnapshot(` { "children": [ { @@ -375,8 +367,8 @@ describe("tree", () => { it("skips disabled nodes", () => { const tree = new TreeCollection({ - itemToChildren: (node) => node.children, - items: { + nodeToChildren: (node) => node.children, + rootNode: { value: "ROOT", children: [ { value: "child1" }, diff --git a/packages/utilities/collection/tests/tree-skip.test.ts b/packages/utilities/collection/tests/tree-skip.test.ts new file mode 100644 index 0000000000..379fcbd5d7 --- /dev/null +++ b/packages/utilities/collection/tests/tree-skip.test.ts @@ -0,0 +1,150 @@ +import { TreeCollection } from "../src/tree-collection" + +interface Item { + id: string + name: string + children?: Item[] +} + +let tree: TreeCollection + +beforeEach(() => { + // @ts-expect-error + tree = new TreeCollection({ + nodeToValue: (node) => node.id, + nodeToString: (node) => node.name, + rootNode: { + id: "ROOT", + name: "", + children: [ + { + id: "node_modules", + name: "node_modules", + children: [ + { id: "node_modules/zag-js", name: "zag-js" }, + { id: "node_modules/pandacss", name: "panda" }, + { + id: "node_modules/@types", + name: "@types", + children: [ + { id: "node_modules/@types/react", name: "react" }, + { id: "node_modules/@types/react-dom", name: "react-dom" }, + ], + }, + ], + }, + { + id: "src", + name: "src", + children: [ + { id: "src/app.tsx", name: "app.tsx" }, + { id: "src/index.ts", name: "index.ts" }, + ], + }, + { id: "panda.config", name: "panda.config" }, + { id: "package.json", name: "package.json" }, + { id: "renovate.json", name: "renovate.json" }, + { id: "readme.md", name: "README.md" }, + ], + }, + }) +}) + +describe("tree traversal / skip", () => { + it("next node", () => { + const expanded: string[] = ["node_modules"] + const current = "node_modules" + + const node = tree.getNextNode(current, { + skip({ indexPath }) { + const paths = tree.getValuePath(indexPath).slice(0, -1) + return paths.some((value) => !expanded.includes(value)) + }, + }) + + const value = node ? tree.getNodeValue(node) : undefined + expect(value).toMatchInlineSnapshot(`"node_modules/zag-js"`) + }) + + it("previous node", () => { + const expanded: string[] = ["node_modules", "node_modules/@types"] + const current = "src" + + const node = tree.getPreviousNode(current, { + skip({ indexPath }) { + const paths = tree.getValuePath(indexPath).slice(0, -1) + return paths.some((value) => !expanded.includes(value)) + }, + }) + + const value = node ? tree.getNodeValue(node) : undefined + expect(value).toMatchInlineSnapshot(`"node_modules/@types/react-dom"`) + }) +}) + +describe("tree / operations", () => { + it("replace", () => { + const rootNode = tree.replace([0, 1], { id: "---------> test", name: "test" }) + expect(tree.getValues(rootNode)).toMatchInlineSnapshot(` + [ + "node_modules", + "node_modules/zag-js", + "---------> test", + "node_modules/@types", + "node_modules/@types/react", + "node_modules/@types/react-dom", + "src", + "src/app.tsx", + "src/index.ts", + "panda.config", + "package.json", + "renovate.json", + "readme.md", + ] + `) + }) + + it("insert before", () => { + const rootNode = tree.insertBefore([0, 0], { id: "---------> test", name: "test" }) + expect(tree.getValues(rootNode!)).toMatchInlineSnapshot(` + [ + "node_modules", + "---------> test", + "node_modules/zag-js", + "node_modules/pandacss", + "node_modules/@types", + "node_modules/@types/react", + "node_modules/@types/react-dom", + "src", + "src/app.tsx", + "src/index.ts", + "panda.config", + "package.json", + "renovate.json", + "readme.md", + ] + `) + }) + + it("insert after", () => { + const rootNode = tree.insertAfter([0, 2], { id: "---------> test", name: "test" }) + expect(tree.getValues(rootNode!)).toMatchInlineSnapshot(` + [ + "node_modules", + "node_modules/zag-js", + "node_modules/pandacss", + "node_modules/@types", + "node_modules/@types/react", + "node_modules/@types/react-dom", + "---------> test", + "src", + "src/app.tsx", + "src/index.ts", + "panda.config", + "package.json", + "renovate.json", + "readme.md", + ] + `) + }) +}) diff --git a/packages/utilities/collection/tests/tree.test.ts b/packages/utilities/collection/tests/tree.test.ts deleted file mode 100644 index 1b484c3587..0000000000 --- a/packages/utilities/collection/tests/tree.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { filePathToTree, flattenedToTree, TreeCollection } from "../src/tree-collection" - -let tree: TreeCollection<{ value: string; children: { value: string }[] }> - -beforeEach(() => { - tree = new TreeCollection({ - itemToChildren: (node: any) => node.children, - items: { - value: "ROOT", - children: [ - { - value: "branch1", - children: [ - { value: "child1-1" }, - { value: "child1-2" }, - { value: "child1-3" }, - { value: "branch1-1", children: [{ value: "child2-1" }] }, - ], - }, - { value: "child1" }, - { value: "child2" }, - ], - }, - }) -}) - -describe("tree", () => { - it("should visit the tree", () => { - expect(tree.getFirstNode()).toMatchInlineSnapshot(` - { - "children": [ - { - "value": "child1-1", - }, - { - "value": "child1-2", - }, - { - "value": "child1-3", - }, - { - "children": [ - { - "value": "child2-1", - }, - ], - "value": "branch1-1", - }, - ], - "value": "branch1", - } - `) - - expect(tree.getLastNode()).toMatchInlineSnapshot(` - { - "value": "child2", - } - `) - - expect(tree.getValues()).toMatchInlineSnapshot(` - [ - "branch1", - "child1-1", - "child1-2", - "child1-3", - "branch1-1", - "child2-1", - "child1", - "child2", - ] - `) - - expect(tree.getNextNode("branch1")).toMatchInlineSnapshot(` - { - "value": "child1-1", - } - `) - - expect(tree.getPreviousNode("child1-2")).toMatchInlineSnapshot(` - { - "value": "child1-1", - } - `) - - expect(tree.getParentNode("child1-2")).toMatchInlineSnapshot(` - { - "children": [ - { - "value": "child1-1", - }, - { - "value": "child1-2", - }, - { - "value": "child1-3", - }, - { - "children": [ - { - "value": "child2-1", - }, - ], - "value": "branch1-1", - }, - ], - "value": "branch1", - } - `) - }) - - it("branch nodes", () => { - const branch = tree.findNode("branch1") - expect(branch).toMatchInlineSnapshot(` - { - "children": [ - { - "value": "child1-1", - }, - { - "value": "child1-2", - }, - { - "value": "child1-3", - }, - { - "children": [ - { - "value": "child2-1", - }, - ], - "value": "branch1-1", - }, - ], - "value": "branch1", - } - `) - - expect(tree.getBranchValues()).toMatchInlineSnapshot(` - [ - "branch1", - "branch1-1", - ] - `) - - expect(tree.getFirstNode(branch)).toMatchInlineSnapshot(` - { - "value": "child1-1", - } - `) - - expect(tree.getLastNode(branch)).toMatchInlineSnapshot(` - { - "children": [ - { - "value": "child2-1", - }, - ], - "value": "branch1-1", - } - `) - }) - - it("value path", () => { - expect(tree.getValuePath("child1-2")).toMatchInlineSnapshot(` - [ - "branch1", - "child1-2", - ] - `) - - expect(tree.flatten()).toMatchInlineSnapshot(` - [ - { - "children": [ - "branch1", - "child1", - "child2", - ], - "indexPath": [], - "label": "ROOT", - "value": "ROOT", - }, - { - "children": [ - "child1-1", - "child1-2", - "child1-3", - "branch1-1", - ], - "indexPath": [ - 0, - ], - "label": "branch1", - "value": "branch1", - }, - { - "indexPath": [ - 0, - 0, - ], - "label": "child1-1", - "value": "child1-1", - }, - { - "indexPath": [ - 0, - 1, - ], - "label": "child1-2", - "value": "child1-2", - }, - { - "indexPath": [ - 0, - 2, - ], - "label": "child1-3", - "value": "child1-3", - }, - { - "children": [ - "child2-1", - ], - "indexPath": [ - 0, - 3, - ], - "label": "branch1-1", - "value": "branch1-1", - }, - { - "indexPath": [ - 0, - 3, - 0, - ], - "label": "child2-1", - "value": "child2-1", - }, - { - "indexPath": [ - 1, - ], - "label": "child1", - "value": "child1", - }, - { - "indexPath": [ - 2, - ], - "label": "child2", - "value": "child2", - }, - ] - `) - - expect( - flattenedToTree([ - { - indexPath: [], - value: "ROOT", - }, - { - indexPath: [0], - value: "branch1", - }, - { - indexPath: [0, 0], - value: "child1-1", - }, - { - indexPath: [0, 1], - value: "child1-2", - }, - { - indexPath: [0, 2], - value: "child1-3", - }, - { - indexPath: [0, 3], - value: "branch1-1", - }, - { - indexPath: [0, 3, 0], - value: "child2-1", - }, - { - indexPath: [1], - value: "child1", - }, - { - indexPath: [2], - value: "child2", - }, - ]).items, - ).toMatchInlineSnapshot(` - { - "children": [ - { - "children": [ - { - "value": "child1-1", - }, - { - "value": "child1-2", - }, - { - "value": "child1-3", - }, - { - "children": [ - { - "value": "child2-1", - }, - ], - "value": "branch1-1", - }, - ], - "value": "branch1", - }, - { - "value": "child1", - }, - { - "value": "child2", - }, - ], - "value": "ROOT", - } - `) - - expect(filePathToTree(["a/b/c", "a/b/d", "a/e", "f"]).items).toMatchInlineSnapshot(` - { - "children": [ - { - "children": [ - { - "children": [ - { - "label": "c", - "value": "a/b/c", - }, - { - "label": "d", - "value": "a/b/d", - }, - ], - "label": "b", - "value": "a/b", - }, - { - "label": "e", - "value": "a/e", - }, - ], - "label": "a", - "value": "a", - }, - { - "label": "f", - "value": "f", - }, - ], - "value": "ROOT", - } - `) - }) -}) diff --git a/packages/utilities/core/src/array.ts b/packages/utilities/core/src/array.ts index 633b30b1c0..ae15d12687 100644 --- a/packages/utilities/core/src/array.ts +++ b/packages/utilities/core/src/array.ts @@ -15,10 +15,12 @@ export const has = (v: T[], t: any): boolean => v.indexOf(t) !== -1 export const add = (v: T[], ...items: T[]): T[] => v.concat(items) -export const remove = (v: T[], item: T): T[] => v.filter((t) => t !== item) +export const remove = (v: T[], ...items: T[]): T[] => v.filter((t) => !items.includes(t)) export const removeAt = (v: T[], i: number): T[] => v.filter((_, idx) => idx !== i) +export const insertAt = (v: T[], i: number, ...items: T[]): T[] => [...v.slice(0, i), ...items, ...v.slice(i)] + export const uniq = (v: T[]): T[] => Array.from(new Set(v)) export const addOrRemove = (v: T[], item: T): T[] => { diff --git a/packages/utilities/dom-query/src/get-by-id.ts b/packages/utilities/dom-query/src/get-by-id.ts index 53589f8f27..78ba9a783e 100644 --- a/packages/utilities/dom-query/src/get-by-id.ts +++ b/packages/utilities/dom-query/src/get-by-id.ts @@ -1,23 +1,27 @@ export type ItemToId = (v: T) => string -export const defaultItemToId = (v: T) => v.id +interface Item { + id: string +} + +export const defaultItemToId = (v: T) => v.id -export function itemById(v: T[], id: string, itemToId: ItemToId = defaultItemToId) { +export function itemById(v: T[], id: string, itemToId: ItemToId = defaultItemToId) { return v.find((item) => itemToId(item) === id) } -export function indexOfId(v: T[], id: string, itemToId: ItemToId = defaultItemToId) { +export function indexOfId(v: T[], id: string, itemToId: ItemToId = defaultItemToId) { const item = itemById(v, id, itemToId) return item ? v.indexOf(item) : -1 } -export function nextById(v: T[], id: string, loop = true) { +export function nextById(v: T[], id: string, loop = true) { let idx = indexOfId(v, id) idx = loop ? (idx + 1) % v.length : Math.min(idx + 1, v.length - 1) return v[idx] } -export function prevById(v: T[], id: string, loop = true) { +export function prevById(v: T[], id: string, loop = true) { let idx = indexOfId(v, id) if (idx === -1) return loop ? v[v.length - 1] : null idx = loop ? (idx - 1 + v.length) % v.length : Math.max(0, idx - 1) diff --git a/packages/utilities/dom-query/src/get-by-text.ts b/packages/utilities/dom-query/src/get-by-text.ts index 09e07f51ea..1acbf6270e 100644 --- a/packages/utilities/dom-query/src/get-by-text.ts +++ b/packages/utilities/dom-query/src/get-by-text.ts @@ -1,7 +1,7 @@ import { defaultItemToId, indexOfId, type ItemToId } from "./get-by-id" import { sanitize } from "./sanitize" -const getValueText = (item: T) => sanitize(item.dataset.valuetext ?? item.textContent ?? "") +const getValueText = (item: T) => sanitize(item.dataset?.valuetext ?? item.textContent ?? "") const match = (valueText: string, query: string) => valueText.trim().toLowerCase().startsWith(query.toLowerCase()) @@ -9,7 +9,13 @@ const wrap = (v: T[], idx: number) => { return v.map((_, index) => v[(Math.max(idx, 0) + index) % v.length]) } -export function getByText( +export interface SearchableItem { + id: string + textContent: string | null + dataset?: any +} + +export function getByText( v: T[], text: string, currentId?: string | null, diff --git a/packages/utilities/dom-query/src/get-by-typeahead.ts b/packages/utilities/dom-query/src/get-by-typeahead.ts index b226e781f7..d3f3d4e839 100644 --- a/packages/utilities/dom-query/src/get-by-typeahead.ts +++ b/packages/utilities/dom-query/src/get-by-typeahead.ts @@ -1,4 +1,4 @@ -import { getByText } from "./get-by-text" +import { getByText, type SearchableItem } from "./get-by-text" import type { ItemToId } from "./get-by-id" export interface TypeaheadState { @@ -6,15 +6,15 @@ export interface TypeaheadState { timer: number } -export interface TypeaheadOptions { +export interface TypeaheadOptions { state: TypeaheadState activeId: string | null key: string timeout?: number | undefined - itemToId?: ItemToId | undefined + itemToId?: ItemToId | undefined } -function getByTypeaheadImpl(_items: T[], options: TypeaheadOptions) { +function getByTypeaheadImpl(_items: T[], options: TypeaheadOptions) { const { state, activeId, key, timeout = 350, itemToId } = options const search = state.keysSoFar + key diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f1ee60261..46fb81be36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1938,7 +1938,7 @@ importers: version: 0.7.2 lucide-svelte: specifier: 0.446.0 - version: 0.446.0(svelte@5.0.0-next.262) + version: 0.446.0(svelte@5.1.2) match-sorter: specifier: 6.3.4 version: 6.3.4 @@ -1948,7 +1948,7 @@ importers: devDependencies: '@sveltejs/vite-plugin-svelte': specifier: 4.0.0-next.3 - version: 4.0.0-next.3(svelte@5.0.0-next.262)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)) + version: 4.0.0-next.3(svelte@5.1.2)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)) '@tsconfig/svelte': specifier: 5.0.4 version: 5.0.4 @@ -1956,11 +1956,11 @@ importers: specifier: 0.7.4 version: 0.7.4 svelte: - specifier: 5.0.0-next.262 - version: 5.0.0-next.262 + specifier: 5.1.2 + version: 5.1.2 svelte-check: specifier: 4.0.4 - version: 4.0.4(picomatch@4.0.2)(svelte@5.0.0-next.262)(typescript@5.6.2) + version: 4.0.4(picomatch@4.0.2)(svelte@5.1.2)(typescript@5.6.2) tslib: specifier: 2.7.0 version: 2.7.0 @@ -3519,6 +3519,9 @@ importers: '@zag-js/anatomy': specifier: workspace:* version: link:../../anatomy + '@zag-js/collection': + specifier: workspace:* + version: link:../../utilities/collection '@zag-js/core': specifier: workspace:* version: link:../../core @@ -3584,9 +3587,6 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../core - tree-visit: - specifier: 0.4.2 - version: 0.4.2 devDependencies: clean-package: specifier: 2.2.0 @@ -12194,10 +12194,6 @@ packages: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.0.0-next.262: - resolution: {integrity: sha512-gAY0POArLN5yI/ihvItG0wQCh7ycfvxSvvVlvPECshFKmR/ep6ZAlqX50+aUGDSG/5megFqnSJfP7piC6lOtew==} - engines: {node: '>=18'} - svelte@5.1.2: resolution: {integrity: sha512-IovgkB3eQq0CdqQB1rd1F4SZbg8Z7VBSbAqhD2eE9t8l0KfJXZ/iHmfqnW5pxs5Lr89/cpIZTLU5buemsydYRw==} engines: {node: '>=18'} @@ -12348,9 +12344,6 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - tree-visit@0.4.2: - resolution: {integrity: sha512-BPfpiWqIwFzvObmmcUqUNt5A+2ZsOzrUcJ6DMaDKkNRz5gUAtdZu/Ya9f13/zjXkkiqYxNdKr8vd3pG2s400fQ==} - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -15850,23 +15843,23 @@ snapshots: transitivePeerDependencies: - typescript - '@sveltejs/vite-plugin-svelte-inspector@3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.3(svelte@5.0.0-next.262)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)))(svelte@5.0.0-next.262)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3))': + '@sveltejs/vite-plugin-svelte-inspector@3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.3(svelte@5.1.2)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)))(svelte@5.1.2)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3))': dependencies: - '@sveltejs/vite-plugin-svelte': 4.0.0-next.3(svelte@5.0.0-next.262)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)) + '@sveltejs/vite-plugin-svelte': 4.0.0-next.3(svelte@5.1.2)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)) debug: 4.3.6 - svelte: 5.0.0-next.262 + svelte: 5.1.2 vite: 5.4.8(@types/node@22.7.9)(terser@5.31.3) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@4.0.0-next.3(svelte@5.0.0-next.262)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3))': + '@sveltejs/vite-plugin-svelte@4.0.0-next.3(svelte@5.1.2)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.3(svelte@5.0.0-next.262)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)))(svelte@5.0.0-next.262)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)) + '@sveltejs/vite-plugin-svelte-inspector': 3.0.0-next.3(@sveltejs/vite-plugin-svelte@4.0.0-next.3(svelte@5.1.2)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)))(svelte@5.1.2)(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)) debug: 4.3.6 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.11 - svelte: 5.0.0-next.262 + svelte: 5.1.2 vite: 5.4.8(@types/node@22.7.9)(terser@5.31.3) vitefu: 0.2.5(vite@5.4.8(@types/node@22.7.9)(terser@5.31.3)) transitivePeerDependencies: @@ -20437,9 +20430,9 @@ snapshots: dependencies: solid-js: 1.9.1 - lucide-svelte@0.446.0(svelte@5.0.0-next.262): + lucide-svelte@0.446.0(svelte@5.1.2): dependencies: - svelte: 5.0.0-next.262 + svelte: 5.1.2 lucide-vue-next@0.446.0(vue@3.5.10(typescript@5.6.3)): dependencies: @@ -23022,14 +23015,14 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.0.4(picomatch@4.0.2)(svelte@5.0.0-next.262)(typescript@5.6.2): + svelte-check@4.0.4(picomatch@4.0.2)(svelte@5.1.2)(typescript@5.6.2): dependencies: '@jridgewell/trace-mapping': 0.3.25 chokidar: 4.0.1 fdir: 6.3.0(picomatch@4.0.2) picocolors: 1.1.0 sade: 1.8.1 - svelte: 5.0.0-next.262 + svelte: 5.1.2 typescript: 5.6.2 transitivePeerDependencies: - picomatch @@ -23043,22 +23036,6 @@ snapshots: svelte: 5.1.2 typescript: 5.6.3 - svelte@5.0.0-next.262: - dependencies: - '@ampproject/remapping': 2.3.0 - '@jridgewell/sourcemap-codec': 1.5.0 - '@types/estree': 1.0.6 - acorn: 8.12.1 - acorn-typescript: 1.4.13(acorn@8.12.1) - aria-query: 5.3.2 - axobject-query: 4.1.0 - esm-env: 1.0.0 - esrap: 1.2.2 - is-reference: 3.0.2 - locate-character: 3.0.0 - magic-string: 0.30.11 - zimmerframe: 1.1.2 - svelte@5.1.2: dependencies: '@ampproject/remapping': 2.3.0 @@ -23210,8 +23187,6 @@ snapshots: tree-kill@1.2.2: {} - tree-visit@0.4.2: {} - trim-lines@3.0.1: {} trough@2.2.0: {} diff --git a/shared/src/css/tree-view.css b/shared/src/css/tree-view.css index 9e1385d3bd..49ddecba2b 100644 --- a/shared/src/css/tree-view.css +++ b/shared/src/css/tree-view.css @@ -1,29 +1,63 @@ [data-scope="tree-view"][data-part="tree"] { - margin: 0; margin-top: 20px; - padding: 0; - list-style: none; + width: 240px; } [data-scope="tree-view"][data-part="item"], [data-scope="tree-view"][data-part="branch-control"] { - padding-inline: 6px; - padding-block: 4px; user-select: none; + --padding-inline: 16px; + padding-inline-start: calc(var(--depth) * var(--padding-inline)); + padding-inline-end: var(--padding-inline); + display: flex; + align-items: center; + gap: 8px; + border-radius: 2px; + min-height: 32px; + + & svg { + width: 16px; + height: 16px; + opacity: 0.5; + } + + &:hover { + background: rgb(243, 243, 243); + } + + &[data-selected] { + background: rgb(226, 226, 226); + } + + &:focus { + outline: 1px solid rgb(148, 148, 148); + outline-offset: -1px; + } } -[data-scope="tree-view"][data-part="item"][data-selected], -[data-scope="tree-view"][data-part="branch-control"][data-selected] { - background: lightblue; +[data-scope="tree-view"][data-part="item-text"], +[data-scope="tree-view"][data-part="branch-text"] { + flex: 1; } [data-scope="tree-view"][data-part="branch-content"] { - list-style: none; - padding-inline-start: 12px; + position: relative; + isolation: isolate; +} + +[data-scope="tree-view"][data-part="branch-indent-guide"] { + position: absolute; + content: ""; + border-left: 1px solid rgb(226, 226, 226); + height: 100%; + translate: calc(var(--depth) * 1.25rem); + z-index: 0; } -[data-scope="tree-view"][data-part="branch-content"] [data-part="item"] { - --dx: calc(var(--depth) + 1); - padding-inline-start: calc(var(--dx) * 4px); - border-inline-start: 1px solid lightgray; +[data-scope="tree-view"][data-part="branch-indicator"] { + display: flex; + align-items: center; + &[data-state="open"] svg { + transform: rotate(90deg); + } } diff --git a/website/data/components/tree-view.mdx b/website/data/components/tree-view.mdx new file mode 100644 index 0000000000..5cede5d1d9 --- /dev/null +++ b/website/data/components/tree-view.mdx @@ -0,0 +1,37 @@ +--- +title: Tree View +description: Using the tree view machine in your project. +package: "@zag-js/tree-view" +--- + +# Tree View + +The TreeView component provides a hierarchical view of data, similar to a file +system explorer. It allows users to expand and collapse branches, select +individual or multiple nodes, and traverse the hierarchy using keyboard +navigation. + +{/* */} + +{/* */} + +**Features** + +- Display hierarchical data in a tree structure. +- Expand or collapse nodes +- Support for keyboard navigation +- Select single or multiple nodes (depending on the selection mode) +- Perform actions on the nodes, such as deleting them or performing some other + operation. + +## Installation + +## Expanding and Collapsing Nodes + +## Multiple selection + +## Indentation Guide + +## Listening for selection + +## Listening for expanding and collapsing