@svadmin/ui 0.63.0 → 0.65.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/ActivityFeed.svelte +135 -0
- package/dist/components/ActivityFeed.svelte.d.ts +21 -0
- package/dist/components/ApprovalActionCard.svelte +248 -0
- package/dist/components/ApprovalActionCard.svelte.d.ts +22 -0
- package/dist/components/AutoTable.svelte +8 -0
- package/dist/components/AutoTable.svelte.d.ts +7 -1
- package/dist/components/CanvasAnnotation.svelte +389 -0
- package/dist/components/CanvasAnnotation.svelte.d.ts +17 -0
- package/dist/components/Cascader.svelte +205 -0
- package/dist/components/Cascader.svelte.d.ts +21 -0
- package/dist/components/ColumnHeaderFilter.svelte +178 -0
- package/dist/components/ColumnHeaderFilter.svelte.d.ts +12 -0
- package/dist/components/ColumnSettings.svelte +198 -0
- package/dist/components/ColumnSettings.svelte.d.ts +19 -0
- package/dist/components/DecisionTable.svelte +240 -0
- package/dist/components/DecisionTable.svelte.d.ts +22 -0
- package/dist/components/DraggableRowTable.svelte +117 -0
- package/dist/components/DraggableRowTable.svelte.d.ts +15 -0
- package/dist/components/DrawerForm.svelte +110 -26
- package/dist/components/DrawerForm.svelte.d.ts +20 -8
- package/dist/components/DynamicFormList.svelte +214 -0
- package/dist/components/DynamicFormList.svelte.d.ts +62 -0
- package/dist/components/EditableTable.svelte +195 -0
- package/dist/components/EditableTable.svelte.d.ts +25 -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/GanttChart.svelte +144 -0
- package/dist/components/GanttChart.svelte.d.ts +20 -0
- package/dist/components/ImageCropper.svelte +122 -0
- package/dist/components/ImageCropper.svelte.d.ts +12 -0
- package/dist/components/ImportWizard.svelte +336 -0
- package/dist/components/ImportWizard.svelte.d.ts +12 -0
- package/dist/components/JsonSchemaForm.svelte +149 -0
- package/dist/components/JsonSchemaForm.svelte.d.ts +10 -0
- package/dist/components/KanbanBoard.svelte +212 -0
- package/dist/components/KanbanBoard.svelte.d.ts +30 -0
- package/dist/components/MasterDetailView.svelte +134 -0
- package/dist/components/MasterDetailView.svelte.d.ts +16 -0
- package/dist/components/MediaLibraryModal.svelte +195 -0
- package/dist/components/MediaLibraryModal.svelte.d.ts +22 -0
- package/dist/components/MentionsInput.svelte +174 -0
- package/dist/components/MentionsInput.svelte.d.ts +21 -0
- package/dist/components/ModalForm.svelte +113 -28
- package/dist/components/ModalForm.svelte.d.ts +20 -8
- package/dist/components/MultiTabKeepAlive.svelte +143 -0
- package/dist/components/MultiTabKeepAlive.svelte.d.ts +21 -0
- package/dist/components/OfflineSyncBanner.svelte +172 -0
- package/dist/components/OfflineSyncBanner.svelte.d.ts +19 -0
- package/dist/components/PdfDocumentViewer.svelte +255 -0
- package/dist/components/PdfDocumentViewer.svelte.d.ts +22 -0
- package/dist/components/PivotTable.svelte +140 -0
- package/dist/components/PivotTable.svelte.d.ts +16 -0
- package/dist/components/PresenceAvatarGroup.svelte +89 -0
- package/dist/components/PresenceAvatarGroup.svelte.d.ts +15 -0
- package/dist/components/PrintableBill.svelte +149 -0
- package/dist/components/PrintableBill.svelte.d.ts +24 -0
- package/dist/components/SensitiveDataMask.svelte +125 -0
- package/dist/components/SensitiveDataMask.svelte.d.ts +13 -0
- package/dist/components/SignaturePad.svelte +264 -0
- package/dist/components/SignaturePad.svelte.d.ts +17 -0
- package/dist/components/SplitPaneLayout.svelte +113 -0
- package/dist/components/SplitPaneLayout.svelte.d.ts +13 -0
- package/dist/components/SpreadsheetView.svelte +363 -0
- package/dist/components/SpreadsheetView.svelte.d.ts +18 -0
- package/dist/components/StepForm.svelte +180 -0
- package/dist/components/StepForm.svelte.d.ts +23 -0
- package/dist/components/TableSummary.svelte +110 -0
- package/dist/components/TableSummary.svelte.d.ts +20 -0
- 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/TreeTable.svelte +237 -0
- package/dist/components/TreeTable.svelte.d.ts +28 -0
- package/dist/components/UnsavedChangesPrompt.svelte +44 -0
- package/dist/components/UnsavedChangesPrompt.svelte.d.ts +7 -0
- package/dist/components/VersionDiffViewer.svelte +196 -0
- package/dist/components/VersionDiffViewer.svelte.d.ts +12 -0
- package/dist/components/VirtualTable.svelte +86 -54
- package/dist/components/VirtualTable.svelte.d.ts +14 -21
- package/dist/components/Watermark.svelte +166 -0
- package/dist/components/Watermark.svelte.d.ts +26 -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 +68 -3
- package/dist/index.js +42 -3
- package/package.json +2 -2
|
@@ -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>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface TreeSelectOption {
|
|
2
|
+
value: string | number;
|
|
3
|
+
label: string;
|
|
4
|
+
children?: TreeSelectOption[];
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
expanded?: boolean;
|
|
7
|
+
}
|
|
8
|
+
interface Props {
|
|
9
|
+
options?: TreeSelectOption[];
|
|
10
|
+
value?: string | number | (string | number)[];
|
|
11
|
+
multiple?: boolean;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
searchable?: boolean;
|
|
14
|
+
onlyLeafSelectable?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
allowClear?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
onchange?: (value: string | number | (string | number)[] | undefined) => void;
|
|
19
|
+
}
|
|
20
|
+
declare const TreeSelect: import("svelte").Component<Props, {}, "value">;
|
|
21
|
+
type TreeSelect = ReturnType<typeof TreeSelect>;
|
|
22
|
+
export default TreeSelect;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { ChevronRight, ChevronDown, Folder, File } from '@lucide/svelte';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import { Checkbox } from './ui/checkbox/index.js';
|
|
6
|
+
import { cn } from '../utils.js';
|
|
7
|
+
|
|
8
|
+
export interface TreeTableColumn {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
width?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
data: Record<string, unknown>[];
|
|
16
|
+
columns: TreeTableColumn[];
|
|
17
|
+
primaryKey?: string;
|
|
18
|
+
childrenKey?: string;
|
|
19
|
+
selectable?: boolean;
|
|
20
|
+
selectedKeys?: (string | number)[];
|
|
21
|
+
onselect?: (selectedKeys: (string | number)[]) => void;
|
|
22
|
+
rowActions?: Snippet<[{ record: Record<string, unknown>; id: string | number }]>;
|
|
23
|
+
customCell?: Snippet<[{ column: TreeTableColumn; record: Record<string, unknown>; value: unknown }]>;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
data = [],
|
|
29
|
+
columns = [],
|
|
30
|
+
primaryKey = 'id',
|
|
31
|
+
childrenKey = 'children',
|
|
32
|
+
selectable = false,
|
|
33
|
+
selectedKeys = $bindable([]),
|
|
34
|
+
onselect,
|
|
35
|
+
rowActions,
|
|
36
|
+
customCell,
|
|
37
|
+
class: className = '',
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
let expandedKeys = $state<Set<string | number>>(new Set());
|
|
41
|
+
|
|
42
|
+
interface FlatTreeItem {
|
|
43
|
+
record: Record<string, unknown>;
|
|
44
|
+
id: string | number;
|
|
45
|
+
level: number;
|
|
46
|
+
hasChildren: boolean;
|
|
47
|
+
isExpanded: boolean;
|
|
48
|
+
isLastChild?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function flattenTree(items: Record<string, unknown>[], level = 0): FlatTreeItem[] {
|
|
52
|
+
const flat: FlatTreeItem[] = [];
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const id = item[primaryKey] as string | number;
|
|
55
|
+
const children = item[childrenKey] as Record<string, unknown>[] | undefined;
|
|
56
|
+
const hasChildren = Array.isArray(children) && children.length > 0;
|
|
57
|
+
const isExpanded = expandedKeys.has(id);
|
|
58
|
+
|
|
59
|
+
flat.push({
|
|
60
|
+
record: item,
|
|
61
|
+
id,
|
|
62
|
+
level,
|
|
63
|
+
hasChildren,
|
|
64
|
+
isExpanded,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (hasChildren && isExpanded && children) {
|
|
68
|
+
flat.push(...flattenTree(children, level + 1));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return flat;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const flattenedRows = $derived(flattenTree(data));
|
|
75
|
+
|
|
76
|
+
function toggleExpand(id: string | number) {
|
|
77
|
+
const next = new Set(expandedKeys);
|
|
78
|
+
if (next.has(id)) {
|
|
79
|
+
next.delete(id);
|
|
80
|
+
} else {
|
|
81
|
+
next.add(id);
|
|
82
|
+
}
|
|
83
|
+
expandedKeys = next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function expandAll() {
|
|
87
|
+
const all = new Set<string | number>();
|
|
88
|
+
function collect(items: Record<string, unknown>[]) {
|
|
89
|
+
for (const item of items) {
|
|
90
|
+
const id = item[primaryKey] as string | number;
|
|
91
|
+
const children = item[childrenKey] as Record<string, unknown>[] | undefined;
|
|
92
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
93
|
+
all.add(id);
|
|
94
|
+
collect(children);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
collect(data);
|
|
99
|
+
expandedKeys = all;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function collapseAll() {
|
|
103
|
+
expandedKeys = new Set();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getAllDescendantIds(item: Record<string, unknown>): (string | number)[] {
|
|
107
|
+
const ids: (string | number)[] = [item[primaryKey] as string | number];
|
|
108
|
+
const children = item[childrenKey] as Record<string, unknown>[] | undefined;
|
|
109
|
+
if (Array.isArray(children)) {
|
|
110
|
+
for (const child of children) {
|
|
111
|
+
ids.push(...getAllDescendantIds(child));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return ids;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toggleSelect(item: Record<string, unknown>) {
|
|
118
|
+
const descendantIds = getAllDescendantIds(item);
|
|
119
|
+
const isSelected = selectedKeys.includes(item[primaryKey] as string | number);
|
|
120
|
+
|
|
121
|
+
let next: (string | number)[];
|
|
122
|
+
if (isSelected) {
|
|
123
|
+
next = selectedKeys.filter((k) => !descendantIds.includes(k));
|
|
124
|
+
} else {
|
|
125
|
+
next = Array.from(new Set([...selectedKeys, ...descendantIds]));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
selectedKeys = next;
|
|
129
|
+
onselect?.(next);
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<div class={cn('w-full space-y-2', className)}>
|
|
134
|
+
<div class="flex items-center justify-between px-1 py-1 text-xs">
|
|
135
|
+
<div class="flex items-center gap-2">
|
|
136
|
+
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" onclick={expandAll}>
|
|
137
|
+
Expand All
|
|
138
|
+
</Button>
|
|
139
|
+
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground" onclick={collapseAll}>
|
|
140
|
+
Collapse All
|
|
141
|
+
</Button>
|
|
142
|
+
</div>
|
|
143
|
+
{#if selectable && selectedKeys.length > 0}
|
|
144
|
+
<span class="text-xs text-muted-foreground">
|
|
145
|
+
Selected: <strong class="text-foreground">{selectedKeys.length}</strong>
|
|
146
|
+
</span>
|
|
147
|
+
{/if}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div class="rounded-lg border border-border/80 overflow-hidden bg-card">
|
|
151
|
+
<table class="w-full text-left text-xs border-collapse">
|
|
152
|
+
<thead class="bg-muted/50 border-b border-border/60 font-semibold text-muted-foreground">
|
|
153
|
+
<tr>
|
|
154
|
+
{#if selectable}
|
|
155
|
+
<th class="w-8 px-3 py-2 text-center"></th>
|
|
156
|
+
{/if}
|
|
157
|
+
{#each columns as col (col.key)}
|
|
158
|
+
<th class="px-3 py-2" style={col.width ? `width: ${col.width}` : ''}>
|
|
159
|
+
{col.label}
|
|
160
|
+
</th>
|
|
161
|
+
{/each}
|
|
162
|
+
{#if rowActions}
|
|
163
|
+
<th class="w-20 px-3 py-2 text-right">Actions</th>
|
|
164
|
+
{/if}
|
|
165
|
+
</tr>
|
|
166
|
+
</thead>
|
|
167
|
+
<tbody class="divide-y divide-border/40">
|
|
168
|
+
{#each flattenedRows as row (row.id)}
|
|
169
|
+
{@const isSelected = selectedKeys.includes(row.id)}
|
|
170
|
+
<tr class="hover:bg-muted/30 transition-colors {isSelected ? 'bg-primary/5' : ''}">
|
|
171
|
+
{#if selectable}
|
|
172
|
+
<td class="w-8 px-3 py-2 text-center">
|
|
173
|
+
<Checkbox
|
|
174
|
+
checked={isSelected}
|
|
175
|
+
onCheckedChange={() => toggleSelect(row.record)}
|
|
176
|
+
aria-label="Select {row.id}"
|
|
177
|
+
/>
|
|
178
|
+
</td>
|
|
179
|
+
{/if}
|
|
180
|
+
|
|
181
|
+
{#each columns as col, colIdx (col.key)}
|
|
182
|
+
{@const val = row.record[col.key]}
|
|
183
|
+
<td class="px-3 py-2">
|
|
184
|
+
{#if colIdx === 0}
|
|
185
|
+
<!-- Primary tree node column with indent -->
|
|
186
|
+
<div
|
|
187
|
+
class="flex items-center gap-1.5"
|
|
188
|
+
style="padding-left: {row.level * 20}px;"
|
|
189
|
+
>
|
|
190
|
+
{#if row.hasChildren}
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
class="h-5 w-5 flex items-center justify-center rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
194
|
+
onclick={() => toggleExpand(row.id)}
|
|
195
|
+
aria-label={row.isExpanded ? 'Collapse' : 'Expand'}
|
|
196
|
+
>
|
|
197
|
+
{#if row.isExpanded}
|
|
198
|
+
<ChevronDown class="h-3.5 w-3.5" />
|
|
199
|
+
{:else}
|
|
200
|
+
<ChevronRight class="h-3.5 w-3.5" />
|
|
201
|
+
{/if}
|
|
202
|
+
</button>
|
|
203
|
+
<Folder class="h-3.5 w-3.5 text-primary/70 shrink-0" />
|
|
204
|
+
{:else}
|
|
205
|
+
<span class="w-5 inline-block"></span>
|
|
206
|
+
<File class="h-3.5 w-3.5 text-muted-foreground/50 shrink-0" />
|
|
207
|
+
{/if}
|
|
208
|
+
|
|
209
|
+
<span class="font-medium text-foreground truncate">
|
|
210
|
+
{val ?? '—'}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
{:else if customCell}
|
|
214
|
+
{@render customCell({ column: col, record: row.record, value: val })}
|
|
215
|
+
{:else}
|
|
216
|
+
<span class="text-foreground truncate">{val ?? '—'}</span>
|
|
217
|
+
{/if}
|
|
218
|
+
</td>
|
|
219
|
+
{/each}
|
|
220
|
+
|
|
221
|
+
{#if rowActions}
|
|
222
|
+
<td class="w-20 px-3 py-2 text-right">
|
|
223
|
+
{@render rowActions({ record: row.record, id: row.id })}
|
|
224
|
+
</td>
|
|
225
|
+
{/if}
|
|
226
|
+
</tr>
|
|
227
|
+
{:else}
|
|
228
|
+
<tr>
|
|
229
|
+
<td colspan={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} class="py-8 text-center text-muted-foreground">
|
|
230
|
+
No data available
|
|
231
|
+
</td>
|
|
232
|
+
</tr>
|
|
233
|
+
{/each}
|
|
234
|
+
</tbody>
|
|
235
|
+
</table>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export interface TreeTableColumn {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
width?: string;
|
|
6
|
+
}
|
|
7
|
+
interface Props {
|
|
8
|
+
data: Record<string, unknown>[];
|
|
9
|
+
columns: TreeTableColumn[];
|
|
10
|
+
primaryKey?: string;
|
|
11
|
+
childrenKey?: string;
|
|
12
|
+
selectable?: boolean;
|
|
13
|
+
selectedKeys?: (string | number)[];
|
|
14
|
+
onselect?: (selectedKeys: (string | number)[]) => void;
|
|
15
|
+
rowActions?: Snippet<[{
|
|
16
|
+
record: Record<string, unknown>;
|
|
17
|
+
id: string | number;
|
|
18
|
+
}]>;
|
|
19
|
+
customCell?: Snippet<[{
|
|
20
|
+
column: TreeTableColumn;
|
|
21
|
+
record: Record<string, unknown>;
|
|
22
|
+
value: unknown;
|
|
23
|
+
}]>;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
declare const TreeTable: import("svelte").Component<Props, {}, "selectedKeys">;
|
|
27
|
+
type TreeTable = ReturnType<typeof TreeTable>;
|
|
28
|
+
export default TreeTable;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
dirty: boolean;
|
|
7
|
+
message?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { dirty, message }: Props = $props();
|
|
11
|
+
|
|
12
|
+
const i18n = useTranslation();
|
|
13
|
+
const promptMessage = $derived(
|
|
14
|
+
message ?? i18n.t('common.warnUnsavedChanges', { defaultValue: 'You have unsaved changes. Are you sure you want to leave?' })
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
|
18
|
+
if (dirty) {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
e.returnValue = promptMessage;
|
|
21
|
+
return promptMessage;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
$effect(() => {
|
|
26
|
+
if (typeof window === 'undefined') return;
|
|
27
|
+
|
|
28
|
+
if (dirty) {
|
|
29
|
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
30
|
+
} else {
|
|
31
|
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
onDestroy(() => {
|
|
40
|
+
if (typeof window !== 'undefined') {
|
|
41
|
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
</script>
|