Skip to content

Commit f0545c6

Browse files
committed
feat(treeview): add checked state support
1 parent 6f6c8f3 commit f0545c6

File tree

18 files changed

+565
-164
lines changed

18 files changed

+565
-164
lines changed

.changeset/khaki-times-pick.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@zag-js/tree-view": minor
3+
---
4+
5+
- Add support for checkbox state for checkbox trees via `defaultCheckedValue`, `checkedValue`, `onCheckedChange` props
6+
- Add callback for when `loadChildren` fails via `onLoadChildrenError` prop
7+
- Add `api.getCheckedMap` method to get the checked state of all nodes

.changeset/ninety-needles-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zag-js/collection": patch
3+
---
4+
5+
Add support for `getDescendantNodes` and `getDescendantValues`

packages/machines/tree-view/src/index.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,23 @@ export { machine } from "./tree-view.machine"
55
export * from "./tree-view.props"
66
export type {
77
TreeViewApi as Api,
8-
TreeViewProps as Props,
8+
CheckedChangeDetails,
9+
CheckedState,
10+
CheckedValueMap,
911
ElementIds,
1012
ExpandedChangeDetails,
1113
FocusChangeDetails,
14+
LoadChildrenCompleteDetails,
15+
LoadChildrenDetails,
16+
LoadChildrenErrorDetails,
17+
TreeViewMachine as Machine,
1218
NodeProps,
1319
NodeState,
20+
NodeWithError,
21+
TreeViewProps as Props,
1422
SelectionChangeDetails,
1523
TreeViewService as Service,
16-
TreeViewMachine as Machine,
17-
TreeNode,
18-
LoadChildrenDetails,
19-
LoadChildrenCompleteDetails,
2024
TreeLoadingStatus,
2125
TreeLoadingStatusMap,
26+
TreeNode,
2227
} from "./tree-view.types"

packages/machines/tree-view/src/tree-view.anatomy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const anatomy = createAnatomy("tree-view").parts(
77
"item",
88
"itemIndicator",
99
"itemText",
10+
"itemCheckbox",
1011
"branch",
1112
"branchControl",
1213
"branchTrigger",

packages/machines/tree-view/src/tree-view.connect.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ import type { EventKeyMap, NormalizeProps, PropTypes } from "@zag-js/types"
1010
import { add, isEqual, uniq } from "@zag-js/utils"
1111
import { parts } from "./tree-view.anatomy"
1212
import * as dom from "./tree-view.dom"
13-
import type { NodeProps, NodeState, TreeViewApi, TreeViewService } from "./tree-view.types"
13+
import type { NodeProps, NodeState, TreeNode, TreeViewApi, TreeViewService } from "./tree-view.types"
14+
import { getCheckedState, getCheckedValueMap } from "./utils/checked-state"
1415

15-
export function connect<T extends PropTypes>(service: TreeViewService, normalize: NormalizeProps<T>): TreeViewApi<T> {
16+
export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
17+
service: TreeViewService<V>,
18+
normalize: NormalizeProps<T>,
19+
): TreeViewApi<T, V> {
1620
const { context, scope, computed, prop, send } = service
1721
const collection = prop("collection")
22+
1823
const expandedValue = Array.from(context.get("expandedValue"))
1924
const selectedValue = Array.from(context.get("selectedValue"))
25+
const checkedValue = Array.from(context.get("checkedValue"))
26+
2027
const isTypingAhead = computed("isTypingAhead")
2128
const focusedValue = context.get("focusedValue")
2229
const loadingStatus = context.get("loadingStatus")
@@ -35,13 +42,29 @@ export function connect<T extends PropTypes>(service: TreeViewService, normalize
3542
loading: loadingStatus[value] === "loading",
3643
depth: indexPath.length,
3744
isBranch: collection.isBranchNode(node),
45+
get checked() {
46+
return getCheckedState(collection, node, checkedValue)
47+
},
3848
}
3949
}
4050

4151
return {
4252
collection,
4353
expandedValue,
4454
selectedValue,
55+
checkedValue,
56+
toggleChecked(value, isBranch) {
57+
send({ type: "CHECKED.TOGGLE", value, isBranch })
58+
},
59+
setChecked(value) {
60+
send({ type: "CHECKED.SET", value })
61+
},
62+
clearChecked() {
63+
send({ type: "CHECKED.CLEAR" })
64+
},
65+
getCheckedMap() {
66+
return getCheckedValueMap(collection, checkedValue)
67+
},
4568
expand(value) {
4669
send({ type: value ? "BRANCH.EXPAND" : "EXPANDED.ALL", value })
4770
},
@@ -52,7 +75,7 @@ export function connect<T extends PropTypes>(service: TreeViewService, normalize
5275
send({ type: value ? "NODE.DESELECT" : "SELECTED.CLEAR", value })
5376
},
5477
select(value) {
55-
send({ type: value ? "NODE.SELECT" : "SELECTED.ALL", value })
78+
send({ type: value ? "NODE.SELECT" : "SELECTED.ALL", value, isTrusted: false })
5679
},
5780
getVisibleNodes() {
5881
return computed("visibleNodes").map(({ node }) => node)
@@ -388,5 +411,27 @@ export function connect<T extends PropTypes>(service: TreeViewService, normalize
388411
"data-depth": nodeState.depth,
389412
})
390413
},
414+
415+
getItemCheckboxProps(props) {
416+
const nodeState = getNodeState(props)
417+
return normalize.element({
418+
...parts.itemCheckbox.attrs,
419+
tabIndex: -1,
420+
role: "checkbox",
421+
"data-state":
422+
nodeState.checked === true ? "checked" : nodeState.checked === false ? "unchecked" : "indeterminate",
423+
"aria-checked": nodeState.checked === true ? "true" : nodeState.checked === false ? "false" : "mixed",
424+
onClick(event) {
425+
if (event.defaultPrevented) return
426+
if (nodeState.disabled) return
427+
428+
send({ type: "CHECKED.TOGGLE", value: nodeState.value, isBranch: nodeState.isBranch })
429+
event.stopPropagation()
430+
431+
const node = event.currentTarget.closest("[role=treeitem]") as HTMLElement | null
432+
node?.focus({ preventScroll: true })
433+
},
434+
})
435+
},
391436
}
392437
}

