@getdom/studio 0.1.0

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 (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,961 @@
1
+ <script setup>
2
+ import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue';
3
+ import {
4
+ flattenTreeItems,
5
+ isSameOrDescendantPath,
6
+ moveTreeItem,
7
+ seedTreeOpenValues,
8
+ treeItemCanAcceptChildren,
9
+ treeItemChildren,
10
+ treeItemHasChildren,
11
+ } from './treeUtils.js';
12
+ import { treeItemsFormSchema } from './treeSchemas.js';
13
+
14
+ /**
15
+ * @typedef {object} TreeItemAction
16
+ * @property {string} [value]
17
+ * @property {string} [label]
18
+ * @property {string} [icon]
19
+ * @property {boolean} [disabled]
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} TreeItem
24
+ * @property {string|number} [id]
25
+ * @property {string|number} [value]
26
+ * @property {string} [label]
27
+ * @property {string} [name]
28
+ * @property {string} [icon]
29
+ * @property {string} [rightIcon]
30
+ * @property {string} [rightIconAction]
31
+ * @property {string} [rightIconLabel]
32
+ * @property {boolean} [open]
33
+ * @property {boolean} [lazy]
34
+ * @property {boolean} [loading]
35
+ * @property {boolean} [disabled]
36
+ * @property {boolean} [draggable]
37
+ * @property {boolean} [acceptsChildren]
38
+ * @property {string} [slot]
39
+ * @property {Array<TreeItemAction>} [actions]
40
+ * @property {Array<TreeItem>} [children]
41
+ * Extra app-specific fields are preserved and returned in node payloads.
42
+ */
43
+
44
+ /**
45
+ * @typedef {Array<TreeItem>} TreeItems
46
+ * @example
47
+ * const items = [
48
+ * {
49
+ * id: 'src',
50
+ * label: 'src',
51
+ * type: 'folder',
52
+ * open: true,
53
+ * children: [
54
+ * { id: 'src-index', label: 'Index.vue', type: 'file' },
55
+ * ],
56
+ * },
57
+ * ];
58
+ */
59
+
60
+ /**
61
+ * @typedef {object} TreeNode
62
+ * @property {TreeItem} item
63
+ * @property {string|number} value
64
+ * @property {string|number} label
65
+ * @property {number} depth
66
+ * @property {TreeNode|null} parent
67
+ * @property {Array<number>} path
68
+ * @property {boolean} open
69
+ * @property {boolean} loading
70
+ * @property {boolean} expandable
71
+ */
72
+
73
+ /**
74
+ * @typedef {object} TreeContextMenuPayload
75
+ * @property {MouseEvent} event
76
+ * @property {'item'|'empty'} kind
77
+ * @property {TreeItem|null} item
78
+ * @property {string|number|null} value
79
+ * @property {TreeNode|null} node
80
+ * @example
81
+ * const rowContext = {
82
+ * event: new MouseEvent('contextmenu'),
83
+ * kind: 'item',
84
+ * item: { id: 'src-pages-docs', label: 'DomStudioLayout.vue', type: 'file' },
85
+ * value: 'src-pages-docs',
86
+ * node: {
87
+ * item: { id: 'src-pages-docs', label: 'DomStudioLayout.vue', type: 'file' },
88
+ * value: 'src-pages-docs',
89
+ * label: 'DomStudioLayout.vue',
90
+ * depth: 3,
91
+ * parent: null,
92
+ * path: [0, 0, 0],
93
+ * open: false,
94
+ * loading: false,
95
+ * expandable: false,
96
+ * },
97
+ * };
98
+ * @example
99
+ * const emptyContext = {
100
+ * event: new MouseEvent('contextmenu'),
101
+ * kind: 'empty',
102
+ * item: null,
103
+ * value: null,
104
+ * node: null,
105
+ * };
106
+ */
107
+
108
+ /**
109
+ * @typedef {'before'|'inside'|'after'} TreeDropPosition
110
+ */
111
+
112
+ /**
113
+ * @typedef {'tree'|'external'} TreeDropKind
114
+ */
115
+
116
+ /**
117
+ * @typedef {object} TreeSelectionPayload
118
+ * @property {TreeItem} item
119
+ * @property {string|number} value
120
+ * @property {TreeNode} node
121
+ */
122
+
123
+ /**
124
+ * @typedef {object} TreeActionPayload
125
+ * @property {TreeItemAction} action
126
+ * @property {TreeItem} item
127
+ * @property {string|number} value
128
+ * @property {TreeNode} node
129
+ */
130
+
131
+ /**
132
+ * @typedef {object} TreeTogglePayload
133
+ * @property {TreeItem} item
134
+ * @property {string|number} value
135
+ * @property {boolean} open
136
+ */
137
+
138
+ /**
139
+ * @typedef {object} TreeLoadChildrenPayload
140
+ * @property {TreeItem} item
141
+ * @property {string|number} value
142
+ */
143
+
144
+ /**
145
+ * @typedef {object} TreeDragPreviewPayload
146
+ * @property {TreeItem} item
147
+ * @property {string|number} value
148
+ * @property {TreeNode} node
149
+ * @property {TreeDropPosition} position
150
+ * @property {TreeDropKind} kind
151
+ */
152
+
153
+ /**
154
+ * @typedef {object} TreeDragPreviewEndPayload
155
+ * @property {string|number} value
156
+ * @property {TreeDropPosition} position
157
+ * @property {TreeDropKind} kind
158
+ */
159
+
160
+ /**
161
+ * @typedef {object} TreeReorderPayload
162
+ * @property {TreeItems} items
163
+ * @property {TreeItem} item
164
+ * @property {string|number} sourceValue
165
+ * @property {TreeNode} sourceNode
166
+ * @property {TreeItem} target
167
+ * @property {string|number} targetValue
168
+ * @property {TreeNode} targetNode
169
+ * @property {TreeDropPosition} position
170
+ */
171
+
172
+ /**
173
+ * @typedef {object} TreeExternalDropPayload
174
+ * @property {DragEvent} event
175
+ * @property {DataTransfer|null} dataTransfer
176
+ * @property {Array<string>} types
177
+ * @property {(type: string) => string} getData
178
+ * @property {TreeItem} item
179
+ * @property {string|number} value
180
+ * @property {TreeNode} node
181
+ * @property {TreeDropPosition} position
182
+ */
183
+
184
+ defineOptions({
185
+ __doc: {
186
+ name: 'Tree view',
187
+ tag: '<DomTreeView>',
188
+ description: 'A keyboard navigable hierarchical tree with selection, custom item rendering, lazy loading, and drag/drop reordering.',
189
+ icon: 'M6 5h5v5H6V5Zm7 2h5M8.5 10v3M6 13h5v5H6v-5Zm7 2h5M8.5 18v1M6 21h5',
190
+ slots: [
191
+ { name: 'item', payload: '{ item, node, depth, open, selected, loading }', description: 'Custom content inside the default tree row.' },
192
+ { name: 'row', payload: '{ item, node, depth, open, selected, loading, toggle, select }', description: 'Replace the full tree row while keeping tree structure, focus, and drag/drop wrapper.' },
193
+ { name: 'item.slot', payload: '{ item, node, depth, open, selected, loading }', description: 'Set item.slot to a named slot for special node rendering.' },
194
+ ],
195
+ events: [
196
+ { name: 'update:modelValue', payload: '(value: string | number)', description: 'Emitted when the selected node changes.' },
197
+ { name: 'select', payload: '({ item, value, node })', typeRef: 'TreeSelectionPayload', description: 'Fired when a tree item is selected.' },
198
+ { name: 'hover', payload: '({ item, value, node })', typeRef: 'TreeSelectionPayload', description: 'Fired when a pointer enters a tree row.' },
199
+ { name: 'hover-end', payload: '({ item, value, node })', typeRef: 'TreeSelectionPayload', description: 'Fired when a pointer leaves a tree row.' },
200
+ { name: 'action', payload: '({ action, item, value, node })', typeRef: 'TreeActionPayload', description: 'Fired when a right-side item action is clicked.' },
201
+ {
202
+ name: 'context-menu',
203
+ payload: '(payload: TreeContextMenuPayload)',
204
+ typeRef: 'TreeContextMenuPayload',
205
+ description: 'Fired when a tree row or empty tree space is right-clicked. Use kind to distinguish item from empty context.',
206
+ },
207
+ { name: 'toggle', payload: '({ item, value, open })', typeRef: 'TreeTogglePayload', description: 'Fired when a branch is opened or closed.' },
208
+ { name: 'load-children', payload: '({ item, value })', typeRef: 'TreeLoadChildrenPayload', description: 'Fired when a lazy node is opened and needs children.' },
209
+ { name: 'drag-preview', payload: '({ item, value, node, position, kind })', typeRef: 'TreeDragPreviewPayload', description: 'Fired while a valid drag target is being previewed.' },
210
+ { name: 'drag-preview-end', payload: '({ value, position, kind })', typeRef: 'TreeDragPreviewEndPayload', description: 'Fired when the current drag preview is cleared.' },
211
+ { name: 'reorder', payload: '({ items, item, sourceValue, sourceNode, target, targetValue, targetNode, position })', typeRef: 'TreeReorderPayload', description: 'Fired after drag/drop with a reordered items array.' },
212
+ { name: 'external-drop', payload: '({ event, dataTransfer, types, getData, item, value, node, position })', typeRef: 'TreeExternalDropPayload', description: 'Fired when accepted data from outside the tree is dropped onto a row.' },
213
+ { name: 'update:items', payload: '(items: TreeItems)', description: 'Emitted with the reordered tree so callers can sync v-model:items.' },
214
+ ],
215
+ keyboard: [
216
+ { keys: '↑ / ↓', action: 'Move focus through visible items.' },
217
+ { keys: '→', action: 'Open a closed branch, otherwise move to the first child.' },
218
+ { keys: '←', action: 'Close an open branch, otherwise move to the parent.' },
219
+ { keys: 'Enter / Space', action: 'Select the focused item.' },
220
+ { keys: 'Home / End', action: 'Move to the first or last visible item.' },
221
+ { keys: 'Alt + ↑ / ↓', action: 'Move the focused item before or after a sibling when draggable is enabled.' },
222
+ ],
223
+ },
224
+ });
225
+
226
+
227
+ const props = defineProps({
228
+ modelValue: {
229
+ type: [String, Number],
230
+ default: '',
231
+ _edit: { description: 'Selected node value. Update it programmatically to highlight a node from another part of the UI.' },
232
+ },
233
+ /**
234
+ * @type {TreeItems}
235
+ */
236
+ items: {
237
+ type: Array,
238
+ required: true,
239
+ _edit: {
240
+ component: 'DomJsonListInput',
241
+ description: 'Nested tree items. Use children for branches, lazy for async loading, and acceptsChildren: false to lock drops into a node.',
242
+ props: {
243
+ compact: true,
244
+ addLabel: '+ Add node',
245
+ schema: treeItemsFormSchema,
246
+ },
247
+ },
248
+ },
249
+ openValues: {
250
+ type: Array,
251
+ default: () => [],
252
+ _edit: { component: 'DomJsonInput', description: 'Controlled open branch values. When empty, item.open seeds the initial state.' },
253
+ },
254
+ hoveredValue: {
255
+ type: [String, Number],
256
+ default: '',
257
+ _edit: { description: 'Value of a node currently hovered from another surface, such as a stage canvas.' },
258
+ },
259
+ draggable: {
260
+ type: Boolean,
261
+ default: true,
262
+ _edit: { description: 'Allow nodes to be reordered by drag and drop.' },
263
+ },
264
+ externalDragValue: {
265
+ type: [String, Number],
266
+ default: '',
267
+ _edit: { description: 'Tree item value currently being dragged from another surface, such as a stage canvas.' },
268
+ },
269
+ externalDropTargetValue: {
270
+ type: [String, Number],
271
+ default: '',
272
+ _edit: { description: 'Tree item value currently being previewed as a drop target from another surface.' },
273
+ },
274
+ externalDropPosition: {
275
+ type: String,
276
+ default: '',
277
+ _edit: { options: ['before', 'inside', 'after'], description: 'Preview position for externalDropTargetValue.' },
278
+ },
279
+ externalDropTypes: {
280
+ type: Array,
281
+ default: () => [],
282
+ _edit: { component: 'DomJsonInput', description: 'DataTransfer MIME types accepted from outside the tree.' },
283
+ },
284
+ externalDropEffect: {
285
+ type: String,
286
+ default: 'copy',
287
+ _edit: { options: ['copy', 'move', 'link'], description: 'Drop effect shown for accepted external drags.' },
288
+ },
289
+ dragExpandDelay: {
290
+ type: Number,
291
+ default: 800,
292
+ _edit: { description: 'Delay in milliseconds before a collapsed branch opens while dragging over it. Set to 0 to disable.' },
293
+ },
294
+ canDropItem: {
295
+ type: Function,
296
+ default: null,
297
+ _edit: { description: 'Optional predicate for tree-item drops. Return false to block a move.' },
298
+ },
299
+ canDropExternal: {
300
+ type: Function,
301
+ default: null,
302
+ _edit: { description: 'Optional predicate for external drops. Return false to block the drop target.' },
303
+ },
304
+ scrollIntoView: {
305
+ type: Boolean,
306
+ default: true,
307
+ _edit: { description: 'Scroll the selected node into view when modelValue changes programmatically.' },
308
+ },
309
+ density: {
310
+ type: String,
311
+ default: 'compact',
312
+ _edit: { options: ['compact', 'comfortable'], description: 'Vertical spacing for tree rows.' },
313
+ },
314
+ toggleTransition: {
315
+ type: Boolean,
316
+ default: true,
317
+ _edit: { description: 'Animate rows in and out when branches open and close.' },
318
+ },
319
+ variant: {
320
+ type: String,
321
+ default: 'default',
322
+ _edit: { options: ['default', 'finder'], description: 'Visual treatment for the tree surface.' },
323
+ },
324
+ chrome: {
325
+ type: Boolean,
326
+ default: true,
327
+ _edit: { description: 'Render the outer border, padding, radius, and shadow around the tree.' },
328
+ },
329
+ label: {
330
+ type: String,
331
+ default: 'Tree view',
332
+ _edit: { description: 'Accessible name for the tree.' },
333
+ },
334
+ });
335
+
336
+ const emit = defineEmits(['update:modelValue', 'update:items', 'update:openValues', 'select', 'hover', 'hover-end', 'action', 'context-menu', 'toggle', 'load-children', 'drag-preview', 'drag-preview-end', 'reorder', 'external-drop']);
337
+ const activeValue = ref(props.modelValue);
338
+ const openSet = ref(new Set(seedTreeOpenValues(props.items, props.openValues)));
339
+ const dropTarget = ref(null);
340
+ const draggedValue = ref(null);
341
+ const liveMessage = ref('');
342
+ const treeId = `dom-tree-${useId()}`;
343
+ const rowRefs = new Map();
344
+ const treeItemDragType = 'application/x-dom-tree-item';
345
+ let dragExpandTimer = null;
346
+ let dragExpandValue = '';
347
+
348
+ const treeItems = computed(() => props.items || []);
349
+ const visibleNodes = computed(() => flattenTreeItems(treeItems.value, openSet.value));
350
+ const selectedValue = computed(() => props.modelValue);
351
+
352
+ // Local open state lets the tree work without a store, while update:openValues
353
+ // still gives larger apps a clean controlled-state path.
354
+ watch(() => props.items, (items) => {
355
+ openSet.value = new Set(seedTreeOpenValues(items, props.openValues, openSet.value));
356
+ if (!activeValue.value && visibleNodes.value[0]) activeValue.value = visibleNodes.value[0].value;
357
+ }, { deep: true });
358
+
359
+ watch(() => props.openValues, (values) => {
360
+ if (Array.isArray(values)) openSet.value = new Set(values.map(String));
361
+ }, { deep: true });
362
+
363
+ watch(() => props.modelValue, (value) => {
364
+ if (value === activeValue.value) return;
365
+ activeValue.value = value;
366
+ if (props.scrollIntoView) nextTick(() => scrollToNode(value));
367
+ });
368
+
369
+ function isLoading(item) {
370
+ return Boolean(item?.loading);
371
+ }
372
+
373
+ function iconPath(item) {
374
+ return item?.icon || defaultIconFor(item);
375
+ }
376
+
377
+ function defaultIconFor(item) {
378
+ if (treeItemHasChildren(item)) return 'M4 6h6l2 2h8v10H4V6Z';
379
+ return 'M6 4h8l4 4v12H6V4Zm8 0v5h5';
380
+ }
381
+
382
+ function slotNameOf(item) {
383
+ return item?.slot || 'item';
384
+ }
385
+
386
+ function rowActions(item) {
387
+ const actions = Array.isArray(item?.actions) ? item.actions : [];
388
+ if (item?.rightIcon) {
389
+ return [
390
+ ...actions,
391
+ { value: item.rightIconAction || 'right-icon', label: item.rightIconLabel || 'Item action', icon: item.rightIcon },
392
+ ];
393
+ }
394
+ return actions;
395
+ }
396
+
397
+ function setRowRef(value, element) {
398
+ if (element) rowRefs.set(String(value), element);
399
+ else rowRefs.delete(String(value));
400
+ }
401
+
402
+ /**
403
+ * Focus a tree row by value.
404
+ *
405
+ * Useful when another surface, such as a design stage, wants to move keyboard
406
+ * focus to the matching layer.
407
+ *
408
+ * @param {string|number} value
409
+ * @param {boolean} shouldScroll
410
+ */
411
+ function focusNode(value, shouldScroll = false) {
412
+ if (value == null) return;
413
+ activeValue.value = value;
414
+ nextTick(() => {
415
+ const row = rowRefs.get(String(value));
416
+ row?.focus();
417
+ if (shouldScroll) row?.scrollIntoView({ block: 'nearest' });
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Scroll a tree row into view by value without changing selection.
423
+ *
424
+ * @param {string|number} value
425
+ */
426
+ function scrollToNode(value) {
427
+ nextTick(() => rowRefs.get(String(value))?.scrollIntoView({ block: 'nearest' }));
428
+ }
429
+
430
+ function selectNode(node) {
431
+ if (node.item?.disabled) return;
432
+ activeValue.value = node.value;
433
+ emit('update:modelValue', node.value);
434
+ emit('select', { item: node.item, value: node.value, node });
435
+ }
436
+
437
+ function onRowPointerEnter(node) {
438
+ emit('hover', { item: node.item, value: node.value, node });
439
+ }
440
+
441
+ function onRowPointerLeave(node) {
442
+ emit('hover-end', { item: node.item, value: node.value, node });
443
+ }
444
+
445
+ function onActionClick(action, node) {
446
+ emit('action', {
447
+ action,
448
+ item: node.item,
449
+ value: node.value,
450
+ node,
451
+ });
452
+ }
453
+
454
+ function onRowContextMenu(event, node) {
455
+ event.preventDefault();
456
+ activeValue.value = node.value;
457
+ emit('context-menu', {
458
+ event,
459
+ item: node.item,
460
+ value: node.value,
461
+ node,
462
+ kind: 'item',
463
+ });
464
+ }
465
+
466
+ function onTreeContextMenu(event) {
467
+ event.preventDefault();
468
+ emit('context-menu', {
469
+ event,
470
+ item: null,
471
+ value: null,
472
+ node: null,
473
+ kind: 'empty',
474
+ });
475
+ }
476
+
477
+ function toggleNode(node, forceOpen = null) {
478
+ if (!node.expandable || node.item?.disabled) return;
479
+ const key = String(node.value);
480
+ const nextOpen = forceOpen ?? !openSet.value.has(key);
481
+ const next = new Set(openSet.value);
482
+ if (nextOpen) next.add(key);
483
+ else next.delete(key);
484
+ openSet.value = next;
485
+ emit('update:openValues', [...next]);
486
+ emit('toggle', { item: node.item, value: node.value, open: nextOpen });
487
+ if (nextOpen && node.item?.lazy && !treeItemChildren(node.item).length) {
488
+ emit('load-children', { item: node.item, value: node.value });
489
+ }
490
+ }
491
+
492
+ function onRowKeydown(event, node) {
493
+ const index = visibleNodes.value.findIndex((visible) => String(visible.value) === String(node.value));
494
+ if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
495
+ reorderByKeyboard(event, node, event.key === 'ArrowUp' ? -1 : 1);
496
+ } else if (event.key === 'ArrowDown') {
497
+ event.preventDefault();
498
+ focusNode(visibleNodes.value[Math.min(index + 1, visibleNodes.value.length - 1)]?.value, true);
499
+ } else if (event.key === 'ArrowUp') {
500
+ event.preventDefault();
501
+ focusNode(visibleNodes.value[Math.max(index - 1, 0)]?.value, true);
502
+ } else if (event.key === 'ArrowRight') {
503
+ event.preventDefault();
504
+ if (node.expandable && !node.open) toggleNode(node, true);
505
+ else if (node.expandable) focusNode(visibleNodes.value[index + 1]?.value, true);
506
+ } else if (event.key === 'ArrowLeft') {
507
+ event.preventDefault();
508
+ if (node.expandable && node.open) toggleNode(node, false);
509
+ else if (node.parent) focusNode(node.parent.value, true);
510
+ } else if (event.key === 'Home') {
511
+ event.preventDefault();
512
+ focusNode(visibleNodes.value[0]?.value, true);
513
+ } else if (event.key === 'End') {
514
+ event.preventDefault();
515
+ focusNode(visibleNodes.value.at(-1)?.value, true);
516
+ } else if (event.key === 'Enter' || event.key === ' ') {
517
+ event.preventDefault();
518
+ selectNode(node);
519
+ }
520
+ }
521
+
522
+ function reorderByKeyboard(event, node, direction) {
523
+ if (!dragAllowed(node)) return;
524
+ const index = visibleNodes.value.findIndex((visible) => String(visible.value) === String(node.value));
525
+ const target = visibleNodes.value[index + direction];
526
+ if (!target) return;
527
+ const position = direction < 0 ? 'before' : 'after';
528
+ if (!canDropTreeItem(String(node.value), target, position)) return;
529
+ event.preventDefault();
530
+ const result = moveTreeItem(treeItems.value, node.value, target.value, position);
531
+ if (!result) return;
532
+ emit('update:items', result.items);
533
+ emit('reorder', {
534
+ items: result.items,
535
+ item: result.item,
536
+ sourceValue: node.value,
537
+ sourceNode: node,
538
+ target: target.item,
539
+ targetValue: target.value,
540
+ targetNode: target,
541
+ position,
542
+ });
543
+ liveMessage.value = `Moved ${node.label} ${direction < 0 ? 'up' : 'down'}.`;
544
+ nextTick(() => focusNode(node.value, true));
545
+ }
546
+
547
+ function dragAllowed(node) {
548
+ return props.draggable && node.item?.draggable !== false && !node.item?.disabled;
549
+ }
550
+
551
+ function onDragStart(event, node) {
552
+ if (!dragAllowed(node)) {
553
+ event.preventDefault();
554
+ return;
555
+ }
556
+ event.dataTransfer.effectAllowed = 'move';
557
+ event.dataTransfer.setData(treeItemDragType, String(node.value));
558
+ event.dataTransfer.setData('text/plain', String(node.value));
559
+ draggedValue.value = String(node.value);
560
+ clearDropPreview();
561
+ }
562
+
563
+ function onDragOver(event, node) {
564
+ // The row is split into three drop zones: top = before, middle = child,
565
+ // bottom = after. Locked/non-container nodes collapse this to before/after.
566
+ const position = dropPositionFromEvent(event, node);
567
+ const sourceValue = treeDragSourceValue(event);
568
+ if (sourceValue && canDropTreeItem(sourceValue, node, position)) {
569
+ event.preventDefault();
570
+ event.dataTransfer.dropEffect = 'move';
571
+ updateDropPreview({ value: node.value, position, kind: 'tree' }, node);
572
+ scheduleDragExpand(node);
573
+ return;
574
+ }
575
+
576
+ if (!canDropExternalDrag(event, node, position)) {
577
+ clearDropPreviewForNode(node);
578
+ cancelDragExpand(node.value);
579
+ return;
580
+ }
581
+ event.preventDefault();
582
+ event.dataTransfer.dropEffect = props.externalDropEffect;
583
+ updateDropPreview({ value: node.value, position, kind: 'external' }, node);
584
+ scheduleDragExpand(node);
585
+ }
586
+
587
+ function onDragLeave(event, node) {
588
+ if (!event.currentTarget.contains(event.relatedTarget) && String(dropTarget.value?.value) === String(node.value)) {
589
+ clearDropPreviewForNode(node);
590
+ }
591
+ }
592
+
593
+ function onDrop(event, node) {
594
+ const target = dropTarget.value;
595
+ clearDropPreview();
596
+ const sourceValue = treeDragSourceValue(event);
597
+ draggedValue.value = null;
598
+ if (!target || String(target.value) !== String(node.value)) return;
599
+ event.preventDefault();
600
+ if (target.kind === 'external') {
601
+ emitExternalDrop(event, node, target.position);
602
+ return;
603
+ }
604
+ if (!sourceValue) return;
605
+ if (!canDropTreeItem(sourceValue, node, target.position)) return;
606
+ const result = moveTreeItem(treeItems.value, sourceValue, node.value, target.position);
607
+ if (!result) return;
608
+ if (target.position === 'inside') toggleNode(node, true);
609
+ emit('update:items', result.items);
610
+ emit('reorder', {
611
+ items: result.items,
612
+ item: result.item,
613
+ sourceValue,
614
+ sourceNode: visibleNodes.value.find((visible) => String(visible.value) === String(sourceValue)),
615
+ target: node.item,
616
+ targetValue: node.value,
617
+ targetNode: node,
618
+ position: target.position,
619
+ });
620
+ }
621
+
622
+ function onDragEnd() {
623
+ draggedValue.value = null;
624
+ clearDropPreview();
625
+ }
626
+
627
+ function updateDropPreview(target, node) {
628
+ const current = dropTarget.value;
629
+ if (
630
+ current
631
+ && String(current.value) === String(target.value)
632
+ && current.position === target.position
633
+ && current.kind === target.kind
634
+ ) {
635
+ return;
636
+ }
637
+ dropTarget.value = target;
638
+ emit('drag-preview', dragPreviewPayload(node, target));
639
+ }
640
+
641
+ function clearDropPreviewForNode(node) {
642
+ if (String(dropTarget.value?.value) !== String(node.value)) return;
643
+ clearDropPreview(node.value);
644
+ }
645
+
646
+ function clearDropPreview(value = null) {
647
+ if (value != null && String(dropTarget.value?.value) !== String(value)) return;
648
+ const target = dropTarget.value;
649
+ dropTarget.value = null;
650
+ cancelDragExpand(value);
651
+ if (!target) return;
652
+ emit('drag-preview-end', {
653
+ value: target.value,
654
+ position: target.position,
655
+ kind: target.kind,
656
+ });
657
+ }
658
+
659
+ function dragPreviewPayload(node, target) {
660
+ return {
661
+ item: node.item,
662
+ value: node.value,
663
+ node,
664
+ position: target.position,
665
+ kind: target.kind,
666
+ };
667
+ }
668
+
669
+ function dropPositionFromEvent(event, node) {
670
+ if (treeItemCanAcceptChildren(node.item)) {
671
+ const rect = event.currentTarget.getBoundingClientRect();
672
+ const y = event.clientY - rect.top;
673
+ if (y < rect.height * 0.25) return 'before';
674
+ if (y > rect.height * 0.75) return 'after';
675
+ return 'inside';
676
+ }
677
+ const rect = event.currentTarget.getBoundingClientRect();
678
+ return event.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
679
+ }
680
+
681
+ function treeDragSourceValue(event) {
682
+ if (draggedValue.value) return String(draggedValue.value);
683
+ if (props.externalDragValue !== '' && props.externalDragValue != null) return String(props.externalDragValue);
684
+ return safeGetData(event, treeItemDragType) || safeGetData(event, 'text/plain');
685
+ }
686
+
687
+ function canDropTreeItem(sourceValue, targetNode, position) {
688
+ if (position === 'inside' && !treeItemCanAcceptChildren(targetNode.item)) return false;
689
+ const source = visibleNodes.value.find((node) => String(node.value) === String(sourceValue));
690
+ if (!source) return false;
691
+ if (isSameOrDescendantPath(targetNode.path, source.path)) return false;
692
+ if (typeof props.canDropItem !== 'function') return true;
693
+ return props.canDropItem({
694
+ items: treeItems.value,
695
+ source: source.item,
696
+ sourceValue,
697
+ sourceNode: source,
698
+ target: targetNode.item,
699
+ targetValue: targetNode.value,
700
+ targetNode,
701
+ position,
702
+ }) !== false;
703
+ }
704
+
705
+ function canDropExternalDrag(event, targetNode, position) {
706
+ if (!hasAcceptedExternalDropType(event)) return false;
707
+ if (position === 'inside' && !treeItemCanAcceptChildren(targetNode.item)) return false;
708
+ if (typeof props.canDropExternal !== 'function') return true;
709
+ return props.canDropExternal({
710
+ event,
711
+ dataTransfer: event.dataTransfer,
712
+ types: dataTransferTypes(event),
713
+ item: targetNode.item,
714
+ value: targetNode.value,
715
+ node: targetNode,
716
+ position,
717
+ }) !== false;
718
+ }
719
+
720
+ function hasAcceptedExternalDropType(event) {
721
+ const accepted = props.externalDropTypes.map(String);
722
+ if (!accepted.length) return false;
723
+ const types = dataTransferTypes(event);
724
+ return accepted.some((type) => types.includes(type));
725
+ }
726
+
727
+ function dataTransferTypes(event) {
728
+ return Array.from(event.dataTransfer?.types || []);
729
+ }
730
+
731
+ function safeGetData(event, type) {
732
+ try {
733
+ return event.dataTransfer?.getData(type) || '';
734
+ } catch {
735
+ return '';
736
+ }
737
+ }
738
+
739
+ function emitExternalDrop(event, node, position) {
740
+ if (!canDropExternalDrag(event, node, position)) return;
741
+ emit('external-drop', {
742
+ event,
743
+ dataTransfer: event.dataTransfer,
744
+ types: dataTransferTypes(event),
745
+ getData: (type) => safeGetData(event, type),
746
+ item: node.item,
747
+ value: node.value,
748
+ node,
749
+ position,
750
+ });
751
+ }
752
+
753
+ function scheduleDragExpand(node) {
754
+ if (!props.dragExpandDelay || !node.expandable || node.open || node.item?.disabled) {
755
+ cancelDragExpand(node.value);
756
+ return;
757
+ }
758
+
759
+ const key = String(node.value);
760
+ if (dragExpandValue === key && dragExpandTimer) return;
761
+ cancelDragExpand();
762
+ dragExpandValue = key;
763
+ dragExpandTimer = window.setTimeout(() => {
764
+ dragExpandTimer = null;
765
+ if (String(dropTarget.value?.value) !== key || openSet.value.has(key)) return;
766
+ toggleNode(node, true);
767
+ liveMessage.value = `Expanded ${node.label}.`;
768
+ }, props.dragExpandDelay);
769
+ }
770
+
771
+ function cancelDragExpand(value = null) {
772
+ if (value != null && dragExpandValue && String(value) !== dragExpandValue) return;
773
+ if (dragExpandTimer) window.clearTimeout(dragExpandTimer);
774
+ dragExpandTimer = null;
775
+ dragExpandValue = '';
776
+ }
777
+
778
+ function dropStateForNode(node) {
779
+ if (String(dropTarget.value?.value) === String(node.value)) return dropTarget.value;
780
+ if (
781
+ props.externalDropTargetValue !== ''
782
+ && props.externalDropTargetValue != null
783
+ && String(props.externalDropTargetValue) === String(node.value)
784
+ ) {
785
+ return {
786
+ value: node.value,
787
+ position: normalizeDropPosition(props.externalDropPosition, 'inside'),
788
+ kind: 'external-preview',
789
+ };
790
+ }
791
+ return null;
792
+ }
793
+
794
+ function isDropPosition(node, position) {
795
+ return dropStateForNode(node)?.position === position;
796
+ }
797
+
798
+ function dropClass(node, position) {
799
+ if (!isDropPosition(node, position)) return '';
800
+ return position === 'inside'
801
+ ? 'bg-accent ring-1 ring-ring/40'
802
+ : '';
803
+ }
804
+
805
+ function normalizeDropPosition(position, fallback = 'inside') {
806
+ return ['before', 'inside', 'after'].includes(position) ? position : fallback;
807
+ }
808
+
809
+ defineExpose({
810
+ focusNode,
811
+ scrollToNode,
812
+ });
813
+
814
+ onBeforeUnmount(cancelDragExpand);
815
+ </script>
816
+
817
+ <template>
818
+ <div
819
+ role="tree"
820
+ :id="treeId"
821
+ class="w-full text-sm text-canvas-fg"
822
+ :class="[
823
+ chrome ? 'skin-card rounded-2xl p-1' : '',
824
+ ]"
825
+ :aria-label="label"
826
+ :aria-describedby="draggable ? `${treeId}-instructions` : undefined"
827
+ @contextmenu="onTreeContextMenu"
828
+ >
829
+ <p v-if="draggable" :id="`${treeId}-instructions`" class="sr-only">Use Alt plus Up or Down arrow to reorder the focused tree item.</p>
830
+ <p class="sr-only" aria-live="polite">{{ liveMessage }}</p>
831
+ <TransitionGroup
832
+ :name="toggleTransition ? 'dom-tree-slide' : null"
833
+ tag="div"
834
+ class="contents"
835
+ >
836
+ <div
837
+ v-for="node in visibleNodes"
838
+ :key="node.value"
839
+ :ref="(element) => setRowRef(node.value, element)"
840
+ role="treeitem"
841
+ :aria-level="node.depth"
842
+ :aria-expanded="node.expandable ? String(node.open) : null"
843
+ :aria-selected="String(String(selectedValue) === String(node.value))"
844
+ :aria-disabled="node.item?.disabled ? 'true' : null"
845
+ :data-hovered="String(hoveredValue) === String(node.value) && String(selectedValue) !== String(node.value) ? 'true' : null"
846
+ :tabindex="String(activeValue) === String(node.value) || (!activeValue && visibleNodes[0]?.value === node.value) ? 0 : -1"
847
+ :aria-keyshortcuts="draggable ? 'Alt+ArrowUp Alt+ArrowDown' : null"
848
+ :draggable="dragAllowed(node)"
849
+ class="group relative flex min-w-0 cursor-pointer items-center rounded-lg outline-none transition focus-visible:ring-2 focus-visible:ring-ring/50 aria-selected:bg-primary aria-selected:text-primary-fg"
850
+ :class="[
851
+ density === 'comfortable' ? 'min-h-4' : 'min-h-6',
852
+ variant === 'finder' ? 'hover:bg-canvas-fg/80 hover:text-canvas aria-selected:bg-accent aria-selected:text-accent-fg' : 'hover:bg-secondary',
853
+ dropClass(node, 'inside'),
854
+ isDropPosition(node, 'before') ? 'before:opacity-100' : '',
855
+ isDropPosition(node, 'after') ? 'after:opacity-100' : '',
856
+ String(hoveredValue) === String(node.value) && String(selectedValue) !== String(node.value) ? 'bg-secondary text-canvas-fg' : '',
857
+ 'before:pointer-events-none before:absolute before:inset-x-2 before:top-0 before:h-0.5 before:rounded-full before:bg-ring before:opacity-0 after:pointer-events-none after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full after:bg-ring after:opacity-0',
858
+ ]"
859
+ :style="{ paddingLeft: `${(node.depth - 1) * 1.25}rem` }"
860
+ @click="selectNode(node)"
861
+ @pointerenter="onRowPointerEnter(node)"
862
+ @pointerleave="onRowPointerLeave(node)"
863
+ @keydown="onRowKeydown($event, node)"
864
+ @dragstart="onDragStart($event, node)"
865
+ @dragover="onDragOver($event, node)"
866
+ @dragleave="onDragLeave($event, node)"
867
+ @drop="onDrop($event, node)"
868
+ @dragend="onDragEnd"
869
+ @focus="activeValue = node.value"
870
+ @contextmenu.stop="onRowContextMenu($event, node)"
871
+ >
872
+ <slot
873
+ name="row"
874
+ :item="node.item"
875
+ :node="node"
876
+ :depth="node.depth"
877
+ :open="node.open"
878
+ :selected="String(selectedValue) === String(node.value)"
879
+ :loading="node.loading"
880
+ :toggle="() => toggleNode(node)"
881
+ :select="() => selectNode(node)"
882
+ >
883
+ <button
884
+ v-if="node.expandable"
885
+ type="button"
886
+ class="grid size-7 shrink-0 cursor-pointer place-items-center rounded-lg transition group-aria-selected:text-primary-fg/80"
887
+ :tabindex="-1"
888
+ :aria-label="node.open ? 'Collapse item' : 'Expand item'"
889
+ @click.stop="toggleNode(node)"
890
+ >
891
+ <svg v-if="node.loading" viewBox="0 0 20 20" class="size-4 animate-spin" fill="none">
892
+ <circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="2" opacity=".25" />
893
+ <path d="M17 10a7 7 0 0 0-7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
894
+ </svg>
895
+ <svg v-else viewBox="0 0 20 20" class="size-4 transition" :class="node.open && 'rotate-90'" fill="none">
896
+ <path d="M8 5l5 5-5 5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
897
+ </svg>
898
+ </button>
899
+ <span v-else class="size-7 shrink-0" aria-hidden="true"></span>
900
+ <span class="grid size-6 shrink-0 place-items-center group-aria-selected:text-primary-fg/80">
901
+ <svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round">
902
+ <path :d="iconPath(node.item)" />
903
+ </svg>
904
+ </span>
905
+ <span class="min-w-0 flex-1 truncate px-1 py-1.5">
906
+ <slot :name="slotNameOf(node.item)" :item="node.item" :node="node" :depth="node.depth" :open="node.open" :selected="String(selectedValue) === String(node.value)" :loading="node.loading">
907
+ {{ node.label }}
908
+ </slot>
909
+ </span>
910
+ <span v-if="rowActions(node.item).length" class="mr-1 flex shrink-0 items-center gap-0.5 opacity-80 transition group-hover:opacity-100 group-focus-within:opacity-100">
911
+ <button
912
+ v-for="action in rowActions(node.item)"
913
+ :key="action.value || action.label"
914
+ type="button"
915
+ class="grid size-6 cursor-pointer place-items-center rounded-md text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 group-aria-selected:text-primary-fg/80 group-aria-selected:hover:bg-primary-fg/15"
916
+ :aria-label="action.label || action.value || 'Tree item action'"
917
+ :title="action.label || action.value || 'Tree item action'"
918
+ :disabled="action.disabled || undefined"
919
+ @click.stop="onActionClick(action, node)"
920
+ @mousedown.stop
921
+ >
922
+ <svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
923
+ <path :d="action.icon" />
924
+ </svg>
925
+ </button>
926
+ </span>
927
+ </slot>
928
+ </div>
929
+ </TransitionGroup>
930
+ </div>
931
+ </template>
932
+
933
+ <style>
934
+ .dom-tree-slide-enter-active,
935
+ .dom-tree-slide-leave-active {
936
+ overflow: hidden;
937
+ min-height: 0 !important;
938
+ transition:
939
+ max-height 260ms cubic-bezier(0.32, 0.72, 0, 1),
940
+ opacity 180ms ease,
941
+ transform 260ms cubic-bezier(0.32, 0.72, 0, 1);
942
+ }
943
+
944
+ .dom-tree-slide-move {
945
+ transition: transform 260ms cubic-bezier(0.32, 0.72, 0, 1);
946
+ }
947
+
948
+ .dom-tree-slide-enter-from,
949
+ .dom-tree-slide-leave-to {
950
+ max-height: 0;
951
+ opacity: 0;
952
+ transform: translateY(-0.35rem);
953
+ }
954
+
955
+ .dom-tree-slide-enter-to,
956
+ .dom-tree-slide-leave-from {
957
+ max-height: 2.75rem;
958
+ opacity: 1;
959
+ transform: translateY(0);
960
+ }
961
+ </style>