@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.
- package/LICENSE.md +109 -0
- package/README.md +44 -0
- package/SKILL.md +83 -0
- package/package.json +73 -0
- package/public/llms.txt +57 -0
- package/public/skills.sh +11 -0
- package/src/pages/_layout/componentCatalog.generated.js +4514 -0
- package/src/pages/_layout/componentInspector.js +60 -0
- package/src/pages/_layout/componentManager.js +192 -0
- package/src/pages/_layout/componentRuntimeRecords.js +49 -0
- package/src/pages/_layout/inspector/componentRegistry.js +639 -0
- package/src/pages/_layout/inspector/serialize.js +134 -0
- package/src/pages/_layout/inspector/useInspector.js +293 -0
- package/src/pages/components/accordion/DomAccordion.vue +39 -0
- package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
- package/src/pages/components/button/DomButton.vue +132 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
- package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
- package/src/pages/components/dialog/DomDialog.vue +271 -0
- package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
- package/src/pages/components/dialog/DomDialogStack.vue +289 -0
- package/src/pages/components/dialog/useDialogs.js +253 -0
- package/src/pages/components/drawer/DomDrawer.vue +159 -0
- package/src/pages/components/dropdown/DomDropdown.vue +261 -0
- package/src/pages/components/icon-button/DomIconButton.vue +155 -0
- package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
- package/src/pages/components/menu/DomMenu.vue +359 -0
- package/src/pages/components/menu/DomMenuItem.vue +233 -0
- package/src/pages/components/menu/DomMenuLabel.vue +19 -0
- package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
- package/src/pages/components/menu/menuContext.js +11 -0
- package/src/pages/components/menu/menuPlacement.js +26 -0
- package/src/pages/components/menu/submenu.js +58 -0
- package/src/pages/components/pagination/DomPagination.vue +265 -0
- package/src/pages/components/playground/DomRenderer.vue +48 -0
- package/src/pages/components/popover/DomPopover.vue +244 -0
- package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
- package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
- package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
- package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
- package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
- package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
- package/src/pages/components/stepper/DomStepper.vue +386 -0
- package/src/pages/components/tabs/DomTabs.vue +149 -0
- package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
- package/src/pages/components/toast/DomToastItem.vue +52 -0
- package/src/pages/components/toast/DomToastStack.vue +768 -0
- package/src/pages/components/toast/useToasts.js +237 -0
- package/src/pages/components/toolbar/DomToolbar.vue +158 -0
- package/src/pages/components/tooltip/DomTooltip.vue +69 -0
- package/src/pages/components/tooltip/vDomTooltip.js +106 -0
- package/src/pages/components/tree-view/DomTreeView.vue +961 -0
- package/src/pages/components/tree-view/treeSchemas.js +129 -0
- package/src/pages/components/tree-view/treeUtils.js +228 -0
- package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
- package/src/pages/data/_shared/resourceRecord.js +29 -0
- package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
- package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
- package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
- package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
- package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
- package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
- package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
- package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
- package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
- package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
- package/src/pages/data/kanban/DomKanban.vue +1703 -0
- package/src/pages/data/kanban/kanbanApi.js +80 -0
- package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
- package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
- package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
- package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
- package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
- package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
- package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
- package/src/pages/forms/_shared/options.js +61 -0
- package/src/pages/forms/_shared/useOptionMenu.js +59 -0
- package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
- package/src/pages/forms/calendar/DomCalendar.vue +442 -0
- package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
- package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
- package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
- package/src/pages/forms/color-input/DomColorInput.vue +118 -0
- package/src/pages/forms/combobox/DomCombobox.vue +237 -0
- package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
- package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
- package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
- package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
- package/src/pages/forms/field/DomField.vue +267 -0
- package/src/pages/forms/field/fieldLayout.js +1 -0
- package/src/pages/forms/field/fieldProps.js +114 -0
- package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
- package/src/pages/forms/field/useField.js +343 -0
- package/src/pages/forms/field/validators.js +364 -0
- package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
- package/src/pages/forms/form/DomForm.vue +985 -0
- package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
- package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
- package/src/pages/forms/form/adapters/shared.js +53 -0
- package/src/pages/forms/form/adapters/zod.js +235 -0
- package/src/pages/forms/form/formApi.js +38 -0
- package/src/pages/forms/form/formDefinition.js +392 -0
- package/src/pages/forms/form/schemaAdapter.js +54 -0
- package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
- package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
- package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
- package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
- package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
- package/src/pages/forms/listbox/DomListbox.vue +105 -0
- package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
- package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
- package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
- package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
- package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
- package/src/pages/forms/password-strength/passwordStrength.js +92 -0
- package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
- package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
- package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
- package/src/pages/forms/select/DomSelect.vue +479 -0
- package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
- package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
- package/src/pages/forms/text-input/DomTextInput.vue +48 -0
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
- package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
- package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
- package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
- package/src/pages/forms/time-selector/timeUtils.js +194 -0
- package/src/pages/forms/toggle/DomToggle.vue +158 -0
- package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
- package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
- package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
- package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
- package/src/pages/lib/css/color.js +245 -0
- package/src/pages/lib/headless/accordion.d.ts +7 -0
- package/src/pages/lib/headless/accordion.js +246 -0
- package/src/pages/lib/headless/autocomplete.d.ts +4 -0
- package/src/pages/lib/headless/autocomplete.js +33 -0
- package/src/pages/lib/headless/base.d.ts +25 -0
- package/src/pages/lib/headless/base.js +127 -0
- package/src/pages/lib/headless/checkbox.d.ts +19 -0
- package/src/pages/lib/headless/checkbox.js +99 -0
- package/src/pages/lib/headless/combobox.d.ts +7 -0
- package/src/pages/lib/headless/combobox.js +488 -0
- package/src/pages/lib/headless/dialog.d.ts +23 -0
- package/src/pages/lib/headless/dialog.js +384 -0
- package/src/pages/lib/headless/drawer.d.ts +21 -0
- package/src/pages/lib/headless/drawer.js +682 -0
- package/src/pages/lib/headless/dropdown.d.ts +19 -0
- package/src/pages/lib/headless/dropdown.js +412 -0
- package/src/pages/lib/headless/floating-effect.js +118 -0
- package/src/pages/lib/headless/floating.d.ts +34 -0
- package/src/pages/lib/headless/floating.js +382 -0
- package/src/pages/lib/headless/index.d.ts +19 -0
- package/src/pages/lib/headless/index.js +19 -0
- package/src/pages/lib/headless/listbox.d.ts +19 -0
- package/src/pages/lib/headless/listbox.js +126 -0
- package/src/pages/lib/headless/menu.d.ts +18 -0
- package/src/pages/lib/headless/menu.js +117 -0
- package/src/pages/lib/headless/popover-panel.d.ts +18 -0
- package/src/pages/lib/headless/popover-panel.js +309 -0
- package/src/pages/lib/headless/popover.d.ts +19 -0
- package/src/pages/lib/headless/popover.js +391 -0
- package/src/pages/lib/headless/radio-group.d.ts +19 -0
- package/src/pages/lib/headless/radio-group.js +100 -0
- package/src/pages/lib/headless/tabs.d.ts +18 -0
- package/src/pages/lib/headless/tabs.js +119 -0
- package/src/pages/lib/headless/toast.d.ts +13 -0
- package/src/pages/lib/headless/toast.js +65 -0
- package/src/pages/lib/headless/toggle.d.ts +19 -0
- package/src/pages/lib/headless/toggle.js +162 -0
- package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
- package/src/pages/lib/headless/tooltip-controller.js +549 -0
- package/src/pages/lib/headless/tooltip.d.ts +19 -0
- package/src/pages/lib/headless/tooltip.js +153 -0
- package/src/pages/lib/headless/transitions.d.ts +5 -0
- package/src/pages/lib/headless/transitions.js +89 -0
- package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
- package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
- package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
- package/src/pages/lib/vue/codeHighlighter.js +132 -0
- package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
- package/src/pages/lib/vue/index.d.ts +6806 -0
- package/src/pages/lib/vue/index.js +218 -0
- package/src/pages/lib/vue/useTheme.js +116 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
- package/src/pages/mobile/shell/DomAppShell.vue +51 -0
- package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
- package/src/pages/mobile/stack/DomAppStack.vue +711 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
- package/src/pages/visual/alert/DomAlert.vue +363 -0
- package/src/pages/visual/avatar/DomAvatar.vue +110 -0
- package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
- package/src/pages/visual/badge/DomBadge.vue +105 -0
- package/src/pages/visual/card/DomCard.vue +65 -0
- package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
- package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
- package/src/pages/visual/progress/DomProgress.vue +212 -0
- package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
- package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
- package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
- package/src/pages/visual/spinner/DomSpinner.vue +122 -0
- package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
- package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
- package/src/pages/visual/text-diff/textDiff.js +300 -0
- 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>
|