@cocoar/vue-ui 3.1.0 → 3.2.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/components/checkbox/CoarCheckbox.vue.d.ts +1 -1
  2. package/dist/components/checkbox/CoarCheckbox.vue.d.ts.map +1 -1
  3. package/dist/components/code-block/CoarCodeBlock.vue.d.ts +1 -1
  4. package/dist/components/code-block/CoarCodeBlock.vue.d.ts.map +1 -1
  5. package/dist/components/data-list/CoarDataList.vue.d.ts +158 -0
  6. package/dist/components/data-list/CoarDataList.vue.d.ts.map +1 -0
  7. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts +30 -0
  8. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts.map +1 -0
  9. package/dist/components/data-list/data-list-builder.d.ts +288 -0
  10. package/dist/components/data-list/data-list-builder.d.ts.map +1 -0
  11. package/dist/components/data-list/index.d.ts +12 -0
  12. package/dist/components/data-list/index.d.ts.map +1 -0
  13. package/dist/components/data-list/internal/compare.d.ts +14 -0
  14. package/dist/components/data-list/internal/compare.d.ts.map +1 -0
  15. package/dist/components/data-list/internal/reorder-core.d.ts +46 -0
  16. package/dist/components/data-list/internal/reorder-core.d.ts.map +1 -0
  17. package/dist/components/data-list/internal/search.d.ts +16 -0
  18. package/dist/components/data-list/internal/search.d.ts.map +1 -0
  19. package/dist/components/data-list/internal/useDataListLines.d.ts +92 -0
  20. package/dist/components/data-list/internal/useDataListLines.d.ts.map +1 -0
  21. package/dist/components/data-list/internal/useDataListReorder.d.ts +68 -0
  22. package/dist/components/data-list/internal/useDataListReorder.d.ts.map +1 -0
  23. package/dist/components/data-list/internal/useSearchHighlight.d.ts +19 -0
  24. package/dist/components/data-list/internal/useSearchHighlight.d.ts.map +1 -0
  25. package/dist/components/data-list/types.d.ts +185 -0
  26. package/dist/components/data-list/types.d.ts.map +1 -0
  27. package/dist/components/data-list/useDataListModel.d.ts +79 -0
  28. package/dist/components/data-list/useDataListModel.d.ts.map +1 -0
  29. package/dist/components/date-time/_shared/maskito-config.d.ts +7 -2
  30. package/dist/components/date-time/_shared/maskito-config.d.ts.map +1 -1
  31. package/dist/components/date-time/_shared/time-helpers.d.ts +11 -4
  32. package/dist/components/date-time/_shared/time-helpers.d.ts.map +1 -1
  33. package/dist/components/date-time/_shared/use-date-picker-base.d.ts +1 -1
  34. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts +1 -1
  35. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts.map +1 -1
  36. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts +1 -1
  37. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts.map +1 -1
  38. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts +1 -1
  39. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts.map +1 -1
  40. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts +3 -0
  41. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts.map +1 -1
  42. package/dist/components/form-field/CoarFormField.vue.d.ts +1 -1
  43. package/dist/components/form-field/CoarFormField.vue.d.ts.map +1 -1
  44. package/dist/components/icon/CoarIcon.vue.d.ts +2 -2
  45. package/dist/components/icon/CoarIcon.vue.d.ts.map +1 -1
  46. package/dist/components/listbox/CoarListbox.vue.d.ts +7 -0
  47. package/dist/components/listbox/CoarListbox.vue.d.ts.map +1 -1
  48. package/dist/components/number-input/CoarNumberInput.vue.d.ts +1 -1
  49. package/dist/components/number-input/CoarNumberInput.vue.d.ts.map +1 -1
  50. package/dist/components/otp-input/CoarOtpInput.vue.d.ts +1 -1
  51. package/dist/components/otp-input/CoarOtpInput.vue.d.ts.map +1 -1
  52. package/dist/components/password-input/CoarPasswordInput.vue.d.ts +1 -1
  53. package/dist/components/password-input/CoarPasswordInput.vue.d.ts.map +1 -1
  54. package/dist/components/switch/CoarSwitch.vue.d.ts +1 -1
  55. package/dist/components/switch/CoarSwitch.vue.d.ts.map +1 -1
  56. package/dist/components/text-input/CoarTextInput.vue.d.ts +1 -1
  57. package/dist/components/text-input/CoarTextInput.vue.d.ts.map +1 -1
  58. package/dist/composables/dragRegistry.d.ts +12 -0
  59. package/dist/composables/dragRegistry.d.ts.map +1 -1
  60. package/dist/composables/index.d.ts +2 -2
  61. package/dist/composables/index.d.ts.map +1 -1
  62. package/dist/composables/useDragDrop.d.ts +62 -6
  63. package/dist/composables/useDragDrop.d.ts.map +1 -1
  64. package/dist/composables/useVirtualList.d.ts +21 -1
  65. package/dist/composables/useVirtualList.d.ts.map +1 -1
  66. package/dist/index.css +1 -1
  67. package/dist/index.d.ts +4 -2
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +4508 -2039
  70. package/package.json +5 -3
  71. package/skills/cocoar-vue-ui/SKILL.md +244 -0
  72. package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
  73. package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
  74. package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
  75. package/skills/cocoar-vue-ui/references/components/button.md +252 -0
  76. package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +353 -0
  77. package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1645 -0
  78. package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
  79. package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
  80. package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +475 -0
  81. package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
  82. package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
  83. package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
  84. package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
  85. package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
  86. package/skills/cocoar-vue-ui/references/components/card.md +151 -0
  87. package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
  88. package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
  89. package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
  90. package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
  91. package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
  92. package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
  93. package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
  94. package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
  95. package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
  96. package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
  97. package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
  98. package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
  99. package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
  100. package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
  101. package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
  102. package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
  103. package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
  104. package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
  105. package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
  106. package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
  107. package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
  108. package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
  109. package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
  110. package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
  111. package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
  112. package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
  113. package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
  114. package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
  115. package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
  116. package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
  117. package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
  118. package/skills/cocoar-vue-ui/references/components/link.md +235 -0
  119. package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
  120. package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
  121. package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
  122. package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
  123. package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
  124. package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
  125. package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
  126. package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
  127. package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
  128. package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
  129. package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
  130. package/skills/cocoar-vue-ui/references/components/note.md +96 -0
  131. package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
  132. package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
  133. package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
  134. package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
  135. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
  136. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
  137. package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
  138. package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
  139. package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
  140. package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
  141. package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
  142. package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
  143. package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
  144. package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
  145. package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
  146. package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
  147. package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
  148. package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
  149. package/skills/cocoar-vue-ui/references/components/select.md +463 -0
  150. package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
  151. package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
  152. package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
  153. package/skills/cocoar-vue-ui/references/components/table.md +170 -0
  154. package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
  155. package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
  156. package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
  157. package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
  158. package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
  159. package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
  160. package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
  161. package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
  162. package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
  163. package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
  164. package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
  165. package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
  166. package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
  167. package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
  168. package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
  169. package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
  170. package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
  171. package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
  172. package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
  173. package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
  174. package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
  175. package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
  176. package/skills/cocoar-vue-ui/references/guide/getting-started.md +119 -0
  177. package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
  178. package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
  179. package/skills/cocoar-vue-ui/references/guide/theming.md +98 -0