packages/machines/tree-view/src/tree-view.machine.ts

Lines changed: 40 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import type { TreeNode, TreeSkipFn } from "@zag-js/collection"
2-
import { createGuards, createMachine, type Params } from "@zag-js/core"
1+
import type { TreeNode } from "@zag-js/collection"
2+
import { createGuards, createMachine } from "@zag-js/core"
33
import { getByTypeahead } from "@zag-js/dom-query"
4-
import { add, addOrRemove, diff, ensure, first, isArray, isEqual, last, remove, toArray, uniq } from "@zag-js/utils"
4+
import { addOrRemove, diff, first, isArray, isEqual, last, remove, toArray, uniq } from "@zag-js/utils"
55
import { collection } from "./tree-view.collection"
66
import * as dom from "./tree-view.dom"
77
import type { TreeLoadingStatusMap, TreeViewSchema } from "./tree-view.types"
8+
import { toggleBranchChecked } from "./utils/checked-state"
9+
import { expandBranches } from "./utils/expand-branch"
10+
import { skipFn } from "./utils/visit-skip"
811

912
const { and } = createGuards<TreeViewSchema>()
1013

@@ -56,6 +59,14 @@ export const machine = createMachine<TreeViewSchema>({
5659
loadingStatus: bindable<TreeLoadingStatusMap>(() => ({
5760
defaultValue: {},
5861
})),
62+
checkedValue: bindable(() => ({
63+
defaultValue: prop("defaultCheckedValue") || [],
64+
value: prop("checkedValue"),
65+
isEqual,
66+
onChange(value) {
67+
prop("onCheckedChange")?.({ checkedValue: value })
68+
},
69+
})),
5970
}
6071
},
6172

