Skip to content

Commit 3a2c24f

Browse files
committed
feat(tree-view): renaming nodes
1 parent d2af59c commit 3a2c24f

File tree

13 files changed

+989
-3
lines changed

13 files changed

+989
-3
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
---
2+
"@zag-js/tree-view": minor
3+
---
4+
5+
Add support for renaming tree node labels with validation and control features.
6+
7+
This feature enables users to edit tree node labels inline, unlocking use cases like file explorers, folder management
8+
systems, content hierarchies, and any tree-based interface where users need to rename items.
9+
10+
## Key Features
11+
12+
### Basic Renaming
13+
14+
- Press `F2` on any node to enter rename mode
15+
- Input is automatically focused and synced with current label
16+
- Press `Enter` to submit or `Escape` to cancel
17+
- Blur event automatically submits changes
18+
- IME composition events are properly handled for international input
19+
20+
### Validation & Control
21+
22+
- **`canRename`** - Control which nodes are renameable based on node type or custom logic
23+
- **`onRenameStart`** - Called when rename mode starts (useful for analytics, showing hints)
24+
- **`onBeforeRename`** - Validate rename before accepting (e.g., prevent duplicates, empty names)
25+
- **Empty name prevention** - Automatically stays in rename mode if submitted name is empty/whitespace
26+
- **Label trimming** - Labels are automatically trimmed before being passed to callbacks
27+
- **`onRenameComplete`** - Handle the rename and update your collection
28+
29+
### Styling & Visual State
30+
31+
- **`data-renaming`** attribute - Added to both item and branch elements when in rename mode for easy styling
32+
- **`nodeState.renaming`** - Boolean property to check if a node is currently being renamed
33+
34+
## API
35+
36+
```tsx
37+
const [collection, setCollection] = useState(initialCollection)
38+
39+
useMachine(tree.machine, {
40+
collection,
41+
42+
// Control which nodes can be renamed
43+
canRename: (node, indexPath) => {
44+
// Only allow renaming leaf nodes (files), not branches (folders)
45+
return !node.children
46+
},
47+
48+
// Called when rename mode starts
49+
onRenameStart: (details) => {
50+
// details contains: { value, node, indexPath }
51+
console.log("Started renaming:", details.node.name)
52+
// Track analytics, show hints, etc.
53+
},
54+
55+
// Validate before accepting rename
56+
onBeforeRename: (details) => {
57+
// Note: details.label is already trimmed by the machine
58+
59+
// Prevent empty names
60+
if (!details.label) return false
61+
62+
// Prevent duplicate names at the same level
63+
const parentPath = details.indexPath.slice(0, -1)
64+
const parent = parentPath.length > 0 ? collection.at(parentPath) : collection.rootNode
65+
const siblings = parent?.children || []
66+
67+
const hasDuplicate = siblings.some((sibling) => sibling.name === details.label && sibling.id !== details.value)
68+
69+
return !hasDuplicate
70+
},
71+
72+
// Handle successful rename
73+
onRenameComplete: (details) => {
74+
// details contains: { value, label (trimmed), indexPath }
75+
const node = collection.at(details.indexPath)
76+
const updatedCollection = collection.replace(details.indexPath, {
77+
...node,
78+
name: details.label,
79+
})
80+
setCollection(updatedCollection)
81+
},
82+
})
83+
```
84+
85+
## Component Integration
86+
87+
```tsx
88+
const TreeNode = ({ node, indexPath, api }) => {
89+
const nodeState = api.getNodeState({ node, indexPath })
90+
91+
return (
92+
<div {...api.getItemProps({ node, indexPath })}>
93+
<FileIcon />
94+
95+
{/* Show text when not renaming */}
96+
<span {...api.getItemTextProps({ node, indexPath })} style={{ display: nodeState.renaming ? "none" : "inline" }}>
97+
{node.name}
98+
</span>
99+
100+
{/* Show input when renaming */}
101+
<input {...api.getNodeRenameInputProps({ node, indexPath })} />
102+
</div>
103+
)
104+
}
105+
```
106+
107+
## Programmatic API
108+
109+
```tsx
110+
// Start renaming a node
111+
api.startRenaming(nodeValue)
112+
113+
// Submit rename with new label
114+
api.submitRenaming(nodeValue, newLabel)
115+
116+
// Cancel renaming
117+
api.cancelRenaming()
118+
```
119+
120+
## Node State & Styling
121+
122+
The `nodeState` now includes a `renaming` property to track rename mode:
123+
124+
```tsx
125+
const nodeState = api.getNodeState({ node, indexPath })
126+
// nodeState.renaming -> boolean
127+
```
128+
129+
Both `getItemProps` and `getBranchControlProps` now include a `data-renaming` attribute for styling:
130+
131+
```css
132+
/* Style items being renamed */
133+
[data-part="item"][data-renaming] {
134+
outline: 2px solid blue;
135+
}
136+
137+
/* Style branch controls being renamed */
138+
[data-part="branch-control"][data-renaming] {
139+
background: rgba(0, 0, 255, 0.1);
140+
}
141+
```
142+
143+
## Use Cases Unlocked
144+
145+
1. **File Explorers** - Allow users to rename files and folders with validation
146+
2. **Content Management** - Edit page titles, categories, or navigation items in-place
147+
3. **Folder Organization** - Rename folders with duplicate prevention
148+
4. **Project Management** - Edit task names, project hierarchies
149+
5. **Knowledge Bases** - Rename articles, sections, or categories inline
150+
151+
## Example: File Explorer with Smart Validation
152+
153+
```tsx
154+
useMachine(tree.machine, {
155+
collection,
156+
157+
canRename: (node, indexPath) => {
158+
// Prevent renaming system files
159+
if (node.system) return false
160+
// Prevent renaming locked files
161+
if (node.locked) return false
162+
// Only allow renaming files, not folders
163+
return !node.children
164+
},
165+
166+
onBeforeRename: (details) => {
167+
// Note: details.label is already trimmed
168+
169+
// Check file extension rules
170+
if (!details.label.includes(".")) {
171+
console.error("File must have an extension")
172+
return false
173+
}
174+
175+
// Validate file name characters
176+
if (/[<>:"/\\|?*]/.test(details.label)) {
177+
console.error("Invalid characters in filename")
178+
return false
179+
}
180+
181+
return true
182+
},
183+
184+
onRenameComplete: (details) => {
185+
// Update collection and sync to backend
186+
const node = collection.at(details.indexPath)
187+
const updatedCollection = collection.replace(details.indexPath, {
188+
...node,
189+
name: details.label,
190+
lastModified: new Date(),
191+
})
192+
setCollection(updatedCollection)
193+
194+
// Sync to server
195+
api.renameFile(details.value, details.label)
196+
},
197+
})
198+
```

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type {
1414
LoadChildrenCompleteDetails,
1515
LoadChildrenDetails,
1616
LoadChildrenErrorDetails,
17+
RenameCompleteDetails,
18+
RenameStartDetails,
1719
TreeViewMachine as Machine,
1820
NodeProps,
1921
NodeState,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const anatomy = createAnatomy("tree-view").parts(
1313
"itemText",
1414
"label",
1515
"nodeCheckbox",
16+
"nodeRenameInput",
1617
"root",
1718
"tree",
1819
)

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
3131
const isTypingAhead = computed("isTypingAhead")
3232
const focusedValue = context.get("focusedValue")
3333
const loadingStatus = context.get("loadingStatus")
34+
const renamingValue = context.get("renamingValue")
3435

3536
function getNodeState(props: NodeProps): NodeState {
3637
const { node, indexPath } = props
@@ -49,6 +50,7 @@ export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
4950
loading: loadingStatus[value] === "loading",
5051
depth: indexPath.length,
5152
isBranch: collection.isBranchNode(node),
53+
renaming: renamingValue === value,
5254
get checked() {
5355
return getCheckedState(collection, node, checkedValue)
5456
},
@@ -110,6 +112,15 @@ export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
110112
const _selectedValue = uniq(value)
111113
send({ type: "SELECTED.SET", value: _selectedValue })
112114
},
115+
startRenaming(value) {
116+
send({ type: "NODE.RENAME", value })
117+
},
118+
submitRenaming(value, label) {
119+
send({ type: "RENAME.SUBMIT", value, label })
120+
},
121+
cancelRenaming() {
122+
send({ type: "RENAME.CANCEL" })
123+
},
113124

114125
getRootProps() {
115126
return normalize.element({
@@ -217,6 +228,24 @@ export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
217228
event.preventDefault()
218229
send({ type: "SELECTED.ALL", moveFocus: true })
219230
},
231+
F2(event) {
232+
if (node.dataset.disabled) return
233+
234+
// Check canRename callback if provided
235+
const canRenameFn = prop("canRename")
236+
if (canRenameFn) {
237+
const indexPath = collection.getIndexPath(nodeId)
238+
if (indexPath) {
239+
const node = collection.at(indexPath)
240+
if (node && !canRenameFn(node, indexPath)) {
241+
return
242+
}
243+
}
244+
}
245+
246+
event.preventDefault()
247+
send({ type: "NODE.RENAME", value: nodeId })
248+
},
220249
}
221250

222251
const key = getEventKey(event, { dir: prop("dir") })
@@ -254,6 +283,7 @@ export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
254283
"data-selected": dataAttr(nodeState.selected),
255284
"aria-disabled": ariaAttr(nodeState.disabled),
256285
"data-disabled": dataAttr(nodeState.disabled),
286+
"data-renaming": dataAttr(nodeState.renaming),
257287
"aria-level": nodeState.depth,
258288
"data-depth": nodeState.depth,
259289
style: {
@@ -372,6 +402,7 @@ export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
372402
"data-disabled": dataAttr(nodeState.disabled),
373403
"data-selected": dataAttr(nodeState.selected),
374404
"data-focus": dataAttr(nodeState.focused),
405+
"data-renaming": dataAttr(nodeState.renaming),
375406
"data-value": nodeState.value,
376407
"data-depth": nodeState.depth,
377408
"data-loading": dataAttr(nodeState.loading),
@@ -449,5 +480,34 @@ export function connect<T extends PropTypes, V extends TreeNode = TreeNode>(
449480
},
450481
})
451482
},
483+
484+
getNodeRenameInputProps(props) {
485+
const nodeState = getNodeState(props)
486+
return normalize.input({
487+
...parts.nodeRenameInput.attrs,
488+
id: dom.getRenameInputId(scope, nodeState.value),
489+
type: "text",
490+
"aria-label": "Rename tree item",
491+
hidden: !nodeState.renaming,
492+
onKeyDown(event) {
493+
// CRITICAL: Ignore keyboard events during IME composition
494+
if (isComposingEvent(event)) return
495+
496+
if (event.key === "Escape") {
497+
send({ type: "RENAME.CANCEL" })
498+
event.preventDefault()
499+
}
500+
if (event.key === "Enter") {
501+
send({ type: "RENAME.SUBMIT", label: event.currentTarget.value })
502+
event.preventDefault()
503+
}
504+
// Stop propagation to prevent tree navigation during renaming
505+
event.stopPropagation()
506+
},
507+
onBlur(event) {
508+
send({ type: "RENAME.SUBMIT", label: event.currentTarget.value })
509+
},
510+
})
511+
},
452512
}
453513
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ export const focusNode = (ctx: Scope, value: string | null | undefined) => {
1010
if (value == null) return
1111
ctx.getById(getNodeId(ctx, value))?.focus()
1212
}
13+
14+
export const getRenameInputId = (ctx: Scope, value: string) => `tree:${ctx.id}:rename-input:${value}`
15+
16+
export const getRenameInputEl = (ctx: Scope, value: string) => {
17+
return ctx.getById<HTMLInputElement>(getRenameInputId(ctx, value))
18+
}

0 commit comments

Comments
 (0)