@aquiferre/ui-kit 0.4.0 → 0.4.2
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/components/DataTable.vue +7 -1
- package/components/SuggestInput.vue +230 -0
- package/components/Tree.vue +193 -0
- package/components/TreeNodeItem.vue +145 -0
- package/index.ts +3 -0
- package/package.json +1 -1
package/components/DataTable.vue
CHANGED
|
@@ -39,8 +39,9 @@
|
|
|
39
39
|
:data-testid="rowPrefix ? `${rowPrefix}-${row[rowKey] ?? index}` : undefined"
|
|
40
40
|
class="table-row"
|
|
41
41
|
@click="handleRowClick(row)"
|
|
42
|
+
@dblclick="handleRowDblClick(row)"
|
|
42
43
|
>
|
|
43
|
-
<td v-if="selectable" class="table-cell" @click.stop>
|
|
44
|
+
<td v-if="selectable" class="table-cell" @click.stop @dblclick.stop>
|
|
44
45
|
<input type="checkbox" :checked="isSelected(row)" :data-testid="rowPrefix ? `${rowPrefix}-checkbox-${row[rowKey] ?? index}` : undefined" @change="toggleRow(row)" />
|
|
45
46
|
</td>
|
|
46
47
|
<td
|
|
@@ -98,6 +99,7 @@ const rowPrefix = computed<string | undefined>(() => props.rowTestIdPrefix ?? ba
|
|
|
98
99
|
const emit = defineEmits<{
|
|
99
100
|
'update:selected': [ids: string[]]
|
|
100
101
|
'row-click': [row: any]
|
|
102
|
+
'row-dblclick': [row: any]
|
|
101
103
|
'sort-change': [payload: { field: string; order: 'asc' | 'desc' } | null]
|
|
102
104
|
}>()
|
|
103
105
|
|
|
@@ -148,4 +150,8 @@ function toggleRow(row: any) {
|
|
|
148
150
|
function handleRowClick(row: any) {
|
|
149
151
|
emit('row-click', row)
|
|
150
152
|
}
|
|
153
|
+
|
|
154
|
+
function handleRowDblClick(row: any) {
|
|
155
|
+
emit('row-dblclick', row)
|
|
156
|
+
}
|
|
151
157
|
</script>
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="rootRef" class="relative w-full" :data-testid="baseTestId">
|
|
3
|
+
<div class="flex items-center input w-full focus-within:border-accent-primary" :class="disabled ? 'opacity-60 cursor-not-allowed' : ''">
|
|
4
|
+
<input
|
|
5
|
+
ref="inputRef"
|
|
6
|
+
:value="modelValue"
|
|
7
|
+
:placeholder="placeholder"
|
|
8
|
+
:disabled="disabled"
|
|
9
|
+
class="flex-1 min-w-0 bg-transparent border-none outline-none text-input-text placeholder:text-input-placeholder"
|
|
10
|
+
:data-testid="baseTestId ? `${baseTestId}-input` : undefined"
|
|
11
|
+
type="text"
|
|
12
|
+
@input="onInput"
|
|
13
|
+
@focus="onFocus"
|
|
14
|
+
/>
|
|
15
|
+
<button
|
|
16
|
+
v-if="clearable && modelValue"
|
|
17
|
+
type="button"
|
|
18
|
+
class="px-2 text-text-tertiary hover:text-text-primary shrink-0"
|
|
19
|
+
:data-testid="baseTestId ? `${baseTestId}-clear` : undefined"
|
|
20
|
+
@click.stop="clear"
|
|
21
|
+
tabindex="-1"
|
|
22
|
+
>×</button>
|
|
23
|
+
<!-- 获取模型按钮(loadBtn):手动触发 loadOptions,点击后加载并展开下拉 -->
|
|
24
|
+
<button
|
|
25
|
+
v-if="loadBtn && loadOptions"
|
|
26
|
+
type="button"
|
|
27
|
+
class="px-2.5 py-1 m-1 text-xs rounded border border-accent-primary/40 text-accent-primary hover:bg-accent-primary/10 whitespace-nowrap shrink-0"
|
|
28
|
+
:disabled="loading"
|
|
29
|
+
:data-testid="baseTestId ? `${baseTestId}-load` : undefined"
|
|
30
|
+
@click.stop="onLoadBtnClick"
|
|
31
|
+
tabindex="-1"
|
|
32
|
+
>{{ loading ? '获取中...' : loadBtnText }}</button>
|
|
33
|
+
<!-- 无 loadBtn 时提供展开按钮(配合外部 options 模式) -->
|
|
34
|
+
<button
|
|
35
|
+
v-if="!loadBtn && loading"
|
|
36
|
+
type="button"
|
|
37
|
+
class="px-2 text-text-tertiary shrink-0"
|
|
38
|
+
disabled
|
|
39
|
+
></button>
|
|
40
|
+
<button
|
|
41
|
+
v-else-if="!loadBtn"
|
|
42
|
+
type="button"
|
|
43
|
+
class="px-2 text-text-tertiary hover:text-text-primary shrink-0"
|
|
44
|
+
:data-testid="baseTestId ? `${baseTestId}-toggle` : undefined"
|
|
45
|
+
@click.stop="toggle"
|
|
46
|
+
tabindex="-1"
|
|
47
|
+
>▾</button>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- 下拉弹层:absolute 定位跟随输入框(与 SearchSelect 一致);父容器勿设 overflow-hidden -->
|
|
51
|
+
<div
|
|
52
|
+
v-if="open"
|
|
53
|
+
class="absolute top-full left-0 mt-1 w-full max-h-60 overflow-auto card shadow-lg z-20 py-1"
|
|
54
|
+
:data-testid="baseTestId ? `${baseTestId}-dropdown` : undefined"
|
|
55
|
+
>
|
|
56
|
+
<!-- 初始加载中 -->
|
|
57
|
+
<div v-if="initialLoading" class="px-4 py-2 text-sm text-text-tertiary">获取中...</div>
|
|
58
|
+
<!-- 加载失败 -->
|
|
59
|
+
<div v-else-if="loadError" class="px-4 py-2">
|
|
60
|
+
<span class="text-sm text-accent-danger">{{ loadError }}</span>
|
|
61
|
+
<button class="ml-2 text-xs text-accent-primary hover:text-accent-primary-hover" @click="retryLoad">重试</button>
|
|
62
|
+
</div>
|
|
63
|
+
<!-- 无选项 -->
|
|
64
|
+
<div v-else-if="filtered.length === 0" class="px-4 py-2 text-sm text-text-tertiary">无匹配项</div>
|
|
65
|
+
<!-- 选项列表 -->
|
|
66
|
+
<div
|
|
67
|
+
v-for="opt in filtered"
|
|
68
|
+
:key="opt.value"
|
|
69
|
+
class="px-4 py-2 text-sm text-text-primary hover:bg-bg-hover cursor-pointer"
|
|
70
|
+
:class="opt.value === modelValue ? 'bg-bg-secondary' : ''"
|
|
71
|
+
:data-testid="optionTestIdPrefix ? `${optionTestIdPrefix}-option-${opt.value}` : undefined"
|
|
72
|
+
@mousedown.prevent="select(opt)"
|
|
73
|
+
>
|
|
74
|
+
{{ opt.label }}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
79
|
+
|
|
80
|
+
<script setup lang="ts">
|
|
81
|
+
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
82
|
+
import { match } from 'pinyin-pro'
|
|
83
|
+
import type { SelectOption } from '../types'
|
|
84
|
+
|
|
85
|
+
const props = withDefaults(defineProps<{
|
|
86
|
+
modelValue: string
|
|
87
|
+
options?: SelectOption[]
|
|
88
|
+
loadOptions?: () => Promise<SelectOption[]>
|
|
89
|
+
placeholder?: string
|
|
90
|
+
disabled?: boolean
|
|
91
|
+
clearable?: boolean
|
|
92
|
+
testId?: string
|
|
93
|
+
loadBtn?: boolean
|
|
94
|
+
loadBtnText?: string
|
|
95
|
+
}>(), {
|
|
96
|
+
options: () => [],
|
|
97
|
+
placeholder: '请输入或选择',
|
|
98
|
+
disabled: false,
|
|
99
|
+
clearable: true,
|
|
100
|
+
loadBtn: false,
|
|
101
|
+
loadBtnText: '获取',
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const emit = defineEmits<{
|
|
105
|
+
'update:modelValue': [value: string]
|
|
106
|
+
}>()
|
|
107
|
+
|
|
108
|
+
const baseTestId = computed(() => props.testId)
|
|
109
|
+
const optionTestIdPrefix = baseTestId
|
|
110
|
+
|
|
111
|
+
const rootRef = ref<HTMLElement | null>(null)
|
|
112
|
+
const inputRef = ref<HTMLInputElement | null>(null)
|
|
113
|
+
const open = ref(false)
|
|
114
|
+
const loading = ref(false)
|
|
115
|
+
const initialLoading = ref(false)
|
|
116
|
+
const loadError = ref('')
|
|
117
|
+
|
|
118
|
+
const innerOptions = ref<SelectOption[]>([])
|
|
119
|
+
|
|
120
|
+
// 合并外部 options 与异步加载的选项
|
|
121
|
+
const mergedOptions = computed(() => {
|
|
122
|
+
// 如果提供了 loadOptions,优先使用异步加载的结果
|
|
123
|
+
if (innerOptions.value.length) return innerOptions.value
|
|
124
|
+
return props.options
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const query = ref('')
|
|
128
|
+
|
|
129
|
+
const filtered = computed(() => {
|
|
130
|
+
const opts = mergedOptions.value
|
|
131
|
+
const q = query.value.trim()
|
|
132
|
+
if (!q) return opts
|
|
133
|
+
const ql = q.toLowerCase()
|
|
134
|
+
return opts.filter(o =>
|
|
135
|
+
o.label.toLowerCase().includes(ql) || match(o.label, q) !== null
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
async function loadAsync() {
|
|
140
|
+
if (!props.loadOptions) return
|
|
141
|
+
loadError.value = ''
|
|
142
|
+
initialLoading.value = true
|
|
143
|
+
loading.value = true
|
|
144
|
+
try {
|
|
145
|
+
const result = await props.loadOptions()
|
|
146
|
+
innerOptions.value = result
|
|
147
|
+
} catch (err: any) {
|
|
148
|
+
loadError.value = err?.message || '获取失败'
|
|
149
|
+
} finally {
|
|
150
|
+
initialLoading.value = false
|
|
151
|
+
loading.value = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function retryLoad() {
|
|
156
|
+
loadAsync()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onInput(e: Event) {
|
|
160
|
+
const value = (e.target as HTMLInputElement).value
|
|
161
|
+
emit('update:modelValue', value)
|
|
162
|
+
query.value = value
|
|
163
|
+
if (!open.value) open.value = true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function onFocus() {
|
|
167
|
+
if (props.disabled) return
|
|
168
|
+
// 有 loadBtn 时,展开聚焦不自动加载(需用户点击按钮)
|
|
169
|
+
if (!props.loadBtn && props.loadOptions && innerOptions.value.length === 0 && !loading.value) {
|
|
170
|
+
loadAsync()
|
|
171
|
+
}
|
|
172
|
+
open.value = true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toggle() {
|
|
176
|
+
if (props.disabled) return
|
|
177
|
+
if (open.value) {
|
|
178
|
+
open.value = false
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
// 有 loadBtn 时不自动加载
|
|
182
|
+
if (!props.loadBtn && props.loadOptions && innerOptions.value.length === 0) {
|
|
183
|
+
loadAsync()
|
|
184
|
+
}
|
|
185
|
+
open.value = true
|
|
186
|
+
nextTick(() => inputRef.value?.focus())
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function onLoadBtnClick() {
|
|
190
|
+
if (!props.loadOptions) return
|
|
191
|
+
await loadAsync()
|
|
192
|
+
open.value = true
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function select(opt: SelectOption) {
|
|
196
|
+
emit('update:modelValue', opt.value)
|
|
197
|
+
open.value = false
|
|
198
|
+
query.value = ''
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function clear() {
|
|
202
|
+
emit('update:modelValue', '')
|
|
203
|
+
open.value = false
|
|
204
|
+
query.value = ''
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 下拉关闭(外部点击 / Escape)
|
|
208
|
+
function close() {
|
|
209
|
+
open.value = false
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function handleOutside(e: MouseEvent) {
|
|
213
|
+
if (rootRef.value && !rootRef.value.contains(e.target as Node)) {
|
|
214
|
+
open.value = false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function onKeydown(e: KeyboardEvent) {
|
|
219
|
+
if (e.key === 'Escape') close()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
onMounted(() => {
|
|
223
|
+
document.addEventListener('mousedown', handleOutside)
|
|
224
|
+
document.addEventListener('keydown', onKeydown)
|
|
225
|
+
})
|
|
226
|
+
onUnmounted(() => {
|
|
227
|
+
document.removeEventListener('mousedown', handleOutside)
|
|
228
|
+
document.removeEventListener('keydown', onKeydown)
|
|
229
|
+
})
|
|
230
|
+
</script>
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="ui-tree" :data-testid="testId">
|
|
3
|
+
<div v-if="showSearch" class="mb-2">
|
|
4
|
+
<input
|
|
5
|
+
v-model="keyword"
|
|
6
|
+
type="text"
|
|
7
|
+
class="tree-search"
|
|
8
|
+
:placeholder="searchPlaceholder"
|
|
9
|
+
:data-testid="testId ? `${testId}-search` : undefined"
|
|
10
|
+
/>
|
|
11
|
+
</div>
|
|
12
|
+
<div v-if="visibleRoot.length === 0" class="tree-empty">
|
|
13
|
+
<p>{{ emptyText }}</p>
|
|
14
|
+
</div>
|
|
15
|
+
<div v-else class="tree-scroll">
|
|
16
|
+
<TreeNodeItem
|
|
17
|
+
v-for="node in visibleRoot"
|
|
18
|
+
:key="node.id"
|
|
19
|
+
:node="node"
|
|
20
|
+
:depth="0"
|
|
21
|
+
:selected-key="selectedKey"
|
|
22
|
+
:expanded-keys="expandedKeys"
|
|
23
|
+
:loading-keys="loadingKeys"
|
|
24
|
+
:load-children="loadChildren"
|
|
25
|
+
:test-id="testId"
|
|
26
|
+
@toggle="toggleNode"
|
|
27
|
+
@select="handleSelect"
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script setup lang="ts">
|
|
34
|
+
import { ref, computed, watch } from 'vue'
|
|
35
|
+
import TreeNodeItem from './TreeNodeItem.vue'
|
|
36
|
+
|
|
37
|
+
/** 通用树节点:业务字段(type 等)可透传附加;children 由组件懒加载后写入 */
|
|
38
|
+
export interface TreeNode {
|
|
39
|
+
id: string
|
|
40
|
+
label: string
|
|
41
|
+
children?: TreeNode[]
|
|
42
|
+
/** 叶子节点(不触发懒加载);缺省时无 children 且无 loadChildren 视为叶子 */
|
|
43
|
+
isLeaf?: boolean
|
|
44
|
+
[key: string]: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const props = withDefaults(
|
|
48
|
+
defineProps<{
|
|
49
|
+
/** 根级节点(顶层数据由调用方加载传入) */
|
|
50
|
+
nodes: TreeNode[]
|
|
51
|
+
/** 懒加载回调:展开节点时调用,返回其子节点(调用方封装数据请求) */
|
|
52
|
+
loadChildren?: (node: TreeNode) => Promise<TreeNode[]>
|
|
53
|
+
/** 选中节点 id(受控,父组件通过 select 事件维护) */
|
|
54
|
+
selectedKey?: string | null
|
|
55
|
+
/** 回显展开链:挂载时按链逐级展开并选中末端节点(祖先 id 顺序 + 选中 id) */
|
|
56
|
+
defaultExpandedKeys?: string[]
|
|
57
|
+
showSearch?: boolean
|
|
58
|
+
searchPlaceholder?: string
|
|
59
|
+
emptyText?: string
|
|
60
|
+
testId?: string
|
|
61
|
+
}>(),
|
|
62
|
+
{ showSearch: false, searchPlaceholder: '搜索...', emptyText: '暂无数据' }
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
(e: 'select', node: TreeNode | null): void
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
const keyword = ref('')
|
|
70
|
+
const expandedKeys = ref<Set<string>>(new Set())
|
|
71
|
+
const loadingKeys = ref<Set<string>>(new Set())
|
|
72
|
+
const selectedKey = ref<string | null>(props.selectedKey ?? null)
|
|
73
|
+
|
|
74
|
+
watch(
|
|
75
|
+
() => props.selectedKey,
|
|
76
|
+
(v) => {
|
|
77
|
+
if (v !== undefined) selectedKey.value = v
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/** 懒加载展开:写入 node.children(共享引用,调用方数据同步可见) */
|
|
82
|
+
async function expandNode(node: TreeNode) {
|
|
83
|
+
if (node.children?.length || node.isLeaf || !props.loadChildren) return
|
|
84
|
+
if (loadingKeys.value.has(node.id)) return
|
|
85
|
+
loadingKeys.value.add(node.id)
|
|
86
|
+
try {
|
|
87
|
+
node.children = await props.loadChildren(node)
|
|
88
|
+
} catch {
|
|
89
|
+
node.children = []
|
|
90
|
+
} finally {
|
|
91
|
+
loadingKeys.value.delete(node.id)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toggleNode(node: TreeNode) {
|
|
96
|
+
const next = new Set(expandedKeys.value)
|
|
97
|
+
if (next.has(node.id)) {
|
|
98
|
+
next.delete(node.id)
|
|
99
|
+
} else {
|
|
100
|
+
next.add(node.id)
|
|
101
|
+
expandNode(node)
|
|
102
|
+
}
|
|
103
|
+
expandedKeys.value = next
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleSelect(node: TreeNode) {
|
|
107
|
+
// 再次点击已选中节点 → 取消选择
|
|
108
|
+
if (selectedKey.value === node.id) {
|
|
109
|
+
selectedKey.value = null
|
|
110
|
+
emit('select', null)
|
|
111
|
+
} else {
|
|
112
|
+
selectedKey.value = node.id
|
|
113
|
+
emit('select', node)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 回显:按 defaultExpandedKeys 逐级展开(每级懒加载后定位下一节点),末端标记选中
|
|
118
|
+
watch(
|
|
119
|
+
() => props.defaultExpandedKeys,
|
|
120
|
+
async (keys) => {
|
|
121
|
+
if (!keys || keys.length === 0) return
|
|
122
|
+
let level = props.nodes
|
|
123
|
+
for (let i = 0; i < keys.length; i++) {
|
|
124
|
+
const target = level.find(n => n.id === keys[i])
|
|
125
|
+
if (!target) break
|
|
126
|
+
if (i < keys.length - 1) {
|
|
127
|
+
const next = new Set(expandedKeys.value)
|
|
128
|
+
next.add(target.id)
|
|
129
|
+
expandedKeys.value = next
|
|
130
|
+
await expandNode(target)
|
|
131
|
+
level = target.children || []
|
|
132
|
+
} else {
|
|
133
|
+
selectedKey.value = target.id
|
|
134
|
+
emit('select', target)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{ immediate: true }
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
/** 展示层过滤(不修改原数据):命中节点或其子树保留 */
|
|
142
|
+
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
|
143
|
+
const kw = keyword.value.trim()
|
|
144
|
+
if (!kw) return nodes
|
|
145
|
+
const filtered: TreeNode[] = []
|
|
146
|
+
for (const n of nodes) {
|
|
147
|
+
const kids = n.children ? filterNodes(n.children) : undefined
|
|
148
|
+
if (n.label.includes(kw) || (kids && kids.length > 0)) {
|
|
149
|
+
filtered.push(kids ? { ...n, children: kids } : n)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return filtered
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const visibleRoot = computed(() => filterNodes(props.nodes))
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<style scoped>
|
|
159
|
+
.ui-tree {
|
|
160
|
+
font-size: 13px;
|
|
161
|
+
color: var(--color-text-primary, #e2e8f0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.tree-search {
|
|
165
|
+
width: 100%;
|
|
166
|
+
padding: 6px 10px;
|
|
167
|
+
border-radius: 6px;
|
|
168
|
+
font-size: 13px;
|
|
169
|
+
background: var(--color-input-bg, rgba(15, 23, 42, 0.8));
|
|
170
|
+
color: var(--color-input-text, #e2e8f0);
|
|
171
|
+
border: 1px solid var(--color-border-primary, rgba(56, 189, 248, 0.3));
|
|
172
|
+
outline: none;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.tree-search::placeholder {
|
|
176
|
+
color: var(--color-input-placeholder, #64748b);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.tree-search:focus {
|
|
180
|
+
border-color: var(--color-accent-primary, #38bdf8);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.tree-empty {
|
|
184
|
+
padding: 20px 0;
|
|
185
|
+
text-align: center;
|
|
186
|
+
color: var(--color-text-tertiary, #94a3b8);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.tree-scroll {
|
|
190
|
+
max-height: 288px;
|
|
191
|
+
overflow-y: auto;
|
|
192
|
+
}
|
|
193
|
+
</style>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<div
|
|
4
|
+
class="tree-node"
|
|
5
|
+
:class="{ 'tree-node-selected': node.id === selectedKey }"
|
|
6
|
+
:style="{ paddingLeft: depth * 16 + 'px' }"
|
|
7
|
+
:data-testid="testId ? `${testId}-node-${node.id}` : undefined"
|
|
8
|
+
@click="emitSelect(node)"
|
|
9
|
+
>
|
|
10
|
+
<span class="tree-arrow" @click.stop="emitToggle(node)">
|
|
11
|
+
<span v-if="loading" class="tree-spinner"></span>
|
|
12
|
+
<span v-else-if="!expandable" class="tree-dot"></span>
|
|
13
|
+
<span v-else class="tree-caret" :class="{ 'tree-caret-open': expanded }">▸</span>
|
|
14
|
+
</span>
|
|
15
|
+
<span class="tree-label" :class="{ 'tree-label-selected': node.id === selectedKey }">{{ node.label }}</span>
|
|
16
|
+
</div>
|
|
17
|
+
<template v-if="expanded && node.children && node.children.length > 0">
|
|
18
|
+
<TreeNodeItem
|
|
19
|
+
v-for="child in node.children"
|
|
20
|
+
:key="child.id"
|
|
21
|
+
:node="child"
|
|
22
|
+
:depth="depth + 1"
|
|
23
|
+
:selected-key="selectedKey"
|
|
24
|
+
:expanded-keys="expandedKeys"
|
|
25
|
+
:loading-keys="loadingKeys"
|
|
26
|
+
:load-children="loadChildren"
|
|
27
|
+
:test-id="testId"
|
|
28
|
+
@toggle="emitToggle"
|
|
29
|
+
@select="emitSelect"
|
|
30
|
+
/>
|
|
31
|
+
</template>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { computed } from 'vue'
|
|
37
|
+
import type { TreeNode } from './Tree.vue'
|
|
38
|
+
|
|
39
|
+
const props = defineProps<{
|
|
40
|
+
node: TreeNode
|
|
41
|
+
depth: number
|
|
42
|
+
selectedKey: string | null
|
|
43
|
+
expandedKeys: Set<string>
|
|
44
|
+
loadingKeys: Set<string>
|
|
45
|
+
loadChildren?: (node: TreeNode) => Promise<TreeNode[]>
|
|
46
|
+
testId?: string
|
|
47
|
+
}>()
|
|
48
|
+
|
|
49
|
+
const emit = defineEmits<{
|
|
50
|
+
(e: 'toggle', node: TreeNode): void
|
|
51
|
+
(e: 'select', node: TreeNode): void
|
|
52
|
+
}>()
|
|
53
|
+
|
|
54
|
+
const expanded = computed(() => props.expandedKeys.has(props.node.id))
|
|
55
|
+
const loading = computed(() => props.loadingKeys.has(props.node.id))
|
|
56
|
+
const expandable = computed(() => {
|
|
57
|
+
if (props.node.isLeaf) return false
|
|
58
|
+
if (props.node.children && props.node.children.length > 0) return true
|
|
59
|
+
return !!props.loadChildren
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
function emitToggle(node: TreeNode) {
|
|
63
|
+
emit('toggle', node)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emitSelect(node: TreeNode) {
|
|
67
|
+
emit('select', node)
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<style scoped>
|
|
72
|
+
.tree-node {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 4px;
|
|
76
|
+
padding: 4px 6px;
|
|
77
|
+
border-radius: 6px;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
user-select: none;
|
|
80
|
+
transition: background 0.15s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.tree-node:hover {
|
|
84
|
+
background: var(--color-bg-tertiary, rgba(30, 41, 59, 0.6));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.tree-node-selected {
|
|
88
|
+
background-color: color-mix(in srgb, var(--color-accent-primary, #38bdf8) 15%, transparent);
|
|
89
|
+
outline: 1px solid color-mix(in srgb, var(--color-accent-primary, #38bdf8) 35%, transparent);
|
|
90
|
+
outline-offset: -1px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.tree-arrow {
|
|
94
|
+
display: inline-flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: center;
|
|
97
|
+
width: 16px;
|
|
98
|
+
height: 16px;
|
|
99
|
+
flex-shrink: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.tree-caret {
|
|
103
|
+
font-size: 11px;
|
|
104
|
+
color: var(--color-text-tertiary, #94a3b8);
|
|
105
|
+
transition: transform 0.15s;
|
|
106
|
+
display: inline-block;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.tree-caret-open {
|
|
110
|
+
transform: rotate(90deg);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.tree-dot {
|
|
114
|
+
width: 5px;
|
|
115
|
+
height: 5px;
|
|
116
|
+
border-radius: 50%;
|
|
117
|
+
background: var(--color-text-tertiary, #94a3b8);
|
|
118
|
+
opacity: 0.5;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.tree-spinner {
|
|
122
|
+
width: 12px;
|
|
123
|
+
height: 12px;
|
|
124
|
+
border: 2px solid var(--color-border-primary, rgba(56, 189, 248, 0.3));
|
|
125
|
+
border-top-color: var(--color-accent-primary, #38bdf8);
|
|
126
|
+
border-radius: 50%;
|
|
127
|
+
animation: tree-spin 0.8s linear infinite;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@keyframes tree-spin {
|
|
131
|
+
to { transform: rotate(360deg); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.tree-label {
|
|
135
|
+
color: var(--color-text-primary, #e2e8f0);
|
|
136
|
+
white-space: nowrap;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
text-overflow: ellipsis;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.tree-label-selected {
|
|
142
|
+
color: var(--color-accent-primary, #38bdf8);
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
}
|
|
145
|
+
</style>
|
package/index.ts
CHANGED
|
@@ -13,7 +13,10 @@ export { default as FileUploader } from './components/FileUploader.vue'
|
|
|
13
13
|
export { default as UserPicker } from './components/UserPicker.vue'
|
|
14
14
|
export { default as SearchSelect } from './components/SearchSelect.vue'
|
|
15
15
|
export { default as SideMenu } from './components/SideMenu.vue'
|
|
16
|
+
export { default as SuggestInput } from './components/SuggestInput.vue'
|
|
16
17
|
export { default as TopNavbar } from './components/TopNavbar.vue'
|
|
17
18
|
export { default as DateTimePicker } from './components/DateTimePicker.vue'
|
|
19
|
+
export { default as Tree } from './components/Tree.vue'
|
|
20
|
+
export type { TreeNode } from './components/Tree.vue'
|
|
18
21
|
export { useToast } from './composables/useToast'
|
|
19
22
|
export type { Column, SelectOption, MenuItem, MenuGroup, NavModule, DropdownItem, ToastType, DateTimePreset } from './types'
|