@@ -0,0 +1,2388 @@
1
+ <!-- Generated from apps/docs/components/tree.md by apps/docs/scripts/sync-skill.mjs. Do not edit; edit the docs page. -->
2
+
3
+ # Tree
4
+
5
+ A generic, keyboard-navigable, drag-drop-aware tree primitive. Identity, children and label are extracted via prop functions — render any node shape (file systems, navigation, settings, categories, …) without forcing a common base type.
6
+
7
+ ```ts
8
+ import {
9
+ CoarTree,
10
+ useTree,
11
+ type CoarTreeNodeMoveEvent,
12
+ type CoarTreeFilesDropEvent,
13
+ } from '@cocoar/vue-ui';
14
+ ```
15
+
16
+ > **Info: Two APIs**
17
+ >
18
+ > `<CoarTree>` works in two modes:
19
+ >
20
+ > - **Props-mode** — pass `nodes`, `getId`, `getChildren`, etc. as props; wire `<CoarContextMenu>` yourself. Good for simple cases.
21
+ > - **Builder-mode** — `useTree()` returns a fluent builder. Declarative per-target context menus are rendered internally, the imperative `api` lets you focus nodes from outside the component. Recommended for anything beyond the basics.
22
+ >
23
+ > The two compose — but pick one per `<CoarTree>` instance; mixing props and builder on the same instance is a footgun.
24
+
25
+ > **Tip: Desktop-first**
26
+ >
27
+ > `<CoarTree>` is an explicit exception to the library's tablet-first principle. Right-click context menus and hover-revealed UI on rows (⋮ buttons, inline actions) are part of the intended UX. The component still works on touch devices but isn't tuned for them — use it for power-user surfaces (file managers, settings explorers, asset trees) rather than mobile navigation.
28
+
29
+ ## Playground
30
+
31
+ Flip every option on or off, switch density, filter, drag-reorder, rename, drive the imperative `api`, and watch the event log — the whole feature surface in one sandbox.
32
+
33
+ **Demo — `tree/demos/TreePlayground.vue`**
34
+
35
+ ```vue
36
+ <script setup lang="ts">
37
+ /**
38
+ * Kitchen-sink playground: flip every CoarTree option on/off, switch density,
39
+ * filter, drag-reorder, rename, drive the imperative api, and watch the event
40
+ * log. A live sandbox for the whole feature surface in one place.
41
+ */
42
+ import { reactive, ref, computed } from 'vue';
43
+ import {
44
+ CoarTree,
45
+ CoarTreeNodeLabel,
46
+ CoarIcon,
47
+ CoarSwitch,
48
+ CoarButton,
49
+ CoarTextInput,
50
+ CoarSegmentedControl,
51
+ type CoarTreeSelectionMode,
52
+ type CoarTreeDensity,
53
+ type CoarTreeNodeMoveEvent,
54
+ type CoarTreeSelectEvent,
55
+ } from '@cocoar/vue-ui';
56
+
57
+ interface Node {
58
+ id: string;
59
+ name: string;
60
+ children?: Node[];
61
+ }
62
+
63
+ // Mutable (reactive) so drag-reorder + rename actually change the tree.
64
+ const treeData = reactive<Node[]>([
65
+ {
66
+ id: 'src',
67
+ name: 'src',
68
+ children: [
69
+ {
70
+ id: 'src/components',
71
+ name: 'components',
72
+ children: [
73
+ { id: 'src/components/Button.vue', name: 'Button.vue' },
74
+ { id: 'src/components/Tree.vue', name: 'Tree.vue' },
75
+ { id: 'src/components/Modal.vue', name: 'Modal.vue' },
76
+ ],
77
+ },
78
+ {
79
+ id: 'src/composables',
80
+ name: 'composables',
81
+ children: [
82
+ { id: 'src/composables/useTree.ts', name: 'useTree.ts' },
83
+ { id: 'src/composables/useDrag.ts', name: 'useDrag.ts' },
84
+ ],
85
+ },
86
+ { id: 'src/main.ts', name: 'main.ts' },
87
+ { id: 'src/App.vue', name: 'App.vue' },
88
+ ],
89
+ },
90
+ {
91
+ id: 'docs',
92
+ name: 'docs',
93
+ children: [
94
+ { id: 'docs/guide.md', name: 'guide.md' },
95
+ { id: 'docs/api.md', name: 'api.md' },
96
+ ],
97
+ },
98
+ { id: 'package.json', name: 'package.json' },
99
+ { id: 'README.md', name: 'README.md' },
100
+ ]);
101
+
102
+ // ─── option state (everything toggleable) ───────────────────────────────────
103
+ const selectionMode = ref<CoarTreeSelectionMode>('single');
104
+ const density = ref<CoarTreeDensity>('m');
105
+ const draggable = ref(false);
106
+ const renamable = ref(false);
107
+ const acceptsFiles = ref(false);
108
+ const activateOnClick = ref(false);
109
+ const virtualize = ref(false);
110
+ const checkStrictly = ref(false);
111
+ const disableSome = ref(false);
112
+ const search = ref('');
113
+ const filter = ref(false);
114
+ const filterMode = ref<'strict' | 'lenient'>('strict');
115
+ const filterModeOptions = (['strict', 'lenient'] as const).map((v) => ({ value: v, label: v }));
116
+
117
+ const modeOptions = (['single', 'multiple', 'checkbox'] as const).map((v) => ({ value: v, label: v }));
118
+ const densityOptions = (['xs', 's', 'm', 'l'] as const).map((v) => ({ value: v, label: v }));
119
+
120
+ // ─── models ──────────────────────────────────────────────────────────────
121
+ const expanded = ref(new Set<string>(['src', 'src/components']));
122
+ const selected = ref<string | null>(null);
123
+ const selectedIds = ref(new Set<string>());
124
+ const checkedIds = ref(new Set<string>());
125
+
126
+ // ─── derived ───────────────────────────────────────────────────────────────
127
+ const DISABLED = new Set(['package.json', 'README.md']);
128
+ const isDisabledFn = (n: Node) => DISABLED.has(n.id);
129
+
130
+ const matchedIds = computed(() => {
131
+ const q = search.value.trim().toLowerCase();
132
+ if (!q) return undefined;
133
+ const hits = new Set<string>();
134
+ const walk = (list: Node[]) => {
135
+ for (const n of list) {
136
+ if (n.name.toLowerCase().includes(q)) hits.add(n.id);
137
+ if (n.children) walk(n.children);
138
+ }
139
+ };
140
+ walk(treeData);
141
+ return hits;
142
+ });
143
+
144
+ const vItemSize = computed(() => ({ xs: 22, s: 26, m: 30, l: 38 })[density.value]);
145
+
146
+ // ─── event log ───────────────────────────────────────────────────────────
147
+ let seq = 0;
148
+ const events = ref<string[]>([]);
149
+ function log(msg: string) {
150
+ events.value = [`#${++seq} ${msg}`, ...events.value].slice(0, 6);
151
+ }
152
+
153
+ // ─── handlers ──────────────────────────────────────────────────────────────
154
+ function onSelect(e: CoarTreeSelectEvent<Node>) {
155
+ log(`select ${e.node?.name ?? '—'} (${e.ids.length} total, ${e.via})`);
156
+ }
157
+ function onActivate(n: Node) {
158
+ log(`activate ${n.name}`);
159
+ }
160
+ function onRename({ node, newName }: { node: Node; newName: string }) {
161
+ node.name = newName;
162
+ log(`rename → ${newName}`);
163
+ }
164
+ function onFilesDrop({ files, target }: { files: FileList; target: Node | null }) {
165
+ log(`files-drop ${files.length} → ${target?.name ?? 'root'}`);
166
+ }
167
+
168
+ function detach(id: string): Node | null {
169
+ const rec = (list: Node[]): Node | null => {
170
+ const i = list.findIndex((n) => n.id === id);
171
+ if (i >= 0) return list.splice(i, 1)[0];
172
+ for (const n of list) if (n.children) {
173
+ const f = rec(n.children);
174
+ if (f) return f;
175
+ }
176
+ return null;
177
+ };
178
+ return rec(treeData);
179
+ }
180
+ function parentListOf(id: string): Node[] | null {
181
+ const rec = (list: Node[]): Node[] | null => {
182
+ if (list.some((n) => n.id === id)) return list;
183
+ for (const n of list) if (n.children) {
184
+ const f = rec(n.children);
185
+ if (f) return f;
186
+ }
187
+ return null;
188
+ };
189
+ return rec(treeData);
190
+ }
191
+ function onNodeMove({ source, target, position }: CoarTreeNodeMoveEvent<Node>) {
192
+ const node = detach(source.id);
193
+ if (!node) return;
194
+ if (!target || position === 'inside') {
195
+ const into = target ? (target.children ??= []) : treeData;
196
+ into.push(node);
197
+ } else {
198
+ const list = parentListOf(target.id) ?? treeData;
199
+ const idx = list.findIndex((n) => n.id === target.id);
200
+ list.splice(position === 'before' ? idx : idx + 1, 0, node);
201
+ }
202
+ log(`move ${node.name} ${position} ${target?.name ?? 'root'}`);
203
+ }
204
+
205
+ // ─── imperative api (template ref) ───────────────────────────────────────
206
+ const treeRef = ref<{
207
+ expandAll(): void;
208
+ collapseAll(): void;
209
+ revealNode(id: string): void;
210
+ startRename(id: string): void;
211
+ } | null>(null);
212
+ </script>
213
+
214
+ <template>
215
+ <div class="pg">
216
+ <div class="pg__controls">
217
+ <label class="pg__field">
218
+ <span class="pg__label">Selection</span>
219
+ <CoarSegmentedControl v-model="selectionMode" :options="modeOptions" size="s" />
220
+ </label>
221
+ <label class="pg__field">
222
+ <span class="pg__label">Density</span>
223
+ <CoarSegmentedControl v-model="density" :options="densityOptions" size="s" />
224
+ </label>
225
+ <div class="pg__switches">
226
+ <CoarSwitch v-model="draggable" label="draggable" size="s" />
227
+ <CoarSwitch v-model="renamable" label="renamable" size="s" />
228
+ <CoarSwitch v-model="acceptsFiles" label="acceptsFiles" size="s" />
229
+ <CoarSwitch v-model="activateOnClick" label="activateOnClick" size="s" />
230
+ <CoarSwitch v-model="virtualize" label="virtualize" size="s" />
231
+ <CoarSwitch v-model="checkStrictly" label="checkStrictly" size="s" />
232
+ <CoarSwitch v-model="disableSome" label="disable some" size="s" />
233
+ </div>
234
+ <label class="pg__field pg__field--grow">
235
+ <span class="pg__label">Filter</span>
236
+ <div class="pg__filter-row">
237
+ <CoarTextInput v-model="search" placeholder="type to highlight + reveal…" size="s" />
238
+ <CoarSwitch v-model="filter" label="hide non-matches" size="s" />
239
+ <CoarSegmentedControl v-if="filter" v-model="filterMode" :options="filterModeOptions" size="s" />
240
+ </div>
241
+ </label>
242
+ <div class="pg__buttons">
243
+ <CoarButton size="xs" variant="secondary" @click="treeRef?.expandAll()">Expand all</CoarButton>
244
+ <CoarButton size="xs" variant="secondary" @click="treeRef?.collapseAll()">Collapse all</CoarButton>
245
+ <CoarButton size="xs" variant="secondary" @click="treeRef?.revealNode('src/components/Modal.vue')">Reveal Modal.vue</CoarButton>
246
+ <CoarButton size="xs" variant="secondary" :disabled="!renamable" @click="treeRef?.startRename('src/App.vue')">Rename App.vue</CoarButton>
247
+ </div>
248
+ </div>
249
+
250
+ <div class="pg__tree-frame">
251
+ <CoarTree
252
+ ref="treeRef"
253
+ :nodes="treeData"
254
+ :get-id="(n: Node) => n.id"
255
+ :get-children="(n: Node) => n.children"
256
+ :get-label="(n: Node) => n.name"
257
+ :is-expandable="(n: Node) => !!n.children"
258
+ :is-disabled="disableSome ? isDisabledFn : undefined"
259
+ :selection-mode="selectionMode"
260
+ :check-strictly="checkStrictly"
261
+ :density="density"
262
+ :draggable="draggable"
263
+ :renamable="renamable"
264
+ :accepts-files="acceptsFiles"
265
+ :activate-on-click="activateOnClick"
266
+ :virtualize="virtualize ? { itemSize: vItemSize } : false"
267
+ :matched-ids="matchedIds"
268
+ :filter="filter"
269
+ :filter-mode="filterMode"
270
+ aria-label="Playground tree"
271
+ v-model:expanded="expanded"
272
+ v-model:selected="selected"
273
+ v-model:selected-ids="selectedIds"
274
+ v-model:checked-ids="checkedIds"
275
+ @select="onSelect"
276
+ @activate="onActivate"
277
+ @rename="onRename"
278
+ @node-move="onNodeMove"
279
+ @files-drop="onFilesDrop"
280
+ >
281
+ <template #default="{ node, isMatch }">
282
+ <CoarIcon
283
+ :name="node.children ? 'folder' : 'file-text'"
284
+ size="var(--coar-tree-icon-size)"
285
+ class="pg__icon"
286
+ />
287
+ <CoarTreeNodeLabel :label="node.name" :class="{ 'pg__hit': isMatch }" />
288
+ </template>
289
+ </CoarTree>
290
+ </div>
291
+
292
+ <div class="pg__log">
293
+ <span class="pg__label">Events</span>
294
+ <p v-for="(e, i) in events" :key="i" class="pg__log-line">{{ e }}</p>
295
+ <p v-if="!events.length" class="pg__log-empty">Click / drag / rename to see events…</p>
296
+ </div>
297
+ </div>
298
+ </template>
299
+
300
+ <style scoped>
301
+ .pg {
302
+ display: flex;
303
+ flex-direction: column;
304
+ gap: 12px;
305
+ }
306
+ .pg__controls {
307
+ display: flex;
308
+ flex-wrap: wrap;
309
+ align-items: flex-end;
310
+ gap: 16px;
311
+ }
312
+ .pg__field {
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 4px;
316
+ }
317
+ .pg__field--grow {
318
+ flex: 1;
319
+ min-width: 200px;
320
+ }
321
+ .pg__filter-row {
322
+ display: flex;
323
+ align-items: center;
324
+ gap: 12px;
325
+ }
326
+ .pg__filter-row > :first-child {
327
+ flex: 1;
328
+ }
329
+ .pg__label {
330
+ font-size: 11px;
331
+ font-weight: 600;
332
+ text-transform: uppercase;
333
+ letter-spacing: 0.04em;
334
+ color: var(--coar-text-neutral-tertiary);
335
+ }
336
+ .pg__switches {
337
+ display: flex;
338
+ flex-wrap: wrap;
339
+ gap: 8px 16px;
340
+ align-items: center;
341
+ }
342
+ .pg__buttons {
343
+ display: flex;
344
+ flex-wrap: wrap;
345
+ gap: 6px;
346
+ }
347
+ .pg__tree-frame {
348
+ border: 1px solid var(--coar-border-neutral-secondary);
349
+ border-radius: 8px;
350
+ height: 300px;
351
+ /* `display: flex` gives the tree a definite height so the *virtualized* mode's
352
+ scroll viewport works; `overflow: auto` scrolls the *non-virtualized* mode
353
+ (which doesn't own a scroll viewport) instead of letting expanded rows
354
+ spill out of the frame. */
355
+ display: flex;
356
+ overflow: auto;
357
+ }
358
+ .pg__icon {
359
+ color: var(--coar-text-neutral-tertiary);
360
+ flex-shrink: 0;
361
+ }
362
+ .pg__hit {
363
+ background: var(--coar-background-warning-tertiary, #fef9c3);
364
+ border-radius: 3px;
365
+ padding: 0 2px;
366
+ }
367
+ .pg__log {
368
+ border-top: 1px dashed var(--coar-border-neutral-secondary);
369
+ padding-top: 8px;
370
+ font-family: var(--coar-font-mono, monospace);
371
+ font-size: 12px;
372
+ min-height: 60px;
373
+ }
374
+ .pg__log-line {
375
+ margin: 2px 0;
376
+ color: var(--coar-text-neutral-secondary);
377
+ }
378
+ .pg__log-empty {
379
+ margin: 2px 0;
380
+ color: var(--coar-text-neutral-tertiary);
381
+ font-style: italic;
382
+ }
383
+ </style>
384
+ ```
385
+
386
+ ## Basic Tree
387
+
388
+ Pass a list of root nodes and four extractors: `getId`, `getChildren`, `getLabel`, and optionally `isExpandable`. Render the row body via the default slot. Two `v-model`s — `expanded` (a `Set<string>` of node ids) and `selected` (a single id or `null`) — let the consumer control state.
389
+
390
+ **Demo — `tree/demos/TreeBasic.vue`**
391
+
392
+ ```vue
393
+ <script setup lang="ts">
394
+ /**
395
+ * The simplest possible `<CoarTree>`: a static nested structure rendered with
396
+ * the default slot. Click a row to select it, click a chevron (or use Space)
397
+ * to expand or collapse a branch.
398
+ */
399
+ import { ref } from 'vue';
400
+ import { CoarTree, CoarIcon, vTooltip } from '@cocoar/vue-ui';
401
+
402
+ interface Node {
403
+ id: string;
404
+ label: string;
405
+ children?: Node[];
406
+ }
407
+
408
+ const tree: Node[] = [
409
+ {
410
+ id: 'docs',
411
+ label: 'Documentation',
412
+ children: [
413
+ { id: 'docs/getting-started', label: 'Getting Started' },
414
+ { id: 'docs/components', label: 'Components' },
415
+ { id: 'docs/recipes', label: 'Recipes' },
416
+ ],
417
+ },
418
+ {
419
+ id: 'design',
420
+ label: 'Design Tokens',
421
+ children: [
422
+ { id: 'design/colors', label: 'Colors' },
423
+ { id: 'design/spacing', label: 'Spacing' },
424
+ ],
425
+ },
426
+ { id: 'changelog', label: 'CHANGELOG.md' },
427
+ ];
428
+
429
+ const expanded = ref(new Set<string>(['docs']));
430
+ const selected = ref<string | null>('docs/components');
431
+ </script>
432
+
433
+ <template>
434
+ <div class="tree-frame">
435
+ <CoarTree
436
+ :nodes="tree"
437
+ :get-id="(n: Node) => n.id"
438
+ :get-children="(n: Node) => n.children"
439
+ :get-label="(n: Node) => n.label"
440
+ v-model:expanded="expanded"
441
+ v-model:selected="selected"
442
+ >
443
+ <template #default="{ node }">
444
+ <span
445
+ v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
446
+ class="tree-row__main"
447
+ >
448
+ <CoarIcon
449
+ :name="node.children ? 'folder' : 'file-text'"
450
+ size="xs"
451
+ class="tree-row__icon"
452
+ />
453
+ <span class="tree-row__label">{{ node.label }}</span>
454
+ </span>
455
+ </template>
456
+ </CoarTree>
457
+ </div>
458
+ <p class="tree-state">Selected: <code>{{ selected ?? '—' }}</code></p>
459
+ </template>
460
+
461
+ <style scoped>
462
+ .tree-frame {
463
+ border: 1px solid var(--coar-border-neutral-secondary);
464
+ border-radius: 8px;
465
+ padding: 4px 0;
466
+ max-width: 360px;
467
+ }
468
+ .tree-row__main {
469
+ display: flex;
470
+ align-items: center;
471
+ gap: 6px;
472
+ flex: 1;
473
+ min-width: 0;
474
+ }
475
+ .tree-row__icon {
476
+ color: var(--coar-text-neutral-tertiary);
477
+ flex-shrink: 0;
478
+ }
479
+ .tree-row__label {
480
+ flex: 1;
481
+ min-width: 0;
482
+ white-space: nowrap;
483
+ overflow: hidden;
484
+ text-overflow: ellipsis;
485
+ }
486
+ .tree-state {
487
+ margin-top: 12px;
488
+ font-size: 13px;
489
+ color: var(--coar-text-neutral-secondary);
490
+ }
491
+ </style>
492
+ ```
493
+
494
+ ## Selection
495
+
496
+ `selectionMode` picks one of three behaviours (default `'single'`, fully back-compatible):
497
+
498
+ | Mode | Bound to | Interaction |
499
+ |------|----------|-------------|
500
+ | `'single'` | `v-model:selected` (`string \| null`) | One highlighted row |
501
+ | `'multiple'` | `v-model:selectedIds` (`Set<string>`) | Ctrl/Cmd-click toggles, Shift-click ranges, `Ctrl/Cmd+A` selects all, Shift+Arrow extends |
502
+ | `'checkbox'` | `v-model:checkedIds` (`Set<string>`) + `v-model:selectedIds` | A per-row tri-state checkbox **independent** of the highlight selection |
503
+
504
+ In **checkbox** mode the checkbox state (`checkedIds`) and the highlight (`selectedIds`) are separate models — a row can be focused/highlighted while a different set is checked. Checking a folder cascades to its descendants and a partially-checked folder shows the indeterminate (`mixed`) state; checking a folder whose children aren't loaded yet propagates the check to them once they arrive (lazy inheritance). Set `check-strictly` for independent parent/child checks with no cascade.
505
+
506
+ ```vue
507
+ <script setup lang="ts">
508
+ const selectedIds = ref(new Set<string>())
509
+ const checkedIds = ref(new Set<string>())
510
+ </script>
511
+
512
+ <template>
513
+ <CoarTree
514
+ selection-mode="checkbox"
515
+ v-model:selected-ids="selectedIds"
516
+ v-model:checked-ids="checkedIds"
517
+ :nodes="nodes" :get-id="n => n.id" :get-children="n => n.children"
518
+ />
519
+ </template>
520
+ ```
521
+
522
+ **Demo — `tree/demos/TreeSelection.vue`**
523
+
524
+ ```vue
525
+ <script setup lang="ts">
526
+ /**
527
+ * The three selection modes. Toggle between single / multiple / checkbox and
528
+ * watch the bound models update. In checkbox mode the checkbox set
529
+ * (`checkedIds`) is independent of the highlight (`selectedIds`), and folder
530
+ * checks cascade with a tri-state (indeterminate) parent.
531
+ */
532
+ import { ref } from 'vue';
533
+ import { CoarTree, CoarIcon, CoarSegmentedControl, type CoarTreeSelectionMode } from '@cocoar/vue-ui';
534
+
535
+ interface Node {
536
+ id: string;
537
+ label: string;
538
+ children?: Node[];
539
+ }
540
+
541
+ const tree: Node[] = [
542
+ {
543
+ id: 'src',
544
+ label: 'src',
545
+ children: [
546
+ { id: 'src/main.ts', label: 'main.ts' },
547
+ { id: 'src/app.vue', label: 'App.vue' },
548
+ {
549
+ id: 'src/components',
550
+ label: 'components',
551
+ children: [
552
+ { id: 'src/components/button.vue', label: 'Button.vue' },
553
+ { id: 'src/components/tree.vue', label: 'Tree.vue' },
554
+ ],
555
+ },
556
+ ],
557
+ },
558
+ { id: 'readme', label: 'README.md' },
559
+ ];
560
+
561
+ const mode = ref<CoarTreeSelectionMode>('checkbox');
562
+ const modeOptions = [
563
+ { value: 'single', label: 'single' },
564
+ { value: 'multiple', label: 'multiple' },
565
+ { value: 'checkbox', label: 'checkbox' },
566
+ ];
567
+
568
+ const expanded = ref(new Set<string>(['src', 'src/components']));
569
+ const selected = ref<string | null>(null);
570
+ const selectedIds = ref(new Set<string>());
571
+ const checkedIds = ref(new Set<string>());
572
+ </script>
573
+
574
+ <template>
575
+ <div class="sel-demo">
576
+ <CoarSegmentedControl v-model="mode" :options="modeOptions" size="s" />
577
+
578
+ <div class="tree-frame">
579
+ <CoarTree
580
+ :nodes="tree"
581
+ :get-id="(n: Node) => n.id"
582
+ :get-children="(n: Node) => n.children"
583
+ :get-label="(n: Node) => n.label"
584
+ :selection-mode="mode"
585
+ aria-label="Project files"
586
+ v-model:expanded="expanded"
587
+ v-model:selected="selected"
588
+ v-model:selected-ids="selectedIds"
589
+ v-model:checked-ids="checkedIds"
590
+ >
591
+ <template #default="{ node }">
592
+ <CoarIcon :name="node.children ? 'folder' : 'file-text'" size="xs" class="sel-row__icon" />
593
+ <span class="sel-row__label">{{ node.label }}</span>
594
+ </template>
595
+ </CoarTree>
596
+ </div>
597
+
598
+ <p class="sel-state">
599
+ <template v-if="mode === 'single'">Selected: <code>{{ selected ?? '—' }}</code></template>
600
+ <template v-else-if="mode === 'multiple'">Selected: <code>{{ [...selectedIds].join(', ') || '—' }}</code></template>
601
+ <template v-else>Checked: <code>{{ [...checkedIds].join(', ') || '—' }}</code></template>
602
+ </p>
603
+ </div>
604
+ </template>
605
+
606
+ <style scoped>
607
+ .sel-demo {
608
+ max-width: 380px;
609
+ }
610
+ .tree-frame {
611
+ border: 1px solid var(--coar-border-neutral-secondary);
612
+ border-radius: 8px;
613
+ padding: 4px 0;
614
+ margin-top: 10px;
615
+ }
616
+ .sel-row__icon {
617
+ color: var(--coar-text-neutral-tertiary);
618
+ flex-shrink: 0;
619
+ }
620
+ .sel-row__label {
621
+ flex: 1;
622
+ min-width: 0;
623
+ white-space: nowrap;
624
+ overflow: hidden;
625
+ text-overflow: ellipsis;
626
+ }
627
+ .sel-state {
628
+ margin-top: 12px;
629
+ font-size: 13px;
630
+ color: var(--coar-text-neutral-secondary);
631
+ }
632
+ </style>
633
+ ```
634
+
635
+ ## Drag-and-Drop Reorder
636
+
637
+ Set `draggable` to allow internal moves. Drop **between** sibling rows to reorder; drop **into** a folder (middle 50 % of the row) to move it inside. The tree rejects self-onto-descendant drops, draws a 2-pixel indicator line for `before`/`after` and a dashed outline for `inside`, and auto-expands collapsed folders after a short hover.
638
+
639
+ The consumer handles the actual mutation in `@node-move` — splice the source out of its current parent and re-insert it at the target's location.
640
+
641
+ **Demo — `tree/demos/TreeDragReorder.vue`**
642
+
643
+ ```vue
644
+ <script setup lang="ts">
645
+ /**
646
+ * Internal drag-and-drop: pick a node, drag onto another. Drop **between**
647
+ * siblings (top / bottom edge of the row) to reorder; drop **into** a folder
648
+ * (middle 50 % of the row) to move it inside. Self-onto-descendant is rejected
649
+ * by the tree automatically. Hovering a collapsed folder for ~700 ms while
650
+ * dragging auto-expands it so deeper drops are reachable.
651
+ */
652
+ import { ref } from 'vue';
653
+ import {
654
+ CoarTree,
655
+ CoarIcon,
656
+ vTooltip,
657
+ type CoarTreeNodeMoveEvent,
658
+ } from '@cocoar/vue-ui';
659
+
660
+ interface Node {
661
+ id: string;
662
+ label: string;
663
+ children?: Node[];
664
+ }
665
+
666
+ const tree = ref<Node[]>([
667
+ {
668
+ id: 'inbox',
669
+ label: 'Inbox',
670
+ children: [
671
+ { id: 'inbox/a', label: 'Welcome aboard' },
672
+ { id: 'inbox/b', label: 'Quarterly report' },
673
+ { id: 'inbox/c', label: 'PTO request' },
674
+ ],
675
+ },
676
+ {
677
+ id: 'archive',
678
+ label: 'Archive',
679
+ children: [{ id: 'archive/x', label: 'Old contracts' }],
680
+ },
681
+ { id: 'spam', label: 'Spam' },
682
+ ]);
683
+
684
+ const expanded = ref(new Set<string>(['inbox', 'archive']));
685
+ const selected = ref<string | null>(null);
686
+
687
+ function findLoc(id: string, nodes: Node[] = tree.value, parent: Node | null = null): { parent: Node | null; idx: number } | null {
688
+ const idx = nodes.findIndex((n) => n.id === id);
689
+ if (idx >= 0) return { parent, idx };
690
+ for (const n of nodes) {
691
+ if (n.children) {
692
+ const found = findLoc(id, n.children, n);
693
+ if (found) return found;
694
+ }
695
+ }
696
+ return null;
697
+ }
698
+
699
+ function moveNode({ source, target, position }: CoarTreeNodeMoveEvent<Node>) {
700
+ const loc = findLoc(source.id);
701
+ if (!loc) return;
702
+ const arr = loc.parent ? loc.parent.children! : tree.value;
703
+ const [node] = arr.splice(loc.idx, 1);
704
+ if (!target) {
705
+ tree.value.push(node);
706
+ return;
707
+ }
708
+ if (position === 'inside') {
709
+ if (!target.children) target.children = [];
710
+ target.children.push(node);
711
+ expanded.value = new Set(expanded.value).add(target.id);
712
+ return;
713
+ }
714
+ const targetLoc = findLoc(target.id);
715
+ if (!targetLoc) return;
716
+ const dst = targetLoc.parent ? targetLoc.parent.children! : tree.value;
717
+ const at = position === 'before' ? targetLoc.idx : targetLoc.idx + 1;
718
+ dst.splice(at, 0, node);
719
+ }
720
+ </script>
721
+
722
+ <template>
723
+ <div class="tree-frame">
724
+ <CoarTree
725
+ :nodes="tree"
726
+ :get-id="(n: Node) => n.id"
727
+ :get-children="(n: Node) => n.children"
728
+ :get-label="(n: Node) => n.label"
729
+ :is-expandable="(n: Node) => Array.isArray(n.children)"
730
+ v-model:expanded="expanded"
731
+ v-model:selected="selected"
732
+ draggable
733
+ @node-move="moveNode"
734
+ >
735
+ <template #default="{ node }">
736
+ <span
737
+ v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
738
+ class="tree-row__main"
739
+ >
740
+ <CoarIcon
741
+ :name="node.children ? 'folder' : 'file-text'"
742
+ size="xs"
743
+ class="tree-row__icon"
744
+ />
745
+ <span class="tree-row__label">{{ node.label }}</span>
746
+ </span>
747
+ </template>
748
+ </CoarTree>
749
+ </div>
750
+ </template>
751
+
752
+ <style scoped>
753
+ .tree-frame {
754
+ border: 1px solid var(--coar-border-neutral-secondary);
755
+ border-radius: 8px;
756
+ padding: 4px 0;
757
+ max-width: 360px;
758
+ }
759
+ .tree-row__main {
760
+ display: flex;
761
+ align-items: center;
762
+ gap: 6px;
763
+ flex: 1;
764
+ min-width: 0;
765
+ }
766
+ .tree-row__icon {
767
+ color: var(--coar-text-neutral-tertiary);
768
+ flex-shrink: 0;
769
+ }
770
+ .tree-row__label {
771
+ flex: 1;
772
+ min-width: 0;
773
+ white-space: nowrap;
774
+ overflow: hidden;
775
+ text-overflow: ellipsis;
776
+ }
777
+ </style>
778
+ ```
779
+
780
+ ## OS File Drop
781
+
782
+ Set `accepts-files` to receive operating-system file drops onto folder rows or the empty background. The tree emits `@files-drop` with the raw `FileList` plus the target folder (or `null` when dropped on the background). Use this for asset uploaders, image inboxes, or any flow where the user drags files in from outside the browser.
783
+
784
+ **Demo — `tree/demos/TreeFileDrop.vue`**
785
+
786
+ ```vue
787
+ <script setup lang="ts">
788
+ /**
789
+ * Drop **OS files** from your file manager onto a folder row (or the empty
790
+ * tree background). The tree highlights the drop target and emits a
791
+ * `@files-drop` event with the `FileList` plus the target folder (or `null`
792
+ * for the background). The consumer decides what to do — here we just append
793
+ * a placeholder leaf per dropped file.
794
+ */
795
+ import { ref } from 'vue';
796
+ import {
797
+ CoarTree,
798
+ CoarIcon,
799
+ vTooltip,
800
+ type CoarTreeFilesDropEvent,
801
+ } from '@cocoar/vue-ui';
802
+
803
+ interface Node {
804
+ id: string;
805
+ label: string;
806
+ children?: Node[];
807
+ }
808
+
809
+ const tree = ref<Node[]>([
810
+ {
811
+ id: 'uploads',
812
+ label: 'Uploads',
813
+ children: [{ id: 'uploads/seed.png', label: 'seed.png' }],
814
+ },
815
+ { id: 'untracked', label: 'Untracked', children: [] },
816
+ ]);
817
+
818
+ const expanded = ref(new Set<string>(['uploads', 'untracked']));
819
+ const selected = ref<string | null>(null);
820
+
821
+ function findFolder(id: string, nodes: Node[] = tree.value): Node | null {
822
+ for (const n of nodes) {
823
+ if (!n.children) continue;
824
+ if (n.id === id) return n;
825
+ const inner = findFolder(id, n.children);
826
+ if (inner) return inner;
827
+ }
828
+ return null;
829
+ }
830
+
831
+ function onFilesDrop({ files, target }: CoarTreeFilesDropEvent<Node>) {
832
+ // Convert FileList to placeholder leaves — a real app would inspect each
833
+ // file, build the right node, and likely kick off an upload.
834
+ const dropped: Node[] = Array.from(files).map((f) => ({
835
+ id: `dropped-${crypto.randomUUID()}`,
836
+ label: f.name,
837
+ }));
838
+ if (target) {
839
+ const folder = findFolder(target.id);
840
+ if (folder) {
841
+ folder.children = [...(folder.children ?? []), ...dropped];
842
+ expanded.value = new Set(expanded.value).add(folder.id);
843
+ }
844
+ } else {
845
+ tree.value.push(...dropped);
846
+ }
847
+ }
848
+ </script>
849
+
850
+ <template>
851
+ <div class="tree-frame">
852
+ <CoarTree
853
+ :nodes="tree"
854
+ :get-id="(n: Node) => n.id"
855
+ :get-children="(n: Node) => n.children"
856
+ :get-label="(n: Node) => n.label"
857
+ :is-expandable="(n: Node) => Array.isArray(n.children)"
858
+ v-model:expanded="expanded"
859
+ v-model:selected="selected"
860
+ accepts-files
861
+ @files-drop="onFilesDrop"
862
+ >
863
+ <template #default="{ node }">
864
+ <span
865
+ v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
866
+ class="tree-row__main"
867
+ >
868
+ <CoarIcon
869
+ :name="node.children ? 'folder' : 'file'"
870
+ size="xs"
871
+ class="tree-row__icon"
872
+ />
873
+ <span class="tree-row__label">{{ node.label }}</span>
874
+ </span>
875
+ </template>
876
+ </CoarTree>
877
+ </div>
878
+ <p class="hint">Drag a file from your OS file manager onto a folder above.</p>
879
+ </template>
880
+
881
+ <style scoped>
882
+ .tree-frame {
883
+ border: 1px solid var(--coar-border-neutral-secondary);
884
+ border-radius: 8px;
885
+ padding: 4px 0;
886
+ max-width: 360px;
887
+ }
888
+ .tree-row__main {
889
+ display: flex;
890
+ align-items: center;
891
+ gap: 6px;
892
+ flex: 1;
893
+ min-width: 0;
894
+ }
895
+ .tree-row__icon {
896
+ color: var(--coar-text-neutral-tertiary);
897
+ flex-shrink: 0;
898
+ }
899
+ .tree-row__label {
900
+ flex: 1;
901
+ min-width: 0;
902
+ white-space: nowrap;
903
+ overflow: hidden;
904
+ text-overflow: ellipsis;
905
+ }
906
+ .hint {
907
+ margin-top: 12px;
908
+ font-size: 12px;
909
+ color: var(--coar-text-neutral-tertiary);
910
+ }
911
+ </style>
912
+ ```
913
+
914
+ ## App-internal drops (drag something onto a node)
915
+
916
+ `accepts-files` is for **OS** files. For an **in-app** drag — a card dragged out of your grid, a chip from a palette — set `accepts-data` to the MIME type(s) your source registered, and handle `@data-drop`. The tree reuses the same drop highlight, auto-expand-on-hover, and `before`/`inside`/`after` position as node reordering, but emits a generic event so your handler decides what the drop means (move the asset into the folder, link it, etc.).
917
+
918
+ ```vue
919
+ <!-- your grid card -->
920
+ <div draggable="true" @dragstart="e => e.dataTransfer.setData('application/x-myapp-asset', asset.id)">…</div>
921
+
922
+ <!-- the tree -->
923
+ <CoarTree
924
+ :builder="builder"
925
+ :accepts-data="['application/x-myapp-asset']"
926
+ @data-drop="onDataDrop"
927
+ />
928
+ ```
929
+
930
+ ```ts
931
+ function onDataDrop({ node, position, dataTransfer }) {
932
+ const assetId = dataTransfer.getData('application/x-myapp-asset') // read at drop time
933
+ const targetFolderId = node?.id ?? null // null = dropped on the background
934
+ moveAssetToFolder(assetId, targetFolderId)
935
+ }
936
+ ```
937
+
938
+ Internal node drags (`@node-move`) are never delivered as `@data-drop` even when `accepts-data` is set — the two channels don't cross. The `DataTransfer` is only valid inside the handler (the browser neuters it afterwards), so read your payload synchronously.
939
+
940
+ ## Context Menu + ⋮ Button
941
+
942
+ `@context-menu` fires on right-click of any row and on background right-click (`node` is `null`). Wire it to a single `useContextMenu()` controller — the menu's contents adapt to whether a folder, file, or background was hit. The same controller doubles as the click handler for a hover-revealed `⋮` button per row, giving keyboard / left-click users equal access.
943
+
944
+ **Demo — `tree/demos/TreeContextMenu.vue`**
945
+
946
+ ```vue
947
+ <script setup lang="ts">
948
+ /**
949
+ * Wire `@context-menu` to a `useContextMenu()` controller and a single
950
+ * `<CoarContextMenu>` instance — the menu's contents adapt to the right-
951
+ * clicked node (folder vs file vs empty background).
952
+ *
953
+ * The same controller also opens from a hover-revealed `⋮` button per row,
954
+ * giving keyboard / non-right-click users the same actions.
955
+ */
956
+ import { ref } from 'vue';
957
+ import {
958
+ CoarTree,
959
+ CoarIcon,
960
+ CoarContextMenu,
961
+ CoarMenu,
962
+ CoarMenuItem,
963
+ CoarMenuDivider,
964
+ useContextMenu,
965
+ vTooltip,
966
+ } from '@cocoar/vue-ui';
967
+
968
+ interface Node {
969
+ id: string;
970
+ label: string;
971
+ children?: Node[];
972
+ }
973
+
974
+ const tree = ref<Node[]>([
975
+ {
976
+ id: 'projects',
977
+ label: 'Projects',
978
+ children: [
979
+ { id: 'projects/alpha', label: 'alpha' },
980
+ { id: 'projects/beta', label: 'beta' },
981
+ ],
982
+ },
983
+ { id: 'README.md', label: 'README.md' },
984
+ ]);
985
+
986
+ const expanded = ref(new Set<string>(['projects']));
987
+ const selected = ref<string | null>(null);
988
+
989
+ const menu = useContextMenu();
990
+ const target = ref<Node | null>(null);
991
+
992
+ function open(node: Node | null, ev: MouseEvent) {
993
+ target.value = node;
994
+ menu.open(ev);
995
+ }
996
+
997
+ function rename() {
998
+ if (!target.value) return;
999
+ const next = window.prompt('Rename to:', target.value.label);
1000
+ if (next) target.value.label = next.trim() || target.value.label;
1001
+ }
1002
+ function remove() {
1003
+ if (!target.value) return;
1004
+ const id = target.value.id;
1005
+ const drop = (list: Node[]): boolean => {
1006
+ const i = list.findIndex((n) => n.id === id);
1007
+ if (i >= 0) { list.splice(i, 1); return true; }
1008
+ return list.some((n) => n.children && drop(n.children));
1009
+ };
1010
+ drop(tree.value);
1011
+ }
1012
+ </script>
1013
+
1014
+ <template>
1015
+ <div class="tree-frame">
1016
+ <CoarTree
1017
+ :nodes="tree"
1018
+ :get-id="(n: Node) => n.id"
1019
+ :get-children="(n: Node) => n.children"
1020
+ :get-label="(n: Node) => n.label"
1021
+ :is-expandable="(n: Node) => Array.isArray(n.children)"
1022
+ v-model:expanded="expanded"
1023
+ v-model:selected="selected"
1024
+ @context-menu="open"
1025
+ >
1026
+ <template #default="{ node }">
1027
+ <span
1028
+ v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
1029
+ class="tree-row__main"
1030
+ >
1031
+ <CoarIcon
1032
+ :name="node.children ? 'folder' : 'file-text'"
1033
+ size="xs"
1034
+ class="tree-row__icon"
1035
+ />
1036
+ <span class="tree-row__label">{{ node.label }}</span>
1037
+ </span>
1038
+ <button
1039
+ type="button"
1040
+ class="tree-row__more"
1041
+ aria-label="More actions"
1042
+ @click.stop="open(node, $event)"
1043
+ >
1044
+ <CoarIcon name="ellipsis-vertical" size="xs" />
1045
+ </button>
1046
+ </template>
1047
+ </CoarTree>
1048
+ </div>
1049
+
1050
+ <CoarContextMenu :menu="menu">
1051
+ <CoarMenu>
1052
+ <template v-if="target">
1053
+ <CoarMenuItem label="Rename…" icon="pencil" @clicked="rename" />
1054
+ <CoarMenuDivider />
1055
+ <CoarMenuItem label="Delete" icon="trash-2" @clicked="remove" />
1056
+ </template>
1057
+ <template v-else>
1058
+ <CoarMenuItem label="(Right-click a row to see context-aware actions)" disabled />
1059
+ </template>
1060
+ </CoarMenu>
1061
+ </CoarContextMenu>
1062
+
1063
+ <p class="hint">Right-click a row, or hover and use the <code>⋮</code> button.</p>
1064
+ </template>
1065
+
1066
+ <style scoped>
1067
+ .tree-frame {
1068
+ border: 1px solid var(--coar-border-neutral-secondary);
1069
+ border-radius: 8px;
1070
+ padding: 4px 0;
1071
+ max-width: 360px;
1072
+ }
1073
+ .tree-row__main {
1074
+ display: flex;
1075
+ align-items: center;
1076
+ gap: 6px;
1077
+ flex: 1;
1078
+ min-width: 0;
1079
+ }
1080
+ .tree-row__icon {
1081
+ color: var(--coar-text-neutral-tertiary);
1082
+ flex-shrink: 0;
1083
+ }
1084
+ .tree-row__label {
1085
+ flex: 1;
1086
+ min-width: 0;
1087
+ white-space: nowrap;
1088
+ overflow: hidden;
1089
+ text-overflow: ellipsis;
1090
+ }
1091
+ .tree-row__more {
1092
+ width: 20px;
1093
+ height: 20px;
1094
+ border: none;
1095
+ background: transparent;
1096
+ border-radius: 3px;
1097
+ color: var(--coar-text-neutral-tertiary);
1098
+ cursor: pointer;
1099
+ opacity: 0;
1100
+ transition: opacity 120ms ease;
1101
+ }
1102
+ :deep(.coar-tree-node__row:hover) .tree-row__more,
1103
+ :deep(.coar-tree-node__row:focus-within) .tree-row__more {
1104
+ opacity: 1;
1105
+ }
1106
+ .tree-row__more:hover {
1107
+ background: var(--coar-background-neutral-primary);
1108
+ color: var(--coar-text-neutral-primary);
1109
+ }
1110
+ .hint {
1111
+ margin-top: 12px;
1112
+ font-size: 12px;
1113
+ color: var(--coar-text-neutral-tertiary);
1114
+ }
1115
+ </style>
1116
+ ```
1117
+
1118
+ ## Builder API
1119
+
1120
+ `useTree<T>()` returns `{ builder, api }`. Configure data, behavior, handlers and three context menus in a single fluent chain — the tree renders the `<CoarContextMenu>` itself, so the template is just `<CoarTree :builder>`.
1121
+
1122
+ **Demo — `tree/demos/TreeBuilderApi.vue`**
1123
+
1124
+ ```vue
1125
+ <script setup lang="ts">
1126
+ /**
1127
+ * Full builder-mode demo. Configures data + handlers + per-target context
1128
+ * menus in a single fluent chain. The tree renders the `<CoarContextMenu>`
1129
+ * internally — no extra markup in the template.
1130
+ *
1131
+ * Three menu types are wired:
1132
+ * - `folderMenu` — right-click a folder row
1133
+ * - `leafMenu` — right-click a file row
1134
+ * - `viewportMenu` — right-click the empty background (e.g. below the rows)
1135
+ */
1136
+ import { ref } from 'vue';
1137
+ import {
1138
+ CoarTree,
1139
+ CoarIcon,
1140
+ useTree,
1141
+ vTooltip,
1142
+ type CoarTreeNodeMoveEvent,
1143
+ } from '@cocoar/vue-ui';
1144
+
1145
+ interface Node {
1146
+ id: string;
1147
+ label: string;
1148
+ children?: Node[];
1149
+ }
1150
+
1151
+ const uid = () => crypto.randomUUID();
1152
+
1153
+ const tree = ref<Node[]>([
1154
+ {
1155
+ id: uid(),
1156
+ label: 'inbox',
1157
+ children: [
1158
+ { id: uid(), label: 'welcome.md' },
1159
+ { id: uid(), label: 'todo.md' },
1160
+ ],
1161
+ },
1162
+ {
1163
+ id: uid(),
1164
+ label: 'archive',
1165
+ children: [{ id: uid(), label: 'old-notes.md' }],
1166
+ },
1167
+ { id: uid(), label: 'CHANGELOG.md' },
1168
+ ]);
1169
+ const expanded = ref(new Set<string>(tree.value.filter((n) => n.children).map((n) => n.id)));
1170
+ const selected = ref<string | null>(null);
1171
+ const lastActivated = ref<string | null>(null);
1172
+
1173
+ // Find a node's parent + index so we can mutate the tree in place.
1174
+ function findLoc(id: string, nodes: Node[] = tree.value, parent: Node | null = null): { parent: Node | null; idx: number } | null {
1175
+ const idx = nodes.findIndex((n) => n.id === id);
1176
+ if (idx >= 0) return { parent, idx };
1177
+ for (const n of nodes) {
1178
+ if (n.children) {
1179
+ const found = findLoc(id, n.children, n);
1180
+ if (found) return found;
1181
+ }
1182
+ }
1183
+ return null;
1184
+ }
1185
+
1186
+ function addFolder(parentId: string | null) {
1187
+ const label = window.prompt('Folder name?')?.trim();
1188
+ if (!label) return;
1189
+ const node: Node = { id: uid(), label, children: [] };
1190
+ if (parentId) {
1191
+ const parent = findLoc(parentId)?.parent === null ? tree.value.find((n) => n.id === parentId) : null;
1192
+ const found = tree.value.find((n) => n.id === parentId);
1193
+ if (found?.children) found.children.push(node);
1194
+ expanded.value = new Set(expanded.value).add(parentId);
1195
+ } else {
1196
+ tree.value.push(node);
1197
+ }
1198
+ }
1199
+
1200
+ function remove(id: string) {
1201
+ const loc = findLoc(id);
1202
+ if (!loc) return;
1203
+ const arr = loc.parent ? loc.parent.children! : tree.value;
1204
+ arr.splice(loc.idx, 1);
1205
+ }
1206
+
1207
+ function move({ source, target, position }: CoarTreeNodeMoveEvent<Node>) {
1208
+ const loc = findLoc(source.id);
1209
+ if (!loc) return;
1210
+ const arr = loc.parent ? loc.parent.children! : tree.value;
1211
+ const [node] = arr.splice(loc.idx, 1);
1212
+ if (!target) { tree.value.push(node); return; }
1213
+ if (position === 'inside') {
1214
+ if (!target.children) target.children = [];
1215
+ target.children.push(node);
1216
+ expanded.value = new Set(expanded.value).add(target.id);
1217
+ return;
1218
+ }
1219
+ const targetLoc = findLoc(target.id);
1220
+ if (!targetLoc) return;
1221
+ const dst = targetLoc.parent ? targetLoc.parent.children! : tree.value;
1222
+ dst.splice(position === 'before' ? targetLoc.idx : targetLoc.idx + 1, 0, node);
1223
+ }
1224
+
1225
+ const { builder, api } = useTree<Node>();
1226
+
1227
+ builder
1228
+ .nodes(tree)
1229
+ .getId((n) => n.id)
1230
+ .getChildren((n) => n.children)
1231
+ .getLabel((n) => n.label)
1232
+ .isExpandable((n) => Array.isArray(n.children))
1233
+ .expanded(expanded)
1234
+ .selected(selected)
1235
+ .draggable(true)
1236
+ .onActivate((n) => { lastActivated.value = n.label; })
1237
+ .onNodeMove(move)
1238
+ .folderMenu((folder) => [
1239
+ { label: 'New subfolder…', icon: 'plus', onClick: () => addFolder(folder.id) },
1240
+ 'divider',
1241
+ { label: 'Delete folder', icon: 'trash-2', danger: true, onClick: () => remove(folder.id) },
1242
+ ])
1243
+ .leafMenu((leaf) => [
1244
+ { label: 'Open', icon: 'file', onClick: () => { lastActivated.value = leaf.label; } },
1245
+ 'divider',
1246
+ { label: 'Delete', icon: 'trash-2', danger: true, onClick: () => remove(leaf.id) },
1247
+ ])
1248
+ .viewportMenu(() => [
1249
+ { label: 'New folder at root…', icon: 'plus', onClick: () => addFolder(null) },
1250
+ ]);
1251
+ </script>
1252
+
1253
+ <template>
1254
+ <div class="tree-frame">
1255
+ <CoarTree :builder="builder">
1256
+ <template #default="{ node }">
1257
+ <span
1258
+ v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
1259
+ class="tree-row__main"
1260
+ >
1261
+ <CoarIcon
1262
+ :name="node.children ? 'folder' : 'file-text'"
1263
+ size="xs"
1264
+ class="tree-row__icon"
1265
+ />
1266
+ <span class="tree-row__label">{{ node.label }}</span>
1267
+ </span>
1268
+ </template>
1269
+ </CoarTree>
1270
+ </div>
1271
+ <p class="hint">
1272
+ Right-click a folder, a file, or the empty area below — each has its own menu.
1273
+ <code>useTree()</code> exposes <code>api.selectedId</code> (currently <code>{{ api.selectedId.value ?? '—' }}</code>)
1274
+ and <code>api.focusNode(id)</code> for imperative control.
1275
+ </p>
1276
+ <p v-if="lastActivated" class="hint">Last activated: <code>{{ lastActivated }}</code></p>
1277
+ </template>
1278
+
1279
+ <style scoped>
1280
+ .tree-frame {
1281
+ border: 1px solid var(--coar-border-neutral-secondary);
1282
+ border-radius: 8px;
1283
+ padding: 4px 0;
1284
+ max-width: 360px;
1285
+ min-height: 220px;
1286
+ }
1287
+ .tree-row__main {
1288
+ display: flex;
1289
+ align-items: center;
1290
+ gap: 6px;
1291
+ flex: 1;
1292
+ min-width: 0;
1293
+ }
1294
+ .tree-row__icon {
1295
+ color: var(--coar-text-neutral-tertiary);
1296
+ flex-shrink: 0;
1297
+ }
1298
+ .tree-row__label {
1299
+ flex: 1;
1300
+ min-width: 0;
1301
+ white-space: nowrap;
1302
+ overflow: hidden;
1303
+ text-overflow: ellipsis;
1304
+ }
1305
+ .hint {
1306
+ margin-top: 12px;
1307
+ font-size: 12px;
1308
+ color: var(--coar-text-neutral-tertiary);
1309
+ }
1310
+ </style>
1311
+ ```
1312
+
1313
+ ### Context menus per target
1314
+
1315
+ Three setters, one per target type. Each callback returns an array of menu entries (`{ label, icon?, danger?, disabled?, onClick }` or the literal string `'divider'`):
1316
+
1317
+ ```ts
1318
+ builder
1319
+ .folderMenu(folder => [
1320
+ { label: 'Upload here', icon: 'upload', onClick: () => upload(folder.id) },
1321
+ { label: 'New subfolder', icon: 'plus', onClick: () => newFolder(folder.id) },
1322
+ 'divider',
1323
+ { label: 'Delete', icon: 'trash-2', danger: true, onClick: () => del(folder) },
1324
+ ])
1325
+ .leafMenu(leaf => [
1326
+ { label: 'Open', icon: 'file', onClick: () => openFile(leaf) },
1327
+ { label: 'Delete', icon: 'trash-2', danger: true, onClick: () => del(leaf) },
1328
+ ])
1329
+ .viewportMenu(() => [
1330
+ { label: 'New folder', icon: 'plus', onClick: () => newFolder(null) },
1331
+ ]);
1332
+ ```
1333
+
1334
+ | Setter | Fires on | Receives |
1335
+ |--------|----------|----------|
1336
+ | `.folderMenu(folder => [])` | Right-click an expandable node | The folder node |
1337
+ | `.leafMenu(leaf => [])` | Right-click a non-expandable node | The leaf node |
1338
+ | `.viewportMenu(() => [])` | Right-click empty tree background | Nothing (`null` target) |
1339
+
1340
+ If a setter is omitted for a target, no menu opens on that target. Items without an `icon` align flush left; the tree allocates the icon column when *any* item in the menu uses an icon.
1341
+
1342
+ ### Escape hatch — raw events
1343
+
1344
+ When you want a fully custom popover (form inputs, async sub-menus, third-party menu component) instead of the standard menu, use the event variants. They **override** the declarative setters for that target:
1345
+
1346
+ ```ts
1347
+ builder.onLeafContextMenu((leaf, ev) => {
1348
+ ev.preventDefault();
1349
+ myCustomPopover.openAt(ev.clientX, ev.clientY, leaf);
1350
+ });
1351
+ ```
1352
+
1353
+ | Event setter | Overrides |
1354
+ |--------------|-----------|
1355
+ | `.onFolderContextMenu((folder, ev) => …)` | `.folderMenu` |
1356
+ | `.onLeafContextMenu((leaf, ev) => …)` | `.leafMenu` |
1357
+ | `.onViewportContextMenu((ev) => …)` | `.viewportMenu` |
1358
+
1359
+ ### The `api`
1360
+
1361
+ `useTree()`'s second return value is a narrow imperative interface — call from anywhere without needing a template ref:
1362
+
1363
+ ```ts
1364
+ const { builder, api } = useTree<MyNode>();
1365
+
1366
+ // readonly refs (suitable for watch / computed)
1367
+ api.selectedId // Ref<string | null>
1368
+ api.expandedIds // Ref<Set<string>>
1369
+
1370
+ // readonly refs (multiple / checkbox modes)
1371
+ api.selectedIds // Ref<Set<string>>
1372
+ api.checkedIds // Ref<Set<string>>
1373
+
1374
+ // imperative (warn until mounted)
1375
+ api.selectNode('some-id') // highlight-select + focus (preferred)
1376
+ api.focusNode('some-id') // alias of selectNode (back-compat since 2.4.0)
1377
+ api.expandTo('some-id') // reveal a deep node
1378
+ api.revealNode('some-id') // scroll into view without stealing focus
1379
+ api.moveNode('a', 'b', 'after') // accessible/programmatic move
1380
+ api.getNode('some-id') // resolve node by id, or null
1381
+ api.reloadChildren('some-id') // re-run loadChildren (retry / refresh)
1382
+ api.startRename('some-id') // enter inline-rename (needs `renamable`)
1383
+ api.startCreate('some-id') // open inline-create draft under a parent (needs `creatable`)
1384
+ ```
1385
+
1386
+ ## Lazy loading (async children)
1387
+
1388
+ Fetch a node's children the first time it's expanded — for trees backed by an API where loading everything up front isn't an option. Two pieces:
1389
+
1390
+ - **`isExpandable`** must return `true` for a folder *before* its children exist (otherwise there's no chevron to expand). Derive it from the node's own "is a folder" flag, not from `getChildren`.
1391
+ - **`loadChildren(node)`** fires on first expand of an unloaded folder. Return a `Promise` and the tree shows a spinner in the chevron until it settles; on rejection the row flips to an error state and `@load-error` fires. Your handler attaches the fetched children to your own `nodes` data — the tree re-renders and stops asking (a node counts as loaded once `getChildren` returns an array; `[]` is loaded-but-empty). Attach so `nodes` updates reactively — produce a **new root `nodes` reference** (`nodes.value = [...]`) or keep `nodes` deeply reactive; a pure in-place mutation on a shallow source leaves the spinner spinning. An unrelated `nodes` change never re-fires a node that already attempted-and-settled — retry is explicit (`api.reloadChildren(id)` or collapse + re-expand).
1392
+
1393
+ The demo below is interactive — dial in **latency** and a **failure rate** to watch the loading spinner, the error + **Retry** path, and the load-once-then-cache behaviour, then drill in to see each level load on its own. It renders the spinner at the row-icon position (the `hide-loading-spinner` + `isLoading` pattern below) rather than the built-in chevron spinner.
1394
+
1395
+ **Demo — `tree/demos/TreeLazyLoad.vue`**
1396
+
1397
+ ```vue
1398
+ <script setup lang="ts">
1399
+ /**
1400
+ * Interactive lazy-loading demo. Folders fetch their children on first expand
1401
+ * via `loadChildren`. The simulator dials in latency + a failure rate so you can
1402
+ * actually SEE the loading spinner, the error + Retry path, and the
1403
+ * load-once-then-cache behaviour (collapse + re-expand never re-fetches).
1404
+ *
1405
+ * The spinner here replaces the row ICON (not the chevron): `hide-loading-spinner`
1406
+ * turns off the tree's built-in chevron spinner, and we render our own from the
1407
+ * `isLoading` slot prop. `hasError` drives the inline Retry button via
1408
+ * `api.reloadChildren`.
1409
+ */
1410
+ import { ref } from 'vue';
1411
+ import { CoarTree, CoarIcon, CoarSegmentedControl, useTree } from '@cocoar/vue-ui';
1412
+
1413
+ interface Node {
1414
+ id: string;
1415
+ name: string;
1416
+ kind: 'folder' | 'file';
1417
+ children?: Node[];
1418
+ }
1419
+
1420
+ const roots = (): Node[] => [
1421
+ { id: 'src', name: 'src', kind: 'folder' },
1422
+ { id: 'docs', name: 'docs', kind: 'folder' },
1423
+ { id: 'assets', name: 'assets', kind: 'folder' },
1424
+ ];
1425
+
1426
+ const tree = ref<Node[]>(roots());
1427
+ const expanded = ref(new Set<string>());
1428
+
1429
+ // ─── simulator knobs ───
1430
+ const latency = ref(700);
1431
+ const failurePct = ref(0);
1432
+ const fetches = ref(0);
1433
+
1434
+ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
1435
+
1436
+ // Deterministic children for any folder — folders for the first two levels, then
1437
+ // files, so you can drill in and watch each level lazy-load on its own.
1438
+ function childrenFor(node: Node): Node[] {
1439
+ const depth = node.id.split('/').length;
1440
+ if (depth >= 3) {
1441
+ return [
1442
+ { id: `${node.id}/index.ts`, name: 'index.ts', kind: 'file' },
1443
+ { id: `${node.id}/styles.css`, name: 'styles.css', kind: 'file' },
1444
+ ];
1445
+ }
1446
+ return [
1447
+ { id: `${node.id}/components`, name: 'components', kind: 'folder' },
1448
+ { id: `${node.id}/lib`, name: 'lib', kind: 'folder' },
1449
+ { id: `${node.id}/index.ts`, name: 'index.ts', kind: 'file' },
1450
+ { id: `${node.id}/README.md`, name: 'README.md', kind: 'file' },
1451
+ ];
1452
+ }
1453
+
1454
+ // Attach fetched children to the node, deep in the tree (reactive via the ref).
1455
+ function attach(id: string, kids: Node[]) {
1456
+ const visit = (list: Node[]): boolean => {
1457
+ for (const n of list) {
1458
+ if (n.id === id) {
1459
+ n.children = kids;
1460
+ return true;
1461
+ }
1462
+ if (n.children && visit(n.children)) return true;
1463
+ }
1464
+ return false;
1465
+ };
1466
+ visit(tree.value);
1467
+ tree.value = [...tree.value];
1468
+ }
1469
+
1470
+ const { builder, api } = useTree<Node>();
1471
+ builder
1472
+ .nodes(tree)
1473
+ .getId((n) => n.id)
1474
+ .getChildren((n) => n.children)
1475
+ .getLabel((n) => n.name)
1476
+ .isExpandable((n) => n.kind === 'folder')
1477
+ .expanded(expanded)
1478
+ .hideLoadingSpinner(true) // we render our own icon-position spinner from `isLoading`
1479
+ .loadChildren(async (node) => {
1480
+ fetches.value += 1;
1481
+ await delay(latency.value);
1482
+ if (Math.random() * 100 < failurePct.value) throw new Error('Simulated network error');
1483
+ attach(node.id, childrenFor(node));
1484
+ });
1485
+
1486
+ function reset() {
1487
+ expanded.value = new Set();
1488
+ tree.value = roots();
1489
+ fetches.value = 0;
1490
+ }
1491
+ </script>
1492
+
1493
+ <template>
1494
+ <div class="lz">
1495
+ <div class="lz__bar">
1496
+ <label class="lz__knob">
1497
+ <span class="lz__knob-label">Latency</span>
1498
+ <CoarSegmentedControl
1499
+ v-model="latency"
1500
+ size="xs"
1501
+ :options="[
1502
+ { value: 0, label: 'Instant' },
1503
+ { value: 700, label: '700ms' },
1504
+ { value: 1800, label: 'Slow' },
1505
+ ]"
1506
+ />
1507
+ </label>
1508
+ <label class="lz__knob">
1509
+ <span class="lz__knob-label">Failure</span>
1510
+ <CoarSegmentedControl
1511
+ v-model="failurePct"
1512
+ size="xs"
1513
+ :options="[
1514
+ { value: 0, label: '0%' },
1515
+ { value: 30, label: '30%' },
1516
+ { value: 100, label: 'Always' },
1517
+ ]"
1518
+ />
1519
+ </label>
1520
+ <button type="button" class="lz__reset" @click="reset">Reset</button>
1521
+ <span class="lz__stat">{{ fetches }} fetch{{ fetches === 1 ? '' : 'es' }}</span>
1522
+ </div>
1523
+
1524
+ <div class="lz__frame">
1525
+ <CoarTree :builder="builder">
1526
+ <template #default="{ node, isLoading, hasError }">
1527
+ <span class="lz__row">
1528
+ <span v-if="isLoading" class="lz__spinner lz__icon" aria-hidden="true" />
1529
+ <CoarIcon
1530
+ v-else
1531
+ :name="node.kind === 'folder' ? 'folder' : 'file-text'"
1532
+ size="xs"
1533
+ class="lz__icon"
1534
+ :class="{ 'lz__icon--error': hasError }"
1535
+ />
1536
+ <span class="lz__label" :class="{ 'lz__label--error': hasError }">{{ node.name }}</span>
1537
+ <button
1538
+ v-if="hasError"
1539
+ type="button"
1540
+ class="lz__retry"
1541
+ @click.stop="api.reloadChildren(node.id)"
1542
+ >
1543
+ Retry
1544
+ </button>
1545
+ </span>
1546
+ </template>
1547
+ </CoarTree>
1548
+ </div>
1549
+
1550
+ <p class="hint">
1551
+ Expand a folder → it fetches its children. Try <strong>Slow</strong> to watch the spinner, bump
1552
+ <strong>Failure</strong> and hit <strong>Retry</strong> on the red row, and drill in to see each
1553
+ level load on its own. Loaded folders cache — collapse + re-expand never re-fetches;
1554
+ <strong>Reset</strong> clears everything.
1555
+ </p>
1556
+ </div>
1557
+ </template>
1558
+
1559
+ <style scoped>
1560
+ .lz {
1561
+ display: flex;
1562
+ flex-direction: column;
1563
+ gap: 10px;
1564
+ }
1565
+ .lz__bar {
1566
+ display: flex;
1567
+ align-items: center;
1568
+ gap: 16px;
1569
+ flex-wrap: wrap;
1570
+ }
1571
+ .lz__knob {
1572
+ display: inline-flex;
1573
+ align-items: center;
1574
+ gap: 6px;
1575
+ }
1576
+ .lz__knob-label {
1577
+ font-size: 11px;
1578
+ font-weight: 700;
1579
+ letter-spacing: 0.04em;
1580
+ text-transform: uppercase;
1581
+ color: var(--coar-text-neutral-tertiary);
1582
+ }
1583
+ .lz__reset {
1584
+ border: 1px solid var(--coar-border-neutral-secondary);
1585
+ background: transparent;
1586
+ color: var(--coar-text-neutral-secondary);
1587
+ border-radius: var(--coar-radius-xs, 3px);
1588
+ font: inherit;
1589
+ font-size: 12px;
1590
+ padding: 2px 10px;
1591
+ cursor: pointer;
1592
+ }
1593
+ .lz__reset:hover {
1594
+ background: var(--coar-background-neutral-tertiary);
1595
+ color: var(--coar-text-neutral-primary);
1596
+ }
1597
+ .lz__stat {
1598
+ margin-left: auto;
1599
+ font-size: 12px;
1600
+ color: var(--coar-text-neutral-tertiary);
1601
+ font-variant-numeric: tabular-nums;
1602
+ }
1603
+ .lz__frame {
1604
+ border: 1px solid var(--coar-border-neutral-secondary);
1605
+ border-radius: 8px;
1606
+ max-width: 400px;
1607
+ height: 300px;
1608
+ /* Non-virtualized trees don't own a scroll viewport — the consumer's
1609
+ container does. Without this, expanded rows overflow the frame. */
1610
+ overflow: auto;
1611
+ }
1612
+ /* The exact border-ring spinner from the file-explorer demo. No
1613
+ prefers-reduced-motion guard on purpose — a frozen loading indicator reads as
1614
+ "stuck", and this matches the file-explorer POC's spinner 1:1. */
1615
+ .lz__spinner {
1616
+ display: inline-block;
1617
+ width: 12px;
1618
+ height: 12px;
1619
+ border-radius: 50%;
1620
+ border: 1.5px solid var(--coar-border-neutral-tertiary);
1621
+ border-top-color: var(--coar-text-accent-primary);
1622
+ flex-shrink: 0;
1623
+ /* `!important` overrides VitePress's global prefers-reduced-motion reset
1624
+ (`*, ::before, ::after { animation-duration: 1ms !important }`), which would
1625
+ otherwise freeze this spinner into a static ring for reduced-motion users.
1626
+ A loading indicator that doesn't move reads as "stuck" — keep it spinning. */
1627
+ animation: lz-spin 700ms linear infinite !important;
1628
+ }
1629
+ @keyframes lz-spin {
1630
+ to {
1631
+ transform: rotate(360deg);
1632
+ }
1633
+ }
1634
+ .lz__row {
1635
+ display: flex;
1636
+ align-items: center;
1637
+ gap: 6px;
1638
+ flex: 1;
1639
+ min-width: 0;
1640
+ }
1641
+ .lz__icon {
1642
+ color: var(--coar-text-neutral-tertiary);
1643
+ flex-shrink: 0;
1644
+ }
1645
+ .lz__icon--error {
1646
+ color: var(--coar-text-semantic-error-bold, #dc2626);
1647
+ }
1648
+ .lz__label {
1649
+ flex: 1;
1650
+ min-width: 0;
1651
+ white-space: nowrap;
1652
+ overflow: hidden;
1653
+ text-overflow: ellipsis;
1654
+ }
1655
+ .lz__label--error {
1656
+ color: var(--coar-text-semantic-error-bold, #dc2626);
1657
+ }
1658
+ .lz__retry {
1659
+ flex-shrink: 0;
1660
+ border: 1px solid var(--coar-border-semantic-error, #dc2626);
1661
+ color: var(--coar-text-semantic-error-bold, #dc2626);
1662
+ background: transparent;
1663
+ border-radius: var(--coar-radius-xs, 2px);
1664
+ font-size: 11px;
1665
+ padding: 1px 6px;
1666
+ cursor: pointer;
1667
+ }
1668
+ .hint {
1669
+ margin: 0;
1670
+ font-size: 12px;
1671
+ color: var(--coar-text-neutral-tertiary);
1672
+ }
1673
+ </style>
1674
+ ```
1675
+
1676
+ ```ts
1677
+ const { builder, api } = useTree<FsNode>();
1678
+ builder
1679
+ .nodes(tree)
1680
+ .getId(n => n.id)
1681
+ .getChildren(n => n.children) // undefined until loaded; [] = loaded-but-empty
1682
+ .isExpandable(n => n.kind === 'folder') // expandable BEFORE children exist
1683
+ .loadChildren(async (node, { signal }) => {
1684
+ const kids = await myApi.fetchChildren(node.id, { signal }); // aborts on collapse
1685
+ attachChildren(node.id, kids); // mutate your own `nodes` data
1686
+ })
1687
+ .maxConcurrentLoads(6) // cap fan-out for rate-limited backends (0 = unlimited)
1688
+ .onLoadError(({ node, error }) => toast.error(`Couldn't open ${node.name}`));
1689
+ ```
1690
+
1691
+ `loadChildren`'s second argument carries an `AbortSignal` that fires when the folder is collapsed or leaves the tree mid-flight — forward it to `fetch` so a cancelled load doesn't waste work or race a later reopen. A load that settles after being aborted is suppressed (no phantom error). `maxConcurrentLoads` (default `0` = unlimited) bounds simultaneous loads so an `expandAll()` / state-restore can't fan out unbounded.
1692
+
1693
+ The `default` slot exposes `isLoading` and `hasError` per row, and `api.reloadChildren(id)` forces a re-fetch — wire it to a retry button (or a "refresh folder" action):
1694
+
1695
+ ```vue
1696
+ <template #default="{ node, hasError }">
1697
+ <FileRow :node="node" />
1698
+ <button v-if="hasError" @click.stop="api.reloadChildren(node.id)">Retry</button>
1699
+ </template>
1700
+ ```
1701
+
1702
+ > **Tip: Default loading + error visuals**
1703
+ >
1704
+ > By default the chevron shows a spinner while loading and a red **retry** icon on error (click it to re-fetch) — no extra markup needed. Set `hide-loading-spinner` to suppress **both** and render your own from `isLoading` / `hasError`. Collapsing then re-expanding an errored folder also retries.
1705
+
1706
+ > **Tip: Render your own loading indicator**
1707
+ >
1708
+ > Prefer a spinner that replaces the row icon (or anything else)? Set `hide-loading-spinner` to suppress the built-in chevron spinner and render your own from the `isLoading` slot prop — the tree still owns the *when* (expand-trigger, dedupe, error state), you own the *look*:
1709
+ >
1710
+ ```vue
1711
+ <CoarTree :builder="builder" hide-loading-spinner>
1712
+ <template #default="{ node, isLoading }">
1713
+ <Spinner v-if="isLoading" />
1714
+ <Icon v-else :name="node.icon" />
1715
+ <span>{{ node.label }}</span>
1716
+ </template>
1717
+ </CoarTree>
1718
+ ```
1719
+
1720
+ ## Virtualization
1721
+
1722
+ For trees with hundreds or thousands of visible rows, enable virtualization to mount only the rows actually inside the viewport. The component is built on `useVirtualList` — fixed-known-size virtualizer, no DOM auto-measure — so you declare the row height once.
1723
+
1724
+ **Demo — `tree/demos/TreeVirtualization.vue`**
1725
+
1726
+ ```vue
1727
+ <script setup lang="ts">
1728
+ /**
1729
+ * Virtualized tree with 5 000 nodes across two levels. Without virtualization
1730
+ * this would mount 5 000 row components — with `.virtualize({ itemSize: 28 })`
1731
+ * only the rows inside the viewport (~15 at this size) + a 5-row overscan
1732
+ * are mounted, regardless of how far the user scrolls.
1733
+ *
1734
+ * Try expanding one of the "Category" folders — each holds 250 entries.
1735
+ * Scrolling stays smooth even at thousands of nodes.
1736
+ */
1737
+ import { ref } from 'vue';
1738
+ import { CoarTree, CoarIcon, useTree, vTooltip } from '@cocoar/vue-ui';
1739
+
1740
+ interface Node {
1741
+ id: string;
1742
+ label: string;
1743
+ children?: Node[];
1744
+ }
1745
+
1746
+ // 20 categories × 250 items = 5 000 leaves, plus the 20 folders themselves.
1747
+ const tree: Node[] = Array.from({ length: 20 }, (_, c) => ({
1748
+ id: `cat-${c}`,
1749
+ label: `Category ${c + 1}`,
1750
+ children: Array.from({ length: 250 }, (_, i) => ({
1751
+ id: `cat-${c}-item-${i}`,
1752
+ label: `item-${c + 1}-${String(i + 1).padStart(3, '0')}.md`,
1753
+ })),
1754
+ }));
1755
+
1756
+ const expanded = ref(new Set<string>(['cat-0'])); // first category open by default
1757
+ const selected = ref<string | null>(null);
1758
+
1759
+ const { builder } = useTree<Node>();
1760
+ builder
1761
+ .nodes(tree)
1762
+ .getId((n) => n.id)
1763
+ .getChildren((n) => n.children)
1764
+ .getLabel((n) => n.label)
1765
+ .expanded(expanded)
1766
+ .selected(selected)
1767
+ // Default itemSize is 28px which matches the standard row layout. Bump it
1768
+ // when the slot adds extra padding or multi-line content. Overscan of 8
1769
+ // gives a slightly smoother scroll on slow GPUs.
1770
+ .virtualize({ itemSize: 28, overscan: 8 });
1771
+ </script>
1772
+
1773
+ <template>
1774
+ <div class="tree-frame">
1775
+ <CoarTree :builder="builder">
1776
+ <template #default="{ node }">
1777
+ <span
1778
+ v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
1779
+ class="tree-row__main"
1780
+ >
1781
+ <CoarIcon
1782
+ :name="node.children ? 'folder' : 'file-text'"
1783
+ size="xs"
1784
+ class="tree-row__icon"
1785
+ />
1786
+ <span class="tree-row__label">{{ node.label }}</span>
1787
+ </span>
1788
+ </template>
1789
+ </CoarTree>
1790
+ </div>
1791
+ <p class="hint">
1792
+ 5 000 nodes total · only the ~15 visible rows are mounted at a time · expand more categories to see scroll stay smooth.
1793
+ </p>
1794
+ </template>
1795
+
1796
+ <style scoped>
1797
+ .tree-frame {
1798
+ border: 1px solid var(--coar-border-neutral-secondary);
1799
+ border-radius: 8px;
1800
+ max-width: 360px;
1801
+ /* Virtualization needs an explicit height for the scroll viewport. Without
1802
+ one the tree would collapse to 0 height (flex / grid auto sizing) and
1803
+ useVirtualList wouldn't have anything to measure. */
1804
+ height: 320px;
1805
+ display: flex;
1806
+ }
1807
+ .tree-row__main {
1808
+ display: flex;
1809
+ align-items: center;
1810
+ gap: 6px;
1811
+ flex: 1;
1812
+ min-width: 0;
1813
+ }
1814
+ .tree-row__icon {
1815
+ color: var(--coar-text-neutral-tertiary);
1816
+ flex-shrink: 0;
1817
+ }
1818
+ .tree-row__label {
1819
+ flex: 1;
1820
+ min-width: 0;
1821
+ white-space: nowrap;
1822
+ overflow: hidden;
1823
+ text-overflow: ellipsis;
1824
+ }
1825
+ .hint {
1826
+ margin-top: 12px;
1827
+ font-size: 12px;
1828
+ color: var(--coar-text-neutral-tertiary);
1829
+ }
1830
+ </style>
1831
+ ```
1832
+
1833
+ ### Scale (50 000 nodes)
1834
+
1835
+ Virtualization plus the flat-render pipeline scales to tens of thousands of nodes. Hit **Expand all** to flatten ~51 200 rows into the visible list — only ~30 row components stay mounted, and selecting / arrow-keying / dragging stays smooth. Expand-all is O(1) in the virtualizer for the constant row height, and a selection or drag-over re-renders only the rows that actually change.
1836
+
1837
+ **Demo — `tree/demos/TreeStress.vue`**
1838
+
1839
+ ```vue
1840
+ <script setup lang="ts">
1841
+ /**
1842
+ * Stress / benchmark demo — ~51 200 nodes across 3 levels.
1843
+ *
1844
+ * 200 top folders × 5 subfolders × 50 leaves. Even with EVERY folder expanded
1845
+ * (51 200 visible rows) only the ~viewport rows are mounted, and the hot paths
1846
+ * stay cheap:
1847
+ * - flat `visibleRows` DFS stamps id / isExpandable / draggable once per row;
1848
+ * - the virtualizer skips the O(n) offset array for the constant 28-px height
1849
+ * (expand-all / collapse-all is O(1) in the virtualizer);
1850
+ * - selection / focus / drag-over re-render only the rows that change, because
1851
+ * each row derives its own flags from the shared reactive state;
1852
+ * - drag-and-drop resolves the source + cycle check in O(depth), not O(n).
1853
+ *
1854
+ * Use "Expand all" then drag the scrollbar, arrow-key through rows, and select
1855
+ * rows — it stays smooth at 50k.
1856
+ */
1857
+ import { ref, computed } from 'vue';
1858
+ import { CoarTree, CoarIcon, CoarButton, useTree } from '@cocoar/vue-ui';
1859
+
1860
+ interface Node {
1861
+ id: string;
1862
+ label: string;
1863
+ children?: Node[];
1864
+ }
1865
+
1866
+ const TOP = 200;
1867
+ const SUB = 5;
1868
+ const LEAVES = 50;
1869
+
1870
+ const tree: Node[] = Array.from({ length: TOP }, (_, t) => ({
1871
+ id: `f${t}`,
1872
+ label: `Folder ${t + 1}`,
1873
+ children: Array.from({ length: SUB }, (_, s) => ({
1874
+ id: `f${t}-s${s}`,
1875
+ label: `Section ${t + 1}.${s + 1}`,
1876
+ children: Array.from({ length: LEAVES }, (_, i) => ({
1877
+ id: `f${t}-s${s}-i${i}`,
1878
+ label: `item-${t + 1}.${s + 1}.${String(i + 1).padStart(2, '0')}.md`,
1879
+ })),
1880
+ })),
1881
+ }));
1882
+
1883
+ const totalNodes = TOP + TOP * SUB + TOP * SUB * LEAVES; // 200 + 1 000 + 50 000
1884
+
1885
+ // Collect every folder id so "Expand all" can flatten the whole tree into the
1886
+ // visible-row list — the worst case for the DFS + virtualizer.
1887
+ const allFolderIds: string[] = [];
1888
+ for (const f of tree) {
1889
+ allFolderIds.push(f.id);
1890
+ for (const s of f.children ?? []) allFolderIds.push(s.id);
1891
+ }
1892
+
1893
+ const expanded = ref(new Set<string>(['f0', 'f0-s0']));
1894
+ const selected = ref<string | null>(null);
1895
+
1896
+ const visibleCount = computed(() => {
1897
+ // Mirrors the tree's own DFS for the on-screen counter (not used internally).
1898
+ let n = 0;
1899
+ const walk = (list: Node[]) => {
1900
+ for (const node of list) {
1901
+ n++;
1902
+ if (node.children && expanded.value.has(node.id)) walk(node.children);
1903
+ }
1904
+ };
1905
+ walk(tree);
1906
+ return n;
1907
+ });
1908
+
1909
+ const { builder } = useTree<Node>();
1910
+ builder
1911
+ .nodes(tree)
1912
+ .getId((n) => n.id)
1913
+ .getChildren((n) => n.children)
1914
+ .getLabel((n) => n.label)
1915
+ .expanded(expanded)
1916
+ .selected(selected)
1917
+ .draggable(true)
1918
+ .onNodeMove(() => {
1919
+ // No-op for the demo — we only exercise the drag machinery, not mutation.
1920
+ })
1921
+ .virtualize({ itemSize: 28, overscan: 8 });
1922
+
1923
+ function expandAll() {
1924
+ expanded.value = new Set(allFolderIds);
1925
+ }
1926
+ function collapseAll() {
1927
+ expanded.value = new Set();
1928
+ }
1929
+ </script>
1930
+
1931
+ <template>
1932
+ <div class="stress">
1933
+ <div class="stress__bar">
1934
+ <CoarButton size="s" variant="secondary" @click="expandAll">Expand all</CoarButton>
1935
+ <CoarButton size="s" variant="secondary" @click="collapseAll">Collapse all</CoarButton>
1936
+ <span class="stress__stat">
1937
+ {{ totalNodes.toLocaleString() }} nodes · {{ visibleCount.toLocaleString() }} visible ·
1938
+ ~{{ Math.ceil(320 / 28) + 16 }} mounted
1939
+ </span>
1940
+ </div>
1941
+ <div class="stress__frame">
1942
+ <CoarTree :builder="builder">
1943
+ <template #default="{ node }">
1944
+ <span class="stress__row">
1945
+ <CoarIcon :name="node.children ? 'folder' : 'file-text'" size="xs" class="stress__icon" />
1946
+ <span class="stress__label">{{ node.label }}</span>
1947
+ </span>
1948
+ </template>
1949
+ </CoarTree>
1950
+ </div>
1951
+ <p class="stress__hint">
1952
+ Expand all → 51 200 visible rows, yet only ~30 row components exist at any time. Scroll, arrow-key,
1953
+ and select stay smooth; dragging resolves in O(depth), not O(nodes).
1954
+ </p>
1955
+ </div>
1956
+ </template>
1957
+
1958
+ <style scoped>
1959
+ .stress {
1960
+ display: flex;
1961
+ flex-direction: column;
1962
+ gap: 8px;
1963
+ }
1964
+ .stress__bar {
1965
+ display: flex;
1966
+ align-items: center;
1967
+ gap: 8px;
1968
+ flex-wrap: wrap;
1969
+ }
1970
+ .stress__stat {
1971
+ font-size: 12px;
1972
+ color: var(--coar-text-neutral-tertiary);
1973
+ font-variant-numeric: tabular-nums;
1974
+ }
1975
+ .stress__frame {
1976
+ border: 1px solid var(--coar-border-neutral-secondary);
1977
+ border-radius: 8px;
1978
+ max-width: 420px;
1979
+ height: 320px;
1980
+ display: flex;
1981
+ }
1982
+ .stress__row {
1983
+ display: flex;
1984
+ align-items: center;
1985
+ gap: 6px;
1986
+ flex: 1;
1987
+ min-width: 0;
1988
+ }
1989
+ .stress__icon {
1990
+ color: var(--coar-text-neutral-tertiary);
1991
+ flex-shrink: 0;
1992
+ }
1993
+ .stress__label {
1994
+ flex: 1;
1995
+ min-width: 0;
1996
+ white-space: nowrap;
1997
+ overflow: hidden;
1998
+ text-overflow: ellipsis;
1999
+ }
2000
+ .stress__hint {
2001
+ margin-top: 4px;
2002
+ font-size: 12px;
2003
+ color: var(--coar-text-neutral-tertiary);
2004
+ }
2005
+ </style>
2006
+ ```
2007
+
2008
+ **Configuration:**
2009
+
2010
+ ```ts
2011
+ // builder mode
2012
+ builder.virtualize(true); // defaults: itemSize 28, overscan 5
2013
+ builder.virtualize({ itemSize: 32 }); // uniform 32-px rows
2014
+ builder.virtualize({ itemSize: (i) => myHeights[i] }); // variable per visible-row
2015
+ builder.virtualize({ itemSize: 28, overscan: 10 }); // larger scroll buffer
2016
+
2017
+ // props mode
2018
+ <CoarTree :virtualize="true" ... />
2019
+ <CoarTree :virtualize="{ itemSize: 32 }" ... />
2020
+ ```
2021
+
2022
+ **Requirements + caveats:**
2023
+
2024
+ - **Explicit height.** The tree owns its scroll container when virtualized. Put it in a sized parent (fixed `height`, flex with `flex: 1` + `min-height: 0`, grid cell with `1fr`, etc.). Without a measurable viewport `useVirtualList` produces an empty render.
2025
+ - **Row height must match.** The configured `itemSize` is what the spacer + absolute positioning use. If your slot renders a row taller than `itemSize`, content overflows; shorter and there's empty space. Measure your row in DevTools and configure accordingly.
2026
+ - **No auto-measurement.** This is a fixed-known-size virtualizer (unlike TanStack Virtual's `measureElement`). For trees with truly dynamic row heights (e.g. wrapping multi-line labels), keep virtualization off — performance is fine up to ~500 visible rows even without it.
2027
+ - **Both modes use flat rendering.** Whether virtualized or not, the tree renders rows as a flat DFS list (depth via `padding-left`, ARIA hierarchy via `aria-level`). `<ul role="group">` nesting was dropped on purpose — it's optional under WAI-ARIA and incompatible with flat virtualization.
2028
+
2029
+ ## Accessibility
2030
+
2031
+ ### Keyboard
2032
+
2033
+ | Key | Action |
2034
+ |-----|--------|
2035
+ | `↑` / `↓` | Move focus to previous / next visible row (skips disabled). Hold `Shift` (multi modes) to extend the selection |
2036
+ | `→` / `←` | Expand / descend, collapse / ascend (inverted under RTL) |
2037
+ | `Home` / `End` | First / last visible (enabled) row |
2038
+ | `PageUp` / `PageDown` | Move focus by one viewport of rows |
2039
+ | `*` | Expand all sibling folders at the focused row's level |
2040
+ | `Enter` | Activate (emits `@activate`) — typically opens the node |
2041
+ | `Space` | Checkbox mode: toggle the row's checkbox. Otherwise: expand a folder / select a leaf |
2042
+ | `Ctrl`/`Cmd` + `A` | Select all visible rows (multiple / checkbox) |
2043
+ | `Ctrl`/`Cmd` + `X` … `V` | Grab the focused row, then drop it relative to the focused target (accessible move); `Escape` cancels |
2044
+ | `F2` | Start an inline rename on the focused row (needs `renamable`) |
2045
+ | Letter | Type-ahead: jump to the next visible row whose label starts with the typed prefix (resets after 500 ms) |
2046
+
2047
+ ### ARIA
2048
+
2049
+ - Root has `role="tree"` — give it an accessible name via `ariaLabel` / `ariaLabelledby`. `aria-multiselectable="true"` in multiple / checkbox modes.
2050
+ - Each row has `role="treeitem"` with `aria-level`, `aria-posinset`, `aria-setsize`. `aria-selected` is set on **every** row (`true` on selected, `false` otherwise — the APG multi-select pattern). Checkbox rows also carry `aria-checked` (`true` / `false` / `mixed`); disabled rows `aria-disabled`; loading rows `aria-busy`.
2051
+ - A polite **live region** announces drag / keyboard-move state (pick-up, dropped, cancelled) and lazy-load **errors**. All strings are overridable via [`labels`](#i18n-labels).
2052
+ - **Flat rendering** — rows are a single DFS list, not nested `<ul role="group">`. Hierarchy is conveyed by `aria-level` (WAI-ARIA permits this and it's what enables flat virtualization; see [Virtualization](#virtualization)).
2053
+
2054
+ ## Inline rename
2055
+
2056
+ Set `renamable` to opt into built-in inline editing. Drop `<CoarTreeNodeLabel>` into the default slot in place of your label span — it swaps to an `<input>` while its row is being renamed and picks up the rename machinery via injection (no extra wiring). Start a rename from a context-menu item or button with `api.startRename(id)`, or press **F2** on the focused row. Commit on Enter / blur fires `@rename`; Escape (or an empty name) fires `@rename-cancel`. Conflict handling / validation is the consumer's job — the tree is stateless about it, so on failure just call `api.startRename(id)` again.
2057
+
2058
+ ```vue
2059
+ <script setup lang="ts">
2060
+ import { CoarTree, CoarTreeNodeLabel, useTree } from '@cocoar/vue-ui'
2061
+ const { builder, api } = useTree<FsNode>()
2062
+ builder
2063
+ .nodes(tree).getId(n => n.id).getChildren(n => n.children).getLabel(n => n.name)
2064
+ .renamable(true)
2065
+ .onRename(({ node, newName }) => applyRename(node.id, newName))
2066
+ .folderMenu(folder => [{ label: 'Rename', icon: 'pencil', onClick: () => api.startRename(folder.id) }])
2067
+ </script>
2068
+
2069
+ <template>
2070
+ <CoarTree :builder="builder">
2071
+ <template #default="{ node }">
2072
+ <CoarIcon :name="node.children ? 'folder' : 'file'" size="xs" />
2073
+ <CoarTreeNodeLabel :label="node.name" />
2074
+ </template>
2075
+ </CoarTree>
2076
+ </template>
2077
+ ```
2078
+
2079
+ ## Inline create
2080
+
2081
+ The counterpart to inline rename. Set `creatable` and call `api.startCreate(parentId, opts?)` to insert a **transient draft row** at its target position — `parentId: null` creates at the root, otherwise the parent auto-expands and the draft renders nested under it. The draft shows a focused `<input>` with the same blur-grace timer as rename; **Enter / blur** commits and fires `@create` with `{ parentId, name, kind }`, while **Escape** or an empty name fires `@create-cancel`. The draft is purely transient: persist the node in your `@create` handler and feed the real one back via your data source — the tree drops the draft on commit.
2082
+
2083
+ `opts`: `kind` (`'folder'` \| `'leaf'`, default `'folder'` — picks the default icon and is echoed back on `@create`), `initialName` (prefill, default `''`), `position` (`'first'` \| `'last'` within the parent, default `'last'`). Override the default draft icon with the optional `#draft` slot (`{ kind, depth }`).
2084
+
2085
+ ```vue
2086
+ <script setup lang="ts">
2087
+ import { CoarTree, CoarTreeNodeLabel, useTree } from '@cocoar/vue-ui'
2088
+ const { builder, api } = useTree<FsNode>()
2089
+ builder
2090
+ .nodes(tree).getId(n => n.id).getChildren(n => n.children).getLabel(n => n.name)
2091
+ .creatable(true)
2092
+ .onCreate(({ parentId, name, kind }) => createNode(parentId, name, kind))
2093
+ .folderMenu(folder => [
2094
+ { label: 'New folder', icon: 'folder-plus', onClick: () => api.startCreate(folder.id, { kind: 'folder' }) },
2095
+ ])
2096
+ .viewportMenu(() => [
2097
+ { label: 'New folder', icon: 'folder-plus', onClick: () => api.startCreate(null) },
2098
+ ])
2099
+ </script>
2100
+
2101
+ <template>
2102
+ <CoarTree :builder="builder">
2103
+ <template #default="{ node }">
2104
+ <CoarIcon :name="node.children ? 'folder' : 'file'" size="xs" />
2105
+ <CoarTreeNodeLabel :label="node.name" />
2106
+ </template>
2107
+ <!-- optional: custom draft icon -->
2108
+ <template #draft="{ kind }">
2109
+ <CoarIcon :name="kind === 'folder' ? 'folder' : 'file'" size="xs" />
2110
+ </template>
2111
+ </CoarTree>
2112
+ </template>
2113
+ ```
2114
+
2115
+ Pairs with `@cocoar/vue-file-explorer-core`'s optimistic `addFolder` so the draft → real-node handoff has no flicker.
2116
+
2117
+ ### Async validation — keep the draft open on rejection
2118
+
2119
+ If creation can fail server-side (a duplicate-name 409, a permission check), you don't want the user's typed name discarded. Two paths, depending on which API form you use:
2120
+
2121
+ - **Builder form** — return a `Promise` from `onCreate`. The tree keeps the draft mounted + focused (name intact) until it settles: it drops the draft on resolve and **reopens it on reject** so the user can fix the name and retry.
2122
+
2123
+ ```ts
2124
+ builder.creatable(true).onCreate(async ({ parentId, name }) => {
2125
+ await api.createFolder(parentId, name) // throws on 409 → draft stays open
2126
+ })
2127
+ ```
2128
+
2129
+ - **Prop / event form** — Vue's `emit` can't return a value, so reopen imperatively: on a rejected `@create`, re-call `startCreate` with `initialName` to restore the draft with the typed text.
2130
+
2131
+ ```ts
2132
+ async function onCreate({ parentId, name }) {
2133
+ try {
2134
+ await createFolder(parentId, name)
2135
+ } catch {
2136
+ treeRef.value?.startCreate(parentId, { initialName: name }) // reopen, name preserved
2137
+ }
2138
+ }
2139
+ ```
2140
+
2141
+ > Both `creatable` / `@create` / `@create-cancel` and `api.startCreate` work in **prop-mode** too (without `useTree()`): `startCreate` is on the component's template ref alongside `startRename`.
2142
+
2143
+ ## Disabled nodes
2144
+
2145
+ `isDisabled(node)` marks rows non-interactive: they can't be selected, activated, directly checked, focused by keyboard, matched by type-ahead, or dragged, and they render `aria-disabled` + dimmed. The `isDisabled` slot prop lets you adjust your own row content. Cascade from a *checked ancestor* still flows through a disabled descendant — disabled blocks **direct** interaction, not bulk parent operations.
2146
+
2147
+ ```ts
2148
+ builder.isDisabled(n => n.readonly)
2149
+ ```
2150
+
2151
+ ## Search / filter
2152
+
2153
+ Pass the matching ids as `matchedIds` and the tree handles the rest. Two modes:
2154
+
2155
+ - **Highlight (default):** every row stays visible; the slot gets `isMatch` / `isMatchAncestor` for styling, and the ancestors of each match auto-expand so deep hits are revealed (add-only — your manual collapses survive).
2156
+ - **Filter** (`filter` prop / `.filter()`): non-matches are hidden — but the **ancestor path of each match stays visible as "virtual parents"** (flagged `isMatchAncestor`, so you can de-emphasize them). The tree never collapses into a contextless flat list; you always see *where* a hit lives. Computing `matchedIds` itself stays yours (any fuzzy / regex / field match you like).
2157
+
2158
+ `filterMode` (mirrors PrimeVue) decides what a matched **folder** keeps:
2159
+ - **`'strict'`** (default) — matches + ancestor path only; a matched folder's non-matching children stay hidden. This is the VS Code / react-arborist "filter down to what I searched for" convention.
2160
+ - **`'lenient'`** — a matched folder reveals its whole subtree.
2161
+
2162
+ ```vue
2163
+ <script setup>
2164
+ const matchedIds = computed(() => {
2165
+ const q = query.value.toLowerCase()
2166
+ if (!q) return undefined
2167
+ return new Set(allNodes.value.filter(n => n.name.toLowerCase().includes(q)).map(n => n.id))
2168
+ })
2169
+ </script>
2170
+
2171
+ <template>
2172
+ <CoarTree :builder="builder" :matched-ids="matchedIds" :filter="hideNonMatches">
2173
+ <template #default="{ node, isMatch, isMatchAncestor }">
2174
+ <span :class="{ hit: isMatch, 'virtual-parent': isMatchAncestor }">{{ node.name }}</span>
2175
+ </template>
2176
+ </CoarTree>
2177
+ </template>
2178
+ ```
2179
+
2180
+ ## Density & theming
2181
+
2182
+ `density` (`xs` / `s` / `m` / `l`, default `m`) scales the **whole** row — font, padding, indent, **and** the built-in chevron + checkbox (box and glyph) together. Under the hood it sets CSS variables you can also override directly to retheme without forking:
2183
+
2184
+ | Variable | Default (`m`) | Controls |
2185
+ |----------|---------------|----------|
2186
+ | `--coar-tree-indent` | `14px` | Per-level indent step |
2187
+ | `--coar-tree-indent-base` | `8px` | Base (level-0) indent |
2188
+ | `--coar-tree-row-pad-y` / `-x` | `3px` / `4px` | Row padding |
2189
+ | `--coar-tree-row-font` | inherits body-small | Row font size |
2190
+ | `--coar-tree-control-size` | `16px` | Chevron + checkbox box size |
2191
+ | `--coar-tree-icon-size` | `12px` | Glyph inside (checkmark / dash / chevron) |
2192
+
2193
+ The icons **you** render in the slot are still yours to size — but `--coar-tree-icon-size` cascades into the slot, so you can scale them with the tree:
2194
+
2195
+ ```vue
2196
+ <template #default="{ node }">
2197
+ <CoarIcon :name="node.children ? 'folder' : 'file'" size="var(--coar-tree-icon-size)" />
2198
+ <span>{{ node.name }}</span>
2199
+ </template>
2200
+ ```
2201
+
2202
+ > **Warning**
2203
+ >
2204
+ > When virtualizing, set `virtualize.itemSize` to match your density's row height — the virtualizer is fixed-size and doesn't auto-measure.
2205
+
2206
+ ## i18n / labels {#i18n-labels}
2207
+
2208
+ Every built-in string (chevron Expand / Collapse, the loading spinner, the retry button, the inline-create input's `aria-label`, and all the polite drag / move / load-error announcements) is overridable via `labels` for localization. Unset fields fall back to the English `DEFAULT_TREE_LABELS`.
2209
+
2210
+ ```ts
2211
+ builder.labels({
2212
+ expand: 'Aufklappen',
2213
+ collapse: 'Zuklappen',
2214
+ loading: 'Lädt …',
2215
+ retry: 'Erneut',
2216
+ draftFolderName: 'Name des neuen Ordners',
2217
+ draftFileName: 'Name der neuen Datei',
2218
+ moveCancelled: 'Verschieben abgebrochen.',
2219
+ })
2220
+ ```
2221
+
2222
+ ## Recipes
2223
+
2224
+ ### Truncation in narrow sidebars
2225
+
2226
+ The tree owns indentation and the chevron but doesn't render the label — the consumer does, via the default slot. The standard pattern is **ellipsis + `v-tooltip` with `onlyOnOverflow: true`** on the label span — the styled Coar tooltip appears only when the text is actually clipped, and stays out of the way when the full label is visible:
2227
+
2228
+ ```vue
2229
+ <script setup lang="ts">
2230
+ import { vTooltip } from '@cocoar/vue-ui';
2231
+ </script>
2232
+
2233
+ <template #default="{ node }">
2234
+ <!--
2235
+ The tooltip lives on a wrapper around the icon + label — NOT the label
2236
+ alone. When the row gets so narrow that the label collapses to 0 px
2237
+ (deep nesting in a slim sidebar), the icon still has a hit area and the
2238
+ tooltip remains reachable. The string form of `onlyOnOverflow` tells the
2239
+ directive to gate on the *child label's* overflow, not the wrapper's.
2240
+ -->
2241
+ <span
2242
+ v-tooltip="{ content: node.label, onlyOnOverflow: '.label' }"
2243
+ class="row-main"
2244
+ >
2245
+ <CoarIcon :name="node.children ? 'folder' : 'file'" size="xs" />
2246
+ <span class="label">{{ node.label }}</span>
2247
+ </span>
2248
+ </template>
2249
+
2250
+ <style>
2251
+ .row-main {
2252
+ display: flex;
2253
+ align-items: center;
2254
+ gap: 6px;
2255
+ flex: 1;
2256
+ min-width: 0;
2257
+ }
2258
+ .label {
2259
+ flex: 1;
2260
+ min-width: 0; /* allow shrink inside the flex row */
2261
+ white-space: nowrap;
2262
+ overflow: hidden;
2263
+ text-overflow: ellipsis;
2264
+ }
2265
+ </style>
2266
+ ```
2267
+
2268
+ `onlyOnOverflow` accepts three forms — all evaluated lazily on hover/focus, no `ResizeObserver` overhead:
2269
+
2270
+ | Form | Meaning |
2271
+ |------|---------|
2272
+ | `true` | Check `scrollWidth > clientWidth` on the trigger element itself |
2273
+ | `string` (CSS selector) | Check overflow on the matched descendant — use when the tooltip is on a wrapper but only an inner element is what gets truncated |
2274
+ | `function: (el) => boolean` | Custom predicate; return `true` to show the tooltip |
2275
+
2276
+ > **Tip: Scale**
2277
+ >
2278
+ > A tree with hundreds of mounted rows means hundreds of `v-tooltip` directive listeners. Combine this pattern with [virtualization](#virtualization) for very large trees — only the ~visible rows hold listeners. For low-overhead plain-text fallback (e.g. mobile / embedded contexts where the overlay system isn't installed), use the native `:title` attribute instead — same UX, zero JS.
2279
+
2280
+ ### File-explorer shell
2281
+
2282
+ Combine `<CoarTree>` with `CoarTabGroup`, `CoarScriptEditor`, `CoarMarkdownEditor`, and `CoarDocumentViewer` to get a VS-Code-style document explorer. The tree owns the asset hierarchy and DnD; tabs hold the open editors; per-row `⋮` and right-click expose CRUD actions through a single `<CoarContextMenu>`.
2283
+
2284
+ ### Multi-tree drag
2285
+
2286
+ Two `<CoarTree>` instances on the same page can exchange nodes via the shared `application/x-coar-tree-node` MIME type. Each tree's `@node-move` only fires when the drop lands on one of its own rows — you decide on the consumer side how to coordinate the source/target trees (e.g. a parent component that owns both data stores).
2287
+
2288
+ ## API
2289
+
2290
+ ### Props
2291
+
2292
+ | Prop | Type | Default | Description |
2293
+ |------|------|---------|-------------|
2294
+ | `builder` | `TreeBuilder<T>` | `undefined` | Fluent builder from `useTree()`. When set, the other config props are ignored. Recommended for non-trivial cases |
2295
+ | `nodes` | `readonly T[]` | — | Root nodes (required in props-mode; ignored when `builder` is set) |
2296
+ | `getId` | `(node: T) => string` | — | Identity extractor. Must be unique across the entire visible tree (required in props-mode) |
2297
+ | `getChildren` | `(node: T) => readonly T[] \| null \| undefined` | `undefined` | Returns the children of a node — return `undefined` or `null` for leaves. Without it, the tree is a flat list |
2298
+ | `getLabel` | `(node: T) => string` | `undefined` | Used by type-ahead navigation. Optional but recommended for keyboard UX |
2299
+ | `isExpandable` | `(node: T) => boolean` | derived from `getChildren` | Override branch detection — useful when a folder should always render as expandable even if its children are lazy-loaded |
2300
+ | `isDisabled` | `(node: T) => boolean` | `undefined` | Mark nodes non-interactive: no select / activate / check / keyboard-focus, `aria-disabled`, dimmed. See [Disabled nodes](#disabled-nodes) |
2301
+ | `loadChildren` | `(node: T, ctx: { signal: AbortSignal }) => void \| Promise<void>` | `undefined` | Lazily fetch a node's children on first expand. Pair with `isExpandable`. `ctx.signal` aborts on collapse / removal. See [Lazy loading](#lazy-loading-async-children) |
2302
+ | `maxConcurrentLoads` | `number` | `0` | Cap simultaneous in-flight `loadChildren` calls (extra ones queue). `0` = unlimited. Set a small number (e.g. `6`) for rate-limited backends |
2303
+ | `hideLoadingSpinner` | `boolean` | `false` | Suppress the built-in chevron spinner **and** retry affordance. Set it when you render your own from `isLoading` / `hasError` |
2304
+ | `selectionMode` | `'single' \| 'multiple' \| 'checkbox'` | `'single'` | See [Selection](#selection). `single` → `v-model:selected`; `multiple`/`checkbox` → `v-model:selectedIds` (+ `v-model:checkedIds` for checkbox) |
2305
+ | `checkStrictly` | `boolean` | `false` | Checkbox mode only: independent parent/child checks, no cascade / indeterminate |
2306
+ | `draggable` | `boolean \| ((n: T) => boolean)` | `false` | Allow internal drag-to-reorder. Pass a function to enable per-node |
2307
+ | `canDrop` | `(source: T, target: T \| null, position: 'before' \| 'inside' \| 'after') => boolean` | `undefined` | Veto drops on top of the built-in cycle guard. Advisory — `source` is the dragstart snapshot; integrity is guaranteed regardless |
2308
+ | `getDragImage` | `(node: T) => HTMLElement \| string \| null \| undefined` | `undefined` | Custom drag ghost (element or HTML string); falls back to the default row image |
2309
+ | `activateOnClick` | `boolean` | `false` | Fire `activate` on a single click too (not only double-click / Enter) |
2310
+ | `renamable` | `boolean` | `false` | Opt into built-in inline rename (`api.startRename` / F2 / `@rename`). See [Inline rename](#inline-rename) |
2311
+ | `creatable` | `boolean` | `false` | Opt into built-in inline create (`api.startCreate` / `@create`). See [Inline create](#inline-create) |
2312
+ | `acceptsFiles` | `boolean` | `false` | Accept OS file drops onto folder rows / the background |
2313
+ | `acceptsData` | `string[]` | `undefined` | MIME type(s) of app-internal drags to accept as a drop (e.g. a card from a grid). Fires `@data-drop`. See [App-internal drops](#app-internal-drops-drag-something-onto-a-node) |
2314
+ | `autoExpandDelay` | `number` | `700` | Milliseconds the cursor must hover before a collapsed folder auto-expands during a drag |
2315
+ | `virtualize` | `boolean \| { itemSize?, overscan? }` | `false` | Enable row virtualization. `true` uses defaults (28-px rows, 5-row overscan); pass an object to customize |
2316
+ | `density` | `'xs' \| 's' \| 'm' \| 'l'` | `'m'` | Row spacing preset (sets the spacing CSS vars). With virtualization, match `virtualize.itemSize` to the density's row height |
2317
+ | `ariaLabel` | `string` | `undefined` | Accessible name on the `role="tree"` element |
2318
+ | `ariaLabelledby` | `string` | `undefined` | Id of an external label element for the `role="tree"` element |
2319
+ | `labels` | `Partial<CoarTreeLabels>` | English defaults | Override built-in / screen-reader strings (chevron, spinner, retry, announcements) for i18n |
2320
+ | `matchedIds` | `Set<string>` | `undefined` | Search hits — drives `isMatch` / `isMatchAncestor` slot props + auto-expand-to-match. See [Search / filter](#search-filter) |
2321
+ | `filter` | `boolean` | `false` | With `matchedIds`, hide non-matches but keep the matches + their ancestor path ("virtual parents"). See [Search / filter](#search-filter) |
2322
+ | `filterMode` | `'strict' \| 'lenient'` | `'strict'` | What a matched **folder** keeps when filtering. `strict` = matches + path only; `lenient` = the matched folder's whole subtree. See [Search / filter](#search-filter) |
2323
+ | `v-model:expanded` | `Set<string>` | empty `Set` | Ids of expanded folders. Replaced with a fresh `Set` on each change to trigger reactivity |
2324
+ | `v-model:selected` | `string \| null` | `null` | Selected row id (**single** mode) |
2325
+ | `v-model:selectedIds` | `Set<string>` | empty `Set` | Highlight selection (**multiple** / **checkbox** modes) |
2326
+ | `v-model:checkedIds` | `Set<string>` | empty `Set` | Checkbox selection (**checkbox** mode), independent of the highlight |
2327
+
2328
+ ### Events
2329
+
2330
+ | Event | Payload | Fires |
2331
+ |-------|---------|-------|
2332
+ | `activate` | `(node: T)` | Double-click on a row or `Enter` on the focused row (also single click if `activateOnClick`) |
2333
+ | `select` | `({ node: T \| null, ids: readonly string[], via: 'user' \| 'api' })` | The highlight selection changed. `node` = the row acted on, `ids` = the full selection after, `via` = user gesture vs `api` call |
2334
+ | `context-menu` | `(node: T \| null, ev: MouseEvent)` | Right-click on a row (`node` set) or background (`node` is `null`). The default action is suppressed automatically by `useContextMenu().open(ev)` |
2335
+ | `files-drop` | `({ files: FileList, target: T \| null })` | OS files dropped on a folder (`target` set) or empty background (`target` is `null`). Only fires when `accepts-files` is `true` |
2336
+ | `data-drop` | `({ node: T \| null, position, dataTransfer: DataTransfer })` | An app-internal drag (an `accepts-data` MIME) dropped on a row (`node` set) or background (`node` is `null`). Read your payload via `dataTransfer.getData(mime)` in the handler |
2337
+ | `node-move` | `({ source: T, target: T \| null, position })` | Internal drag-drop OR keyboard move / `api.moveNode`. `position` is `'before'`, `'inside'`, or `'after'`. `target: null` + `'inside'` means "move to root" |
2338
+ | `rename` | `({ node: T, newName: string })` | An inline rename committed (Enter / blur, non-empty). Needs `renamable`. See [Inline rename](#inline-rename) |
2339
+ | `rename-cancel` | `(node: T)` | An inline rename cancelled (Escape, or committed empty) |
2340
+ | `create` | `({ parentId: string \| null, name: string, kind: 'folder' \| 'leaf' })` | An inline create committed (Enter / blur, non-empty). Needs `creatable`. See [Inline create](#inline-create) |
2341
+ | `create-cancel` | — | An inline create cancelled (Escape, or committed empty) |
2342
+ | `load-error` | `({ node: T, error: unknown })` | A lazy `loadChildren` promise rejected. Only fires when `loadChildren` is set |
2343
+ | `update:expanded` | `(Set<string>)` | Folder expanded / collapsed |
2344
+ | `update:selected` | `(string \| null)` | Selection changed (single mode) |
2345
+ | `update:selectedIds` | `(Set<string>)` | Highlight selection changed (multiple / checkbox modes) |
2346
+ | `update:checkedIds` | `(Set<string>)` | Checkbox selection changed (checkbox mode) |
2347
+
2348
+ ### Slots
2349
+
2350
+ | Slot | Props | Description |
2351
+ |------|-------|-------------|
2352
+ | `default` | `{ node, depth, isExpanded, isSelected, isChecked, isIndeterminate, isFocused, isExpandable, isMatch, isMatchAncestor, isDisabled, isRenaming, isLoading, hasError }` | Row body. The tree renders indentation, chevron, checkbox (checkbox mode), focus ring and drop indicators; you render the icon, label, inline action buttons, dirty markers, etc. |
2353
+ | `empty` | — | Shown when `nodes` is empty. Defaults to nothing — provide your own empty-state copy |
2354
+ | `draft` | `{ kind: 'folder' \| 'leaf', depth }` | Leading content (icon) for the inline-create draft row. Defaults to a folder/file icon. Needs `creatable`. See [Inline create](#inline-create) |
2355
+
2356
+ The `default` slot props in full:
2357
+
2358
+ | Prop | Meaning |
2359
+ |------|---------|
2360
+ | `isSelected` | In the highlight selection (`selected` / `selectedIds`) |
2361
+ | `isChecked` / `isIndeterminate` | Checkbox fully / partially checked (checkbox mode) |
2362
+ | `isMatch` / `isMatchAncestor` | A search hit / an ancestor of one (see [Search / filter](#search-filter)) |
2363
+ | `isDisabled` | `isDisabled(node)` returned true |
2364
+ | `isRenaming` | This row is in inline-rename mode |
2365
+ | `isLoading` / `hasError` | Lazy `loadChildren` in flight / failed |
2366
+
2367
+ ### Exposed methods
2368
+
2369
+ Available on both the **template ref** and the builder **`api`** (the `api` warns if called before mount; `getNode` returns `null`):
2370
+
2371
+ | Method | Description |
2372
+ |--------|-------------|
2373
+ | `selectNode(id)` | Highlight-select **and** focus a node (the "reveal & select" action) — preferred |
2374
+ | `focusNode(id)` | Builder `api`: alias of `selectNode` (selects + focuses, back-compat since 2.4.0). Template ref: focus-only. Prefer `selectNode` / `revealNode` for explicit intent |
2375
+ | `expandAll()` / `collapseAll()` | Expand every loaded, expandable node / collapse everything |
2376
+ | `expandTo(id)` | Expand all loaded ancestors of `id` so its row becomes visible |
2377
+ | `revealNode(id)` | Scroll a node into view **without** stealing focus (expands ancestors first) |
2378
+ | `getNode(id)` | Resolve a node by id from the loaded tree, or `null` |
2379
+ | `moveNode(sourceId, targetId, position)` | Move a node (keyboard / a11y equivalent of a drop); runs the cycle + `canDrop` guards; returns whether it emitted |
2380
+ | `reloadChildren(id)` | Force `loadChildren` to (re)run — retry after an error or refresh a loaded folder |
2381
+ | `startRename(id)` | Enter inline-rename mode on a node (needs `renamable`) |
2382
+ | `startCreate(parentId, opts?)` | Open an inline-create draft under `parentId` (`null` = root); needs `creatable`. `opts`: `{ kind?, initialName?, position? }` |
2383
+
2384
+ ### Constants
2385
+
2386
+ | Constant | Value | Use |
2387
+ |----------|-------|-----|
2388
+ | `COAR_TREE_DRAG_MIME` | `'application/x-coar-tree-node'` | Mime type set on `DataTransfer` for internal drags. Read it on `drop` if you're orchestrating drags between two trees that share an outer container |