@svadmin/ui 0.60.1 → 0.64.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/dist/components/AutoTable.svelte +8 -0
- package/dist/components/AutoTable.svelte.d.ts +7 -1
- package/dist/components/Cascader.svelte +205 -0
- package/dist/components/Cascader.svelte.d.ts +21 -0
- package/dist/components/DynamicFormList.svelte +214 -0
- package/dist/components/DynamicFormList.svelte.d.ts +62 -0
- package/dist/components/FieldRenderer.svelte +32 -0
- package/dist/components/FilterBuilder.svelte +310 -0
- package/dist/components/FilterBuilder.svelte.d.ts +23 -0
- package/dist/components/InferencerPanel.svelte +66 -6
- package/dist/components/Transfer.svelte +352 -0
- package/dist/components/Transfer.svelte.d.ts +28 -0
- package/dist/components/TreeSelect.svelte +307 -0
- package/dist/components/TreeSelect.svelte.d.ts +22 -0
- package/dist/components/ui/navigation-menu/index.d.ts +11 -0
- package/dist/components/ui/navigation-menu/index.js +13 -0
- package/dist/components/ui/navigation-menu/navigation-menu-content.svelte +21 -0
- package/dist/components/ui/navigation-menu/navigation-menu-content.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte +22 -0
- package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-item.svelte +17 -0
- package/dist/components/ui/navigation-menu/navigation-menu-item.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-link.svelte +20 -0
- package/dist/components/ui/navigation-menu/navigation-menu-link.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-list.svelte +17 -0
- package/dist/components/ui/navigation-menu/navigation-menu-list.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte +28 -0
- package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte +22 -0
- package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu.svelte +24 -0
- package/dist/components/ui/navigation-menu/navigation-menu.svelte.d.ts +4 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/package.json +16 -16
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import { ChevronRight, ChevronLeft, ChevronsRight, ChevronsLeft, Search } from '@lucide/svelte';
|
|
5
|
+
import { cn } from '../utils.js';
|
|
6
|
+
import { Checkbox } from './ui/checkbox/index.js';
|
|
7
|
+
import { Button } from './ui/button/index.js';
|
|
8
|
+
import { Input } from './ui/input/index.js';
|
|
9
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
10
|
+
|
|
11
|
+
export interface TransferItem {
|
|
12
|
+
key: string | number;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const i18n = useTranslation();
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
dataSource?: TransferItem[];
|
|
22
|
+
targetKeys?: (string | number)[];
|
|
23
|
+
titles?: [string, string];
|
|
24
|
+
searchable?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
class?: string;
|
|
27
|
+
onchange?: (
|
|
28
|
+
nextTargetKeys: (string | number)[],
|
|
29
|
+
direction: 'left' | 'right',
|
|
30
|
+
moveKeys: (string | number)[]
|
|
31
|
+
) => void;
|
|
32
|
+
renderItem?: Snippet<[{ item: TransferItem; direction: 'left' | 'right' }]>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
dataSource = [],
|
|
37
|
+
targetKeys = $bindable([]),
|
|
38
|
+
titles = ['源列表', '目标列表'],
|
|
39
|
+
searchable = true,
|
|
40
|
+
disabled = false,
|
|
41
|
+
class: className,
|
|
42
|
+
onchange,
|
|
43
|
+
renderItem,
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
let leftSearch = $state('');
|
|
47
|
+
let rightSearch = $state('');
|
|
48
|
+
|
|
49
|
+
const leftSelected = new SvelteSet<string | number>();
|
|
50
|
+
const rightSelected = new SvelteSet<string | number>();
|
|
51
|
+
|
|
52
|
+
const targetKeySet = $derived(new Set(targetKeys));
|
|
53
|
+
|
|
54
|
+
const leftDataSource = $derived(
|
|
55
|
+
dataSource.filter((item) => !targetKeySet.has(item.key))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const rightDataSource = $derived(
|
|
59
|
+
dataSource.filter((item) => targetKeySet.has(item.key))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const filteredLeft = $derived(
|
|
63
|
+
leftDataSource.filter((item) => {
|
|
64
|
+
if (!leftSearch.trim()) return true;
|
|
65
|
+
const q = leftSearch.toLowerCase();
|
|
66
|
+
return item.title.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q));
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const filteredRight = $derived(
|
|
71
|
+
rightDataSource.filter((item) => {
|
|
72
|
+
if (!rightSearch.trim()) return true;
|
|
73
|
+
const q = rightSearch.toLowerCase();
|
|
74
|
+
return item.title.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q));
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function toggleLeftSelect(key: string | number) {
|
|
79
|
+
if (leftSelected.has(key)) leftSelected.delete(key);
|
|
80
|
+
else leftSelected.add(key);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toggleRightSelect(key: string | number) {
|
|
84
|
+
if (rightSelected.has(key)) rightSelected.delete(key);
|
|
85
|
+
else rightSelected.add(key);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toggleAllLeft() {
|
|
89
|
+
const selectable = filteredLeft.filter((i) => !i.disabled);
|
|
90
|
+
const allSelected = selectable.length > 0 && selectable.every((i) => leftSelected.has(i.key));
|
|
91
|
+
if (allSelected) {
|
|
92
|
+
for (const item of selectable) leftSelected.delete(item.key);
|
|
93
|
+
} else {
|
|
94
|
+
for (const item of selectable) leftSelected.add(item.key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toggleAllRight() {
|
|
99
|
+
const selectable = filteredRight.filter((i) => !i.disabled);
|
|
100
|
+
const allSelected = selectable.length > 0 && selectable.every((i) => rightSelected.has(i.key));
|
|
101
|
+
if (allSelected) {
|
|
102
|
+
for (const item of selectable) rightSelected.delete(item.key);
|
|
103
|
+
} else {
|
|
104
|
+
for (const item of selectable) rightSelected.add(item.key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function moveToRight() {
|
|
109
|
+
if (disabled) return;
|
|
110
|
+
const moveKeys = Array.from(leftSelected).filter((key) =>
|
|
111
|
+
leftDataSource.some((item) => item.key === key && !item.disabled)
|
|
112
|
+
);
|
|
113
|
+
if (moveKeys.length === 0) return;
|
|
114
|
+
|
|
115
|
+
const next = [...targetKeys, ...moveKeys];
|
|
116
|
+
targetKeys = next;
|
|
117
|
+
leftSelected.clear();
|
|
118
|
+
onchange?.(next, 'right', moveKeys);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function moveToLeft() {
|
|
122
|
+
if (disabled) return;
|
|
123
|
+
const moveKeys = Array.from(rightSelected).filter((key) =>
|
|
124
|
+
rightDataSource.some((item) => item.key === key && !item.disabled)
|
|
125
|
+
);
|
|
126
|
+
if (moveKeys.length === 0) return;
|
|
127
|
+
|
|
128
|
+
const moveKeySet = new Set(moveKeys);
|
|
129
|
+
const next = targetKeys.filter((k) => !moveKeySet.has(k));
|
|
130
|
+
targetKeys = next;
|
|
131
|
+
rightSelected.clear();
|
|
132
|
+
onchange?.(next, 'left', moveKeys);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function moveAllToRight() {
|
|
136
|
+
if (disabled) return;
|
|
137
|
+
const moveKeys = leftDataSource.filter((i) => !i.disabled).map((i) => i.key);
|
|
138
|
+
if (moveKeys.length === 0) return;
|
|
139
|
+
const next = [...targetKeys, ...moveKeys];
|
|
140
|
+
targetKeys = next;
|
|
141
|
+
leftSelected.clear();
|
|
142
|
+
onchange?.(next, 'right', moveKeys);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function moveAllToLeft() {
|
|
146
|
+
if (disabled) return;
|
|
147
|
+
const moveKeys = rightDataSource.filter((i) => !i.disabled).map((i) => i.key);
|
|
148
|
+
if (moveKeys.length === 0) return;
|
|
149
|
+
const moveKeySet = new Set(moveKeys);
|
|
150
|
+
const next = targetKeys.filter((k) => !moveKeySet.has(k));
|
|
151
|
+
targetKeys = next;
|
|
152
|
+
rightSelected.clear();
|
|
153
|
+
onchange?.(next, 'left', moveKeys);
|
|
154
|
+
}
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<div class={cn('flex flex-col sm:flex-row items-center gap-3 w-full', className)} data-testid="transfer">
|
|
158
|
+
<!-- Left Panel -->
|
|
159
|
+
<div class="flex-1 w-full rounded-lg border border-border bg-card shadow-xs flex flex-col h-72">
|
|
160
|
+
<div class="flex items-center justify-between border-b border-border/80 px-3 py-2 bg-muted/30">
|
|
161
|
+
<div class="flex items-center gap-2">
|
|
162
|
+
<Checkbox
|
|
163
|
+
checked={filteredLeft.length > 0 && filteredLeft.filter((i) => !i.disabled).every((i) => leftSelected.has(i.key))}
|
|
164
|
+
disabled={disabled || filteredLeft.filter((i) => !i.disabled).length === 0}
|
|
165
|
+
onCheckedChange={toggleAllLeft}
|
|
166
|
+
/>
|
|
167
|
+
<span class="text-xs font-medium text-foreground">{titles[0]}</span>
|
|
168
|
+
</div>
|
|
169
|
+
<span class="text-[11px] text-muted-foreground tabular-nums">
|
|
170
|
+
{leftSelected.size} / {leftDataSource.length}
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{#if searchable}
|
|
175
|
+
<div class="p-2 border-b border-border/40">
|
|
176
|
+
<div class="relative">
|
|
177
|
+
<Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
178
|
+
<Input
|
|
179
|
+
type="text"
|
|
180
|
+
bind:value={leftSearch}
|
|
181
|
+
placeholder={i18n.t('common.search', undefined) ?? '搜索...'}
|
|
182
|
+
class="h-8 pl-8 pr-2 text-xs"
|
|
183
|
+
{disabled}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
{/if}
|
|
188
|
+
|
|
189
|
+
<div class="flex-1 overflow-y-auto p-1 space-y-0.5">
|
|
190
|
+
{#each filteredLeft as item (item.key)}
|
|
191
|
+
{@const selected = leftSelected.has(item.key)}
|
|
192
|
+
<div
|
|
193
|
+
role="button"
|
|
194
|
+
tabindex="0"
|
|
195
|
+
class={cn(
|
|
196
|
+
'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors cursor-pointer select-none',
|
|
197
|
+
selected ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted/50 text-foreground',
|
|
198
|
+
item.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
|
199
|
+
)}
|
|
200
|
+
onclick={() => toggleLeftSelect(item.key)}
|
|
201
|
+
onkeydown={(e) => {
|
|
202
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
toggleLeftSelect(item.key);
|
|
205
|
+
}
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<Checkbox
|
|
209
|
+
checked={selected}
|
|
210
|
+
disabled={disabled || item.disabled}
|
|
211
|
+
onCheckedChange={() => toggleLeftSelect(item.key)}
|
|
212
|
+
/>
|
|
213
|
+
<div class="min-w-0 flex-1">
|
|
214
|
+
{#if renderItem}
|
|
215
|
+
{@render renderItem({ item, direction: 'left' })}
|
|
216
|
+
{:else}
|
|
217
|
+
<div class="truncate">{item.title}</div>
|
|
218
|
+
{#if item.description}
|
|
219
|
+
<div class="truncate text-[10px] text-muted-foreground">{item.description}</div>
|
|
220
|
+
{/if}
|
|
221
|
+
{/if}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
{:else}
|
|
225
|
+
<div class="py-10 text-center text-xs text-muted-foreground">
|
|
226
|
+
{i18n.t('common.noData', undefined) ?? '无项目'}
|
|
227
|
+
</div>
|
|
228
|
+
{/each}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<!-- Middle Action Buttons -->
|
|
233
|
+
<div class="flex sm:flex-col gap-1.5 shrink-0 justify-center">
|
|
234
|
+
<Button
|
|
235
|
+
type="button"
|
|
236
|
+
variant="outline"
|
|
237
|
+
size="icon-sm"
|
|
238
|
+
disabled={disabled || leftSelected.size === 0}
|
|
239
|
+
onclick={moveToRight}
|
|
240
|
+
title="转移所选项"
|
|
241
|
+
>
|
|
242
|
+
<ChevronRight class="h-4 w-4" />
|
|
243
|
+
</Button>
|
|
244
|
+
|
|
245
|
+
<Button
|
|
246
|
+
type="button"
|
|
247
|
+
variant="outline"
|
|
248
|
+
size="icon-sm"
|
|
249
|
+
disabled={disabled || rightSelected.size === 0}
|
|
250
|
+
onclick={moveToLeft}
|
|
251
|
+
title="移除所选项"
|
|
252
|
+
>
|
|
253
|
+
<ChevronLeft class="h-4 w-4" />
|
|
254
|
+
</Button>
|
|
255
|
+
|
|
256
|
+
<Button
|
|
257
|
+
type="button"
|
|
258
|
+
variant="ghost"
|
|
259
|
+
size="icon-sm"
|
|
260
|
+
disabled={disabled || leftDataSource.length === 0}
|
|
261
|
+
onclick={moveAllToRight}
|
|
262
|
+
title="全部转移"
|
|
263
|
+
>
|
|
264
|
+
<ChevronsRight class="h-4 w-4" />
|
|
265
|
+
</Button>
|
|
266
|
+
|
|
267
|
+
<Button
|
|
268
|
+
type="button"
|
|
269
|
+
variant="ghost"
|
|
270
|
+
size="icon-sm"
|
|
271
|
+
disabled={disabled || rightDataSource.length === 0}
|
|
272
|
+
onclick={moveAllToLeft}
|
|
273
|
+
title="全部移除"
|
|
274
|
+
>
|
|
275
|
+
<ChevronsLeft class="h-4 w-4" />
|
|
276
|
+
</Button>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<!-- Right Panel -->
|
|
280
|
+
<div class="flex-1 w-full rounded-lg border border-border bg-card shadow-xs flex flex-col h-72">
|
|
281
|
+
<div class="flex items-center justify-between border-b border-border/80 px-3 py-2 bg-muted/30">
|
|
282
|
+
<div class="flex items-center gap-2">
|
|
283
|
+
<Checkbox
|
|
284
|
+
checked={filteredRight.length > 0 && filteredRight.filter((i) => !i.disabled).every((i) => rightSelected.has(i.key))}
|
|
285
|
+
disabled={disabled || filteredRight.filter((i) => !i.disabled).length === 0}
|
|
286
|
+
onCheckedChange={toggleAllRight}
|
|
287
|
+
/>
|
|
288
|
+
<span class="text-xs font-medium text-foreground">{titles[1]}</span>
|
|
289
|
+
</div>
|
|
290
|
+
<span class="text-[11px] text-muted-foreground tabular-nums">
|
|
291
|
+
{rightSelected.size} / {rightDataSource.length}
|
|
292
|
+
</span>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{#if searchable}
|
|
296
|
+
<div class="p-2 border-b border-border/40">
|
|
297
|
+
<div class="relative">
|
|
298
|
+
<Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
299
|
+
<Input
|
|
300
|
+
type="text"
|
|
301
|
+
bind:value={rightSearch}
|
|
302
|
+
placeholder={i18n.t('common.search', undefined) ?? '搜索...'}
|
|
303
|
+
class="h-8 pl-8 pr-2 text-xs"
|
|
304
|
+
{disabled}
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
{/if}
|
|
309
|
+
|
|
310
|
+
<div class="flex-1 overflow-y-auto p-1 space-y-0.5">
|
|
311
|
+
{#each filteredRight as item (item.key)}
|
|
312
|
+
{@const selected = rightSelected.has(item.key)}
|
|
313
|
+
<div
|
|
314
|
+
role="button"
|
|
315
|
+
tabindex="0"
|
|
316
|
+
class={cn(
|
|
317
|
+
'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors cursor-pointer select-none',
|
|
318
|
+
selected ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted/50 text-foreground',
|
|
319
|
+
item.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
|
320
|
+
)}
|
|
321
|
+
onclick={() => toggleRightSelect(item.key)}
|
|
322
|
+
onkeydown={(e) => {
|
|
323
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
toggleRightSelect(item.key);
|
|
326
|
+
}
|
|
327
|
+
}}
|
|
328
|
+
>
|
|
329
|
+
<Checkbox
|
|
330
|
+
checked={selected}
|
|
331
|
+
disabled={disabled || item.disabled}
|
|
332
|
+
onCheckedChange={() => toggleRightSelect(item.key)}
|
|
333
|
+
/>
|
|
334
|
+
<div class="min-w-0 flex-1">
|
|
335
|
+
{#if renderItem}
|
|
336
|
+
{@render renderItem({ item, direction: 'right' })}
|
|
337
|
+
{:else}
|
|
338
|
+
<div class="truncate">{item.title}</div>
|
|
339
|
+
{#if item.description}
|
|
340
|
+
<div class="truncate text-[10px] text-muted-foreground">{item.description}</div>
|
|
341
|
+
{/if}
|
|
342
|
+
{/if}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
{:else}
|
|
346
|
+
<div class="py-10 text-center text-xs text-muted-foreground">
|
|
347
|
+
{i18n.t('common.noData', undefined) ?? '无项目'}
|
|
348
|
+
</div>
|
|
349
|
+
{/each}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export interface TransferItem {
|
|
3
|
+
key: string | number;
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
dataSource?: TransferItem[];
|
|
10
|
+
targetKeys?: (string | number)[];
|
|
11
|
+
titles?: [string, string];
|
|
12
|
+
searchable?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
class?: string;
|
|
15
|
+
onchange?: (nextTargetKeys: (string | number)[], direction: 'left' | 'right', moveKeys: (string | number)[]) => void;
|
|
16
|
+
renderItem?: Snippet<[{
|
|
17
|
+
item: TransferItem;
|
|
18
|
+
direction: 'left' | 'right';
|
|
19
|
+
}]>;
|
|
20
|
+
}
|
|
21
|
+
declare const Transfer: import("svelte").Component<Props, {
|
|
22
|
+
moveToRight: () => void;
|
|
23
|
+
moveToLeft: () => void;
|
|
24
|
+
moveAllToRight: () => void;
|
|
25
|
+
moveAllToLeft: () => void;
|
|
26
|
+
}, "targetKeys">;
|
|
27
|
+
type Transfer = ReturnType<typeof Transfer>;
|
|
28
|
+
export default Transfer;
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
3
|
+
import { ChevronRight, ChevronDown, Check, X, Search, ChevronsUpDown } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../utils.js';
|
|
5
|
+
import * as Popover from './ui/popover/index.js';
|
|
6
|
+
import { Input } from './ui/input/index.js';
|
|
7
|
+
import { Badge } from './ui/badge/index.js';
|
|
8
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
9
|
+
|
|
10
|
+
export interface TreeSelectOption {
|
|
11
|
+
value: string | number;
|
|
12
|
+
label: string;
|
|
13
|
+
children?: TreeSelectOption[];
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
expanded?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const i18n = useTranslation();
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
options?: TreeSelectOption[];
|
|
22
|
+
value?: string | number | (string | number)[];
|
|
23
|
+
multiple?: boolean;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
searchable?: boolean;
|
|
26
|
+
onlyLeafSelectable?: boolean;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
allowClear?: boolean;
|
|
29
|
+
class?: string;
|
|
30
|
+
onchange?: (value: string | number | (string | number)[] | undefined) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
options = [],
|
|
35
|
+
value = $bindable(undefined),
|
|
36
|
+
multiple = false,
|
|
37
|
+
placeholder,
|
|
38
|
+
searchable = true,
|
|
39
|
+
onlyLeafSelectable = false,
|
|
40
|
+
disabled = false,
|
|
41
|
+
allowClear = true,
|
|
42
|
+
class: className,
|
|
43
|
+
onchange,
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
let open = $state(false);
|
|
47
|
+
let searchQuery = $state('');
|
|
48
|
+
let expandedNodes = new SvelteSet<string | number>();
|
|
49
|
+
|
|
50
|
+
// Map to quickly look up option info by value
|
|
51
|
+
const optionMap = $derived.by(() => {
|
|
52
|
+
const map = new Map<string | number, TreeSelectOption>();
|
|
53
|
+
function traverse(nodes: TreeSelectOption[]) {
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
map.set(node.value, node);
|
|
56
|
+
if (node.children) traverse(node.children);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
traverse(options);
|
|
60
|
+
return map;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Selected values array normalized
|
|
64
|
+
const selectedValues = $derived.by< (string | number)[]>(() => {
|
|
65
|
+
if (value === undefined || value === null || value === '') return [];
|
|
66
|
+
return Array.isArray(value) ? value : [value];
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const selectedLabels = $derived.by(() => {
|
|
70
|
+
return selectedValues.map((v) => optionMap.get(v)?.label ?? String(v));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function toggleExpand(nodeValue: string | number, event?: MouseEvent) {
|
|
74
|
+
event?.stopPropagation();
|
|
75
|
+
if (expandedNodes.has(nodeValue)) {
|
|
76
|
+
expandedNodes.delete(nodeValue);
|
|
77
|
+
} else {
|
|
78
|
+
expandedNodes.add(nodeValue);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isNodeExpanded(node: TreeSelectOption): boolean {
|
|
83
|
+
if (searchQuery.trim()) return true; // Auto expand all on search
|
|
84
|
+
return expandedNodes.has(node.value) || !!node.expanded;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isSelected(val: string | number): boolean {
|
|
88
|
+
return selectedValues.includes(val);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handleSelect(node: TreeSelectOption) {
|
|
92
|
+
if (node.disabled) return;
|
|
93
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
94
|
+
if (onlyLeafSelectable && hasChildren) {
|
|
95
|
+
toggleExpand(node.value);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (multiple) {
|
|
100
|
+
let next: (string | number)[];
|
|
101
|
+
if (isSelected(node.value)) {
|
|
102
|
+
next = selectedValues.filter((v) => v !== node.value);
|
|
103
|
+
} else {
|
|
104
|
+
next = [...selectedValues, node.value];
|
|
105
|
+
}
|
|
106
|
+
value = next;
|
|
107
|
+
onchange?.(next);
|
|
108
|
+
} else {
|
|
109
|
+
const next = node.value;
|
|
110
|
+
value = next;
|
|
111
|
+
onchange?.(next);
|
|
112
|
+
open = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clearAll(event?: MouseEvent) {
|
|
117
|
+
event?.stopPropagation();
|
|
118
|
+
const next = multiple ? [] : undefined;
|
|
119
|
+
value = next;
|
|
120
|
+
onchange?.(next);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function removeSingle(val: string | number, event?: MouseEvent) {
|
|
124
|
+
event?.stopPropagation();
|
|
125
|
+
if (multiple) {
|
|
126
|
+
const next = selectedValues.filter((v) => v !== val);
|
|
127
|
+
value = next;
|
|
128
|
+
onchange?.(next);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function filterTree(nodes: TreeSelectOption[], query: string): TreeSelectOption[] {
|
|
133
|
+
const q = query.trim().toLowerCase();
|
|
134
|
+
if (!q) return nodes;
|
|
135
|
+
|
|
136
|
+
const filtered: TreeSelectOption[] = [];
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
const matchSelf = node.label.toLowerCase().includes(q);
|
|
139
|
+
const filteredChildren = node.children ? filterTree(node.children, q) : undefined;
|
|
140
|
+
const matchChildren = filteredChildren && filteredChildren.length > 0;
|
|
141
|
+
|
|
142
|
+
if (matchSelf || matchChildren) {
|
|
143
|
+
filtered.push({
|
|
144
|
+
...node,
|
|
145
|
+
children: filteredChildren ?? node.children,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return filtered;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const visibleOptions = $derived(filterTree(options, searchQuery));
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
{#snippet treeNode(node: TreeSelectOption, level: number)}
|
|
156
|
+
{@const hasChildren = Boolean(node.children && node.children.length > 0)}
|
|
157
|
+
{@const expanded = isNodeExpanded(node)}
|
|
158
|
+
{@const selected = isSelected(node.value)}
|
|
159
|
+
{@const selectable = !node.disabled && (!onlyLeafSelectable || !hasChildren)}
|
|
160
|
+
|
|
161
|
+
<div class="flex flex-col">
|
|
162
|
+
<div
|
|
163
|
+
role="treeitem"
|
|
164
|
+
tabindex="0"
|
|
165
|
+
aria-selected={selected}
|
|
166
|
+
aria-expanded={hasChildren ? expanded : undefined}
|
|
167
|
+
class={cn(
|
|
168
|
+
'group flex items-center justify-between gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors cursor-pointer select-none',
|
|
169
|
+
selected ? 'bg-primary/10 text-primary font-medium' : 'hover:bg-muted/60 text-foreground',
|
|
170
|
+
node.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
|
171
|
+
)}
|
|
172
|
+
style="padding-left: {level * 16 + 8}px;"
|
|
173
|
+
onclick={() => {
|
|
174
|
+
if (selectable) handleSelect(node);
|
|
175
|
+
}}
|
|
176
|
+
onkeydown={(e) => {
|
|
177
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
if (selectable) handleSelect(node);
|
|
180
|
+
}
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<div class="flex min-w-0 items-center gap-1.5">
|
|
184
|
+
{#if hasChildren}
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
class="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted"
|
|
188
|
+
onclick={(e) => toggleExpand(node.value, e)}
|
|
189
|
+
>
|
|
190
|
+
{#if expanded}
|
|
191
|
+
<ChevronDown class="h-3.5 w-3.5" />
|
|
192
|
+
{:else}
|
|
193
|
+
<ChevronRight class="h-3.5 w-3.5" />
|
|
194
|
+
{/if}
|
|
195
|
+
</button>
|
|
196
|
+
{:else}
|
|
197
|
+
<span class="inline-block h-4 w-4 shrink-0"></span>
|
|
198
|
+
{/if}
|
|
199
|
+
|
|
200
|
+
<span class="truncate">{node.label}</span>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{#if selected}
|
|
204
|
+
<Check class="h-3.5 w-3.5 shrink-0 text-primary" />
|
|
205
|
+
{/if}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{#if hasChildren && expanded && node.children}
|
|
209
|
+
<div role="group" class="flex flex-col">
|
|
210
|
+
{#each node.children as child (child.value)}
|
|
211
|
+
{@render treeNode(child, level + 1)}
|
|
212
|
+
{/each}
|
|
213
|
+
</div>
|
|
214
|
+
{/if}
|
|
215
|
+
</div>
|
|
216
|
+
{/snippet}
|
|
217
|
+
|
|
218
|
+
<div class={cn('relative w-full', className)} data-testid="tree-select">
|
|
219
|
+
<Popover.Root bind:open>
|
|
220
|
+
<Popover.Trigger class="w-full">
|
|
221
|
+
{#snippet child({ props })}
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
{...props}
|
|
225
|
+
{disabled}
|
|
226
|
+
class={cn(
|
|
227
|
+
'flex min-h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-1.5 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-left',
|
|
228
|
+
open && 'ring-2 ring-ring ring-offset-2'
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
<div class="flex flex-wrap items-center gap-1 min-w-0 flex-1">
|
|
232
|
+
{#if selectedValues.length === 0}
|
|
233
|
+
<span class="text-muted-foreground">
|
|
234
|
+
{placeholder ?? (i18n.t('field.selectPlaceholder', undefined) ?? '请选择...')}
|
|
235
|
+
</span>
|
|
236
|
+
{:else if multiple}
|
|
237
|
+
{#each selectedValues as val (val)}
|
|
238
|
+
{@const opt = optionMap.get(val)}
|
|
239
|
+
<Badge variant="secondary" class="h-5 px-1.5 py-0 text-[11px] gap-1 shrink-0">
|
|
240
|
+
<span>{opt?.label ?? val}</span>
|
|
241
|
+
{#if !disabled}
|
|
242
|
+
<span
|
|
243
|
+
role="button"
|
|
244
|
+
tabindex="0"
|
|
245
|
+
class="hover:text-destructive cursor-pointer"
|
|
246
|
+
onclick={(e) => removeSingle(val, e)}
|
|
247
|
+
onkeydown={(e) => {
|
|
248
|
+
if (e.key === 'Enter') removeSingle(val, e as never);
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<X class="h-3 w-3" />
|
|
252
|
+
</span>
|
|
253
|
+
{/if}
|
|
254
|
+
</Badge>
|
|
255
|
+
{/each}
|
|
256
|
+
{:else}
|
|
257
|
+
<span class="truncate text-foreground font-normal">
|
|
258
|
+
{selectedLabels[0] ?? ''}
|
|
259
|
+
</span>
|
|
260
|
+
{/if}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="flex items-center gap-1 shrink-0 ml-1 text-muted-foreground">
|
|
264
|
+
{#if allowClear && selectedValues.length > 0 && !disabled}
|
|
265
|
+
<span
|
|
266
|
+
role="button"
|
|
267
|
+
tabindex="0"
|
|
268
|
+
class="hover:text-foreground cursor-pointer p-0.5"
|
|
269
|
+
onclick={clearAll}
|
|
270
|
+
onkeydown={(e) => {
|
|
271
|
+
if (e.key === 'Enter') clearAll(e as never);
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
<X class="h-3.5 w-3.5" />
|
|
275
|
+
</span>
|
|
276
|
+
{/if}
|
|
277
|
+
<ChevronsUpDown class="h-3.5 w-3.5 opacity-50" />
|
|
278
|
+
</div>
|
|
279
|
+
</button>
|
|
280
|
+
{/snippet}
|
|
281
|
+
</Popover.Trigger>
|
|
282
|
+
|
|
283
|
+
<Popover.Content class="w-(--bits-popover-anchor-width) min-w-[220px] max-w-[400px] p-2" align="start">
|
|
284
|
+
{#if searchable}
|
|
285
|
+
<div class="relative mb-2">
|
|
286
|
+
<Search class="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
|
287
|
+
<Input
|
|
288
|
+
type="text"
|
|
289
|
+
bind:value={searchQuery}
|
|
290
|
+
placeholder={i18n.t('common.search', undefined) ?? '搜索...'}
|
|
291
|
+
class="h-8 pl-8 pr-2 text-xs"
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
{/if}
|
|
295
|
+
|
|
296
|
+
<div role="tree" class="max-h-60 overflow-y-auto space-y-0.5 pr-1">
|
|
297
|
+
{#each visibleOptions as rootNode (rootNode.value)}
|
|
298
|
+
{@render treeNode(rootNode, 0)}
|
|
299
|
+
{:else}
|
|
300
|
+
<div class="py-6 text-center text-xs text-muted-foreground">
|
|
301
|
+
{i18n.t('common.noData', undefined) ?? '无匹配选项'}
|
|
302
|
+
</div>
|
|
303
|
+
{/each}
|
|
304
|
+
</div>
|
|
305
|
+
</Popover.Content>
|
|
306
|
+
</Popover.Root>
|
|
307
|
+
</div>
|