@@ -119,6 +130,15 @@ export const machine = createMachine<TreeViewSchema>({
119130
"NODE.DESELECT": {
120131
actions: ["deselectNode"],
121132
},
133+
"CHECKED.TOGGLE": {
134+
actions: ["toggleChecked"],
135+
},
136+
"CHECKED.SET": {
137+
actions: ["setChecked"],
138+
},
139+
"CHECKED.CLEAR": {
140+
actions: ["clearChecked"],
141+
},
122142
},
123143

124144
exit: ["clearPendingAborts"],
@@ -241,14 +261,12 @@ export const machine = createMachine<TreeViewSchema>({
241261
expandOnClick: ({ prop }) => !!prop("expandOnClick"),
242262
},
243263
actions: {
244-
selectNode({ context, event, prop }) {
245-
const value = toArray(event.id || event.value)
264+
selectNode({ context, event }) {
265+
const value = (event.id || event.value) as string | string[] | undefined
246266
context.set("selectedValue", (prev) => {
247-
if (prop("selectionMode") === "single") {
248-
return [last(value)].filter(Boolean)
249-
} else {
250-
return uniq([...prev, ...value])
251-
}
267+
if (value == null) return prev
268+
if (!event.isTrusted && isArray(value)) return prev.concat(...value)
269+
return [isArray(value) ? last(value) : value].filter(Boolean) as string[]
252270
})
253271
},
254272
deselectNode({ context, event }) {
@@ -480,114 +498,18 @@ export const machine = createMachine<TreeViewSchema>({
480498
aborts.forEach((abort) => abort.abort())
481499
aborts.clear()
482500
},
501+
toggleChecked({ context, event, prop }) {
502+
const collection = prop("collection")
503+
context.set("checkedValue", (prev) =>
504+
event.isBranch ? toggleBranchChecked(collection, event.value, prev) : addOrRemove(prev, event.value),
505+
)
506+
},
507+
setChecked({ context, event }) {
508+
context.set("checkedValue", event.value)
509+
},
510+
clearChecked({ context }) {
511+
context.set("checkedValue", [])
512+
},
483513
},
484514
},
485515
})
486-
487-
function skipFn(params: Pick<Params<TreeViewSchema>, "prop" | "context">): TreeSkipFn<any> {
488-
const { prop, context } = params
489-
return function skip({ indexPath }) {
490-
const paths = prop("collection").getValuePath(indexPath).slice(0, -1)
491-
return paths.some((value) => !context.get("expandedValue").includes(value))
492-
}
493-
}
494-
495-
function partition<T>(array: T[], predicate: (value: T) => boolean) {
496-
const pass: T[] = []
497-
const fail: T[] = []
498-
array.forEach((value) => {
499-
if (predicate(value)) pass.push(value)
500-
else fail.push(value)
501-
})
502-
return [pass, fail]
503-
}
504-
505-
function expandBranches(params: Params<TreeViewSchema>, ids: string[]) {
506-
const { context, prop, refs } = params
507-
508-
if (!prop("loadChildren")) {
509-
context.set("expandedValue", (prev) => uniq(add(prev, ...ids)))
510-
return
511-
}
512-
513-
const [loadedValues, loadingValues] = partition(ids, (id) => context.get("loadingStatus")[id] === "loaded")
514-
515-
if (loadedValues.length > 0) {
516-
context.set("expandedValue", (prev) => uniq(add(prev, ...loadedValues)))
517-
}
518-
519-
if (loadingValues.length === 0) return
520-
521-
const collection = prop("collection")
522-
const [nodeWithChildren, nodeWithoutChildren] = partition(loadingValues, (id) => {
523-
const node = collection.findNode(id)
524-
return collection.getNodeChildren(node).length > 0
525-
})
526-
527-
// Check if node already has children (skip loading)
528-
if (nodeWithChildren.length > 0) {
529-
context.set("expandedValue", (prev) => uniq(add(prev, ...nodeWithChildren)))
530-
}
531-
532-
if (nodeWithoutChildren.length === 0) return
533-
534-
context.set("loadingStatus", (prev) => ({
535-
...prev,
536-
...nodeWithoutChildren.reduce((acc, id) => ({ ...acc, [id]: "loading" }), {}),
537-
}))
538-
539-
const nodesToLoad = nodeWithoutChildren.map((id) => {
540-
const indexPath = collection.getIndexPath(id)!
541-
const valuePath = collection.getValuePath(indexPath)!
542-
const node = collection.findNode(id)!
543-
return { id, indexPath, valuePath, node }
544-
})
545-
546-
const pendingAborts = refs.get("pendingAborts")
547-
548-
// load children asynchronously
549-
const loadChildren = prop("loadChildren")
550-
ensure(loadChildren, () => "[zag-js/tree-view] `loadChildren` is required for async expansion")
551-
552-
const proms = nodesToLoad.map(({ id, indexPath, valuePath, node }) => {
553-
const existingAbort = pendingAborts.get(id)
554-
if (existingAbort) {
555-
existingAbort.abort()
556-
pendingAborts.delete(id)
557-
}
558-
const abortController = new AbortController()
559-
pendingAborts.set(id, abortController)
560-
return loadChildren({
561-
valuePath,
562-
indexPath,
563-
node,
564-
signal: abortController.signal,
565-
})
566-
})
567-
568-
// prefer `Promise.allSettled` over `Promise.all` to avoid early termination
569-
Promise.allSettled(proms).then((results) => {
570-
const loadedValues: string[] = []
571-
const nextLoadingStatus = context.get("loadingStatus")
572-
let collection = prop("collection")
573-
574-
results.forEach((result, index) => {
575-
const { id, indexPath, node } = nodesToLoad[index]
576-
if (result.status === "fulfilled") {
577-
nextLoadingStatus[id] = "loaded"
578-
loadedValues.push(id)
579-
collection = collection.replace(indexPath, { ...node, children: result.value })
580-
} else {
581-
pendingAborts.delete(id)
582-
Reflect.deleteProperty(nextLoadingStatus, id)
583-
}
584-
})
585-
586-
context.set("loadingStatus", nextLoadingStatus)
587-
588-
if (loadedValues.length) {
589-
context.set("expandedValue", (prev) => uniq(add(prev, ...loadedValues)))
590-
prop("onLoadChildrenComplete")?.({ collection })
591-
}
592-
})
593-
}

packages/machines/tree-view/src/tree-view.props.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ export const props = createProps<TreeViewProps>()([
1414
"onExpandedChange",
1515
"onFocusChange",
1616
"onSelectionChange",
17+
"checkedValue",
1718
"selectedValue",
1819
"selectionMode",
1920
"typeahead",
2021
"defaultExpandedValue",
2122
"defaultSelectedValue",
23+
"defaultCheckedValue",
24+
"onCheckedChange",
2225
"onLoadChildrenComplete",
26+
"onLoadChildrenError",
2327
"loadChildren",
2428
])
2529

0 commit comments

Comments
 (0)