viur-ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +79 -0
  2. package/index.d.ts +351 -0
  3. package/index.js +46 -0
  4. package/package.json +84 -0
  5. package/src/components/Browser/Browser.vue +496 -0
  6. package/src/components/Browser/TreeNav.vue +408 -0
  7. package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
  8. package/src/components/Browser/ViurBrowser.vue +271 -0
  9. package/src/components/Browser/dragSession.ts +134 -0
  10. package/src/components/Browser/index.ts +14 -0
  11. package/src/components/Browser/types.ts +30 -0
  12. package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
  13. package/src/components/DataTable/DataTable.vue +3155 -0
  14. package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
  15. package/src/components/DataTable/ViurDataTable.vue +331 -0
  16. package/src/components/DataTable/columns.ts +400 -0
  17. package/src/components/DataTable/index.ts +60 -0
  18. package/src/components/DataTable/rows.ts +446 -0
  19. package/src/components/DataTable/types.ts +267 -0
  20. package/src/components/DataTable/useColumnLayout.ts +294 -0
  21. package/src/components/DataTable/useGridNavigation.ts +193 -0
  22. package/src/components/DataTable/useVirtualRows.ts +126 -0
  23. package/src/components/DataTable/viurColumns.ts +330 -0
  24. package/src/forms/ViForm.d.vue.ts +106 -0
  25. package/src/forms/ViForm.vue +592 -0
  26. package/src/forms/ViFormCategory.vue +57 -0
  27. package/src/forms/bones/Bone.vue +1023 -0
  28. package/src/forms/bones/BoneActions.vue +127 -0
  29. package/src/forms/bones/BoneLabel.vue +122 -0
  30. package/src/forms/bones/BoneMultipleList.vue +91 -0
  31. package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
  32. package/src/forms/bones/BoneNestedWrapper.vue +155 -0
  33. package/src/forms/bones/BoneSingleEntry.vue +51 -0
  34. package/src/forms/bones/FilterBone.vue +694 -0
  35. package/src/forms/bones/actionbar/defaultBar.vue +98 -0
  36. package/src/forms/bones/actionbar/fileBar.vue +165 -0
  37. package/src/forms/bones/actionbar/relationalBar.vue +125 -0
  38. package/src/forms/bones/actions/ActionDialog.vue +70 -0
  39. package/src/forms/bones/actions/Debug.vue +106 -0
  40. package/src/forms/bones/actions/Delete.vue +13 -0
  41. package/src/forms/bones/actions/DescribeImage.vue +201 -0
  42. package/src/forms/bones/actions/Download.vue +27 -0
  43. package/src/forms/bones/actions/Placeholder.vue +14 -0
  44. package/src/forms/bones/actions/Translation.vue +238 -0
  45. package/src/forms/bones/actions/index.js +280 -0
  46. package/src/forms/bones/bone.d.ts +45 -0
  47. package/src/forms/bones/country_information.json +779 -0
  48. package/src/forms/bones/index.js +225 -0
  49. package/src/forms/bones/uploadAdapter.js +46 -0
  50. package/src/forms/bones/utils.js +284 -0
  51. package/src/forms/bones/view/base_item.vue +35 -0
  52. package/src/forms/bones/view/boneLogic.js +202 -0
  53. package/src/forms/bones/view/index.js +5 -0
  54. package/src/forms/bones/widgets/booleanBone.vue +83 -0
  55. package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
  56. package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
  57. package/src/forms/bones/widgets/codeBone.vue +214 -0
  58. package/src/forms/bones/widgets/colorBone.vue +75 -0
  59. package/src/forms/bones/widgets/dateBone.vue +114 -0
  60. package/src/forms/bones/widgets/emailBone.vue +100 -0
  61. package/src/forms/bones/widgets/fileBone.vue +273 -0
  62. package/src/forms/bones/widgets/jsonBone.vue +63 -0
  63. package/src/forms/bones/widgets/keyBone.vue +71 -0
  64. package/src/forms/bones/widgets/numericBone.vue +125 -0
  65. package/src/forms/bones/widgets/passwordBone.vue +215 -0
  66. package/src/forms/bones/widgets/phoneBone.vue +173 -0
  67. package/src/forms/bones/widgets/rawBone.vue +94 -0
  68. package/src/forms/bones/widgets/recordBone.vue +64 -0
  69. package/src/forms/bones/widgets/relationalBone.vue +164 -0
  70. package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
  71. package/src/forms/bones/widgets/selectBone.vue +122 -0
  72. package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
  73. package/src/forms/bones/widgets/spatialBone.vue +111 -0
  74. package/src/forms/bones/widgets/stringBone.vue +139 -0
  75. package/src/forms/bones/widgets/textBone.vue +641 -0
  76. package/src/forms/install.d.ts +4 -0
  77. package/src/forms/install.js +43 -0
  78. package/src/forms/layouts/LayloutList2Columns.vue +57 -0
  79. package/src/forms/layouts/LayoutCategory.vue +38 -0
  80. package/src/forms/layouts/LayoutList.vue +31 -0
  81. package/src/forms/utils.js +601 -0
  82. package/src/public/assets/icons/fontawesome-manifest.json +8 -0
  83. package/src/public/assets/icons/regular/file.svg +1 -0
  84. package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
  85. package/src/public/assets/icons/solid/bug.svg +1 -0
  86. package/src/public/assets/icons/solid/check.svg +1 -0
  87. package/src/public/assets/icons/solid/circle-info.svg +1 -0
  88. package/src/public/assets/icons/solid/compress.svg +1 -0
  89. package/src/public/assets/icons/solid/download.svg +1 -0
  90. package/src/public/assets/icons/solid/expand.svg +1 -0
  91. package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
  92. package/src/public/assets/icons/solid/plus.svg +1 -0
  93. package/src/public/assets/icons/solid/question.svg +1 -0
  94. package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
  95. package/src/public/assets/icons/solid/upload.svg +1 -0
  96. package/src/public/assets/icons/solid/xmark.svg +1 -0
  97. package/src/request/cache.js +183 -0
  98. package/src/request/configStore.js +516 -0
  99. package/src/request/envelope.js +211 -0
  100. package/src/request/index.js +35 -0
  101. package/src/request/messageStore.js +201 -0
  102. package/src/request/request.js +752 -0
  103. package/src/request/skel.js +90 -0
  104. package/src/theme/colorStore.js +168 -0
  105. package/src/translations/de.js +178 -0
  106. package/src/translations/en.js +177 -0
  107. package/src/user/LoginScreen.d.vue.ts +54 -0
  108. package/src/user/LoginScreen.vue +627 -0
  109. package/src/user/google.js +75 -0
  110. package/src/user/index.js +3 -0
  111. package/src/user/store.d.ts +59 -0
  112. package/src/user/store.js +183 -0
  113. package/src/webawesomeCompat.css +72 -0
  114. package/src/webawesomeConfig.js +61 -0
@@ -0,0 +1,3155 @@
1
+ <template>
2
+ <!-- Vue-Nachbau von <wa-data-grid> (WebAwesome Pro). Spalten sind Daten,
3
+ Zell-Inhalte kommen aus Slots (`cell:<id>`), veränderlicher Zustand
4
+ läuft über v-model. Alles Weitere steckt in den Composables. -->
5
+ <div
6
+ class="vi-dt"
7
+ :class="[
8
+ `is-size-${size}`,
9
+ `is-${appearance}`,
10
+ {
11
+ 'is-striped': striped,
12
+ 'is-loading': state.isLoading,
13
+ 'is-selectable': selectable !== false && selectOnRowClick,
14
+ },
15
+ ]"
16
+ :style="state.hostStyle"
17
+ >
18
+ <!-- ── Toolbar: Suche + Spaltenmenü ─────────────────────────────── -->
19
+ <div
20
+ v-if="state.showToolbar"
21
+ class="vi-dt-toolbar wa-split wa-gap-s wa-flex-nowrap"
22
+ >
23
+ <div class="wa-cluster wa-gap-xs wa-flex-nowrap">
24
+ <slot name="toolbar-start" />
25
+ <wa-input
26
+ v-if="withSearch"
27
+ class="vi-dt-search"
28
+ size="small"
29
+ appearance="outlined"
30
+ :placeholder="t('dataTable.search')"
31
+ :value="search"
32
+ @input="onSearchInput(($event.target as HTMLInputElement).value)"
33
+ >
34
+ <wa-icon
35
+ slot="start"
36
+ name="magnifying-glass"
37
+ ></wa-icon>
38
+ </wa-input>
39
+ </div>
40
+ <div class="wa-cluster wa-gap-xs wa-flex-nowrap">
41
+ <slot name="toolbar-end" />
42
+ <template v-if="withColumnsMenu">
43
+ <wa-button
44
+ :id="`${uid}-columns`"
45
+ class="vi-dt-columns-button"
46
+ size="small"
47
+ appearance="outlined"
48
+ >
49
+ <wa-icon
50
+ slot="start"
51
+ name="table-columns"
52
+ ></wa-icon>
53
+ {{ t("dataTable.columns") }}
54
+ </wa-button>
55
+ <wa-popover
56
+ :for="`${uid}-columns`"
57
+ placement="bottom-end"
58
+ class="vi-dt-columns-menu"
59
+ >
60
+ <div class="wa-stack wa-gap-2xs">
61
+ <label
62
+ v-for="column in state.columns"
63
+ :key="column.id"
64
+ class="vi-dt-colopt wa-flank wa-flex-nowrap wa-gap-xs"
65
+ >
66
+ <input
67
+ type="checkbox"
68
+ :checked="layout.isVisible(column)"
69
+ :disabled="!column.hideable"
70
+ :data-column="column.id"
71
+ @change="layout.toggleColumn(column.id)"
72
+ />
73
+ <span class="wa-ellipsis">{{ column.label }}</span>
74
+ </label>
75
+ <wa-button
76
+ class="vi-dt-reset-columns"
77
+ size="small"
78
+ appearance="plain"
79
+ @click="resetColumns"
80
+ >
81
+ {{ t("dataTable.resetColumns") }}
82
+ </wa-button>
83
+ </div>
84
+ </wa-popover>
85
+ </template>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- ── Scroll-Bereich: Kopf, Körper, Spaltenfüße ────────────────── -->
90
+ <div
91
+ ref="scrollerEl"
92
+ class="vi-dt-scroller"
93
+ :role="state.gridRole"
94
+ :aria-label="label ?? undefined"
95
+ :aria-rowcount="state.filteredCount"
96
+ :aria-colcount="state.cellColumns.length"
97
+ @scroll.passive="virtual.onScroll"
98
+ @keydown="nav.handleKeydown"
99
+ >
100
+ <div
101
+ class="vi-dt-head"
102
+ role="rowgroup"
103
+ >
104
+ <div
105
+ class="vi-dt-row vi-dt-headrow"
106
+ role="row"
107
+ :style="state.gridStyle"
108
+ >
109
+ <div
110
+ v-for="cell in state.cellColumns"
111
+ :key="cell.key"
112
+ class="vi-dt-cell vi-dt-hcell"
113
+ :class="[
114
+ cell.kind === 'data' ? `is-align-${cell.col!.column.headerAlign}` : 'is-lead',
115
+ {
116
+ 'is-sticky': cell.sticky,
117
+ 'is-pin-edge': cell.pinEdge,
118
+ 'is-pin-start': cell.side === 'start',
119
+ 'is-pin-end': cell.side === 'end',
120
+ 'is-sortable': cell.kind === 'data' && cell.col!.column.sortable,
121
+ 'is-sorted': cell.kind === 'data' && state.sortIndex[cell.col!.column.id] !== undefined,
122
+ 'is-dragging': layout.state.dragging?.id === cell.id,
123
+ 'is-drop-target': layout.state.dragging?.overId === cell.id && layout.state.dragging?.id !== cell.id,
124
+ },
125
+ ]"
126
+ :style="cellStyle(cell)"
127
+ role="columnheader"
128
+ :data-row="-1"
129
+ :data-col="cell.index"
130
+ :data-column="cell.id"
131
+ :tabindex="nav.isActive(-1, cell.index) ? 0 : -1"
132
+ :aria-sort="ariaSort(cell)"
133
+ :draggable="cell.kind === 'data' && cell.col!.movable ? 'true' : undefined"
134
+ @click="onHeaderClick(cell, $event)"
135
+ @focus="nav.setActive(-1, cell.index, false)"
136
+ @dragstart="onDragStart(cell, $event)"
137
+ @dragover="onDragOver(cell, $event)"
138
+ @dragend="layout.endDrag()"
139
+ @drop.prevent="layout.endDrag()"
140
+ >
141
+ <!-- Auswahl-Kopfzelle: alles auf dieser Seite -->
142
+ <input
143
+ v-if="cell.kind === 'select' && selectable === 'multiple'"
144
+ class="vi-dt-checkbox vi-dt-select-all"
145
+ type="checkbox"
146
+ :aria-label="t('dataTable.selectAllPage')"
147
+ :checked="state.selectAllState === 'checked'"
148
+ :indeterminate.prop="state.selectAllState === 'indeterminate'"
149
+ @click.stop="toggleSelectAll()"
150
+ />
151
+
152
+ <template v-if="cell.kind === 'data'">
153
+ <span class="vi-dt-hlabel wa-ellipsis">
154
+ <slot
155
+ :name="`header:${cell.col!.column.id}`"
156
+ :column="cell.col!.column"
157
+ >
158
+ {{ cell.col!.column.label }}
159
+ </slot>
160
+ </span>
161
+ <wa-icon
162
+ v-if="state.sortIndex[cell.col!.column.id] !== undefined"
163
+ class="vi-dt-sort"
164
+ :name="state.sortDirs[cell.col!.column.id] ? 'chevron-down' : 'chevron-up'"
165
+ ></wa-icon>
166
+ <span
167
+ v-if="state.sort.length > 1 && state.sortIndex[cell.col!.column.id] !== undefined"
168
+ class="vi-dt-sortnum"
169
+ >
170
+ {{ state.sortIndex[cell.col!.column.id]! + 1 }}
171
+ </span>
172
+ <wa-button
173
+ v-if="cell.col!.column.filterable"
174
+ :id="filterButtonId(cell.col!.column.id)"
175
+ class="vi-dt-filter-button"
176
+ :class="{ 'is-active': !isEmptyFilter(filterValue(cell.col!.column.id)) }"
177
+ appearance="plain"
178
+ size="small"
179
+ :data-column="cell.col!.column.id"
180
+ :title="t('dataTable.filterFor', { column: cell.col!.column.label })"
181
+ >
182
+ <wa-icon name="filter"></wa-icon>
183
+ </wa-button>
184
+ <wa-dropdown
185
+ v-if="withColumnMenu"
186
+ class="vi-dt-column-menu"
187
+ placement="bottom-end"
188
+ >
189
+ <wa-button
190
+ slot="trigger"
191
+ class="vi-dt-column-menu-button"
192
+ appearance="plain"
193
+ size="small"
194
+ :data-column="cell.col!.column.id"
195
+ :title="t('dataTable.columnOptions')"
196
+ >
197
+ <wa-icon name="ellipsis-vertical"></wa-icon>
198
+ </wa-button>
199
+ <wa-dropdown-item
200
+ v-if="cell.col!.column.sortable"
201
+ @click="applySort(cell.col!.column.id, false)"
202
+ >
203
+ {{ t("dataTable.sortAsc") }}
204
+ </wa-dropdown-item>
205
+ <wa-dropdown-item
206
+ v-if="cell.col!.column.sortable"
207
+ @click="applySort(cell.col!.column.id, true)"
208
+ >
209
+ {{ t("dataTable.sortDesc") }}
210
+ </wa-dropdown-item>
211
+ <wa-dropdown-item
212
+ v-if="cell.col!.pinnable"
213
+ @click="pinColumn(cell.col!.column.id, 'start')"
214
+ >
215
+ {{ t("dataTable.pinLeft") }}
216
+ </wa-dropdown-item>
217
+ <wa-dropdown-item
218
+ v-if="cell.col!.pinnable"
219
+ @click="pinColumn(cell.col!.column.id, 'end')"
220
+ >
221
+ {{ t("dataTable.pinRight") }}
222
+ </wa-dropdown-item>
223
+ <wa-dropdown-item
224
+ v-if="cell.col!.pinnable && cell.col!.pinned"
225
+ @click="pinColumn(cell.col!.column.id, false)"
226
+ >
227
+ {{ t("dataTable.unpin") }}
228
+ </wa-dropdown-item>
229
+ <wa-dropdown-item
230
+ v-if="cell.col!.resizable"
231
+ @click="autoSizeColumn(cell.col!.column.id)"
232
+ >
233
+ {{ t("dataTable.autoSize") }}
234
+ </wa-dropdown-item>
235
+ <wa-dropdown-item
236
+ v-if="cell.col!.column.hideable"
237
+ @click="layout.toggleColumn(cell.col!.column.id, false)"
238
+ >
239
+ {{ t("dataTable.hideColumn") }}
240
+ </wa-dropdown-item>
241
+ </wa-dropdown>
242
+ <wa-icon
243
+ v-if="cell.col!.pinned"
244
+ class="vi-dt-pin"
245
+ name="thumbtack"
246
+ :title="cell.col!.pinned === 'start' ? t('dataTable.pinnedLeft') : t('dataTable.pinnedRight')"
247
+ @click.stop="pinColumn(cell.col!.column.id, false)"
248
+ ></wa-icon>
249
+ <span
250
+ v-if="cell.col!.resizable"
251
+ class="vi-dt-resize"
252
+ :data-column="cell.col!.column.id"
253
+ @pointerdown.stop.prevent="onResizeStart(cell.col!.column.id, $event)"
254
+ @dblclick.stop="autoSizeColumn(cell.col!.column.id)"
255
+ ></span>
256
+ </template>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ <div
262
+ ref="bodyEl"
263
+ class="vi-dt-body"
264
+ role="rowgroup"
265
+ :style="{
266
+ paddingBlockStart: `${virtual.state.padStart}px`,
267
+ paddingBlockEnd: `${virtual.state.padEnd}px`,
268
+ }"
269
+ >
270
+ <template
271
+ v-for="displayRow in virtual.state.windowRows"
272
+ :key="displayRow.key"
273
+ >
274
+ <!-- Detail-Panel: eigene Zeile über die volle Breite -->
275
+ <div
276
+ v-if="displayRow.kind === 'detail'"
277
+ class="vi-dt-detailrow"
278
+ role="row"
279
+ :data-detail="displayRow.key"
280
+ >
281
+ <div
282
+ class="vi-dt-detail"
283
+ role="gridcell"
284
+ >
285
+ <slot
286
+ name="row-detail"
287
+ :row="displayRow.row!"
288
+ />
289
+ </div>
290
+ </div>
291
+
292
+ <div
293
+ v-else
294
+ class="vi-dt-row"
295
+ :class="[
296
+ displayRow.kind === 'group' ? 'is-group' : 'is-data',
297
+ {
298
+ 'is-selected': displayRow.kind === 'data' && state.selectedSet.has(displayRow.nodeKey),
299
+ 'is-odd': displayRow.index % 2 === 1,
300
+ 'is-dragging': state.rowDrag?.key === displayRow.nodeKey,
301
+ 'is-drop-before': state.rowDrag?.overKey === displayRow.nodeKey && state.rowDrag?.mode === 'before',
302
+ 'is-drop-after': state.rowDrag?.overKey === displayRow.nodeKey && state.rowDrag?.mode === 'after',
303
+ 'is-drop-inside':
304
+ (state.rowDrag?.overKey === displayRow.nodeKey && state.rowDrag?.mode === 'inside') ||
305
+ (dragSession?.state.over?.container === dragSource &&
306
+ dragSession?.state.over?.key === displayRow.nodeKey),
307
+ },
308
+ rowClass?.(displayRow.row!) || '',
309
+ ]"
310
+ role="row"
311
+ :style="state.gridStyle"
312
+ :data-key="displayRow.key"
313
+ @dblclick="onRowDoubleClick(displayRow, $event)"
314
+ :aria-selected="
315
+ selectable !== false && displayRow.kind === 'data' ? state.selectedSet.has(displayRow.nodeKey) : undefined
316
+ "
317
+ :aria-level="state.isTree ? displayRow.depth + 1 : undefined"
318
+ :aria-expanded="displayRow.expandable ? displayRow.expanded : undefined"
319
+ >
320
+ <div
321
+ v-for="cell in state.cellColumns"
322
+ :key="cell.key"
323
+ class="vi-dt-cell"
324
+ :class="[
325
+ cell.kind === 'data' ? `is-align-${cell.col!.column.align}` : 'is-lead',
326
+ cell.kind === 'data' ? cellClass(cell, displayRow) : '',
327
+ {
328
+ 'is-tree-cell': state.isTree && cell.index === state.firstDataCellIndex,
329
+ 'is-child': displayRow.depth > 0,
330
+ 'is-sticky': cell.sticky,
331
+ 'is-pin-edge': cell.pinEdge,
332
+ 'is-pin-start': cell.side === 'start',
333
+ 'is-pin-end': cell.side === 'end',
334
+ },
335
+ ]"
336
+ :style="[cellStyle(cell), indentStyle(cell, displayRow)]"
337
+ role="gridcell"
338
+ :data-row="state.rowIndexOf[displayRow.key]"
339
+ :data-col="cell.index"
340
+ :data-column="cell.id"
341
+ :tabindex="nav.isActive(state.rowIndexOf[displayRow.key]!, cell.index) ? 0 : -1"
342
+ @focus="nav.setActive(state.rowIndexOf[displayRow.key]!, cell.index, false)"
343
+ @mousedown="onCellMouseDown($event)"
344
+ @click="onCellClick(displayRow, cell, $event)"
345
+ @contextmenu="onCellContextMenu(displayRow, cell, $event)"
346
+ >
347
+ <!-- Ziehgriff der festen Reihenfolge -->
348
+ <span
349
+ v-if="cell.kind === 'order' && displayRow.kind === 'data'"
350
+ class="vi-dt-grip"
351
+ :class="{ 'is-disabled': !state.orderActive }"
352
+ :title="state.orderActive ? t('dataTable.dragToReorder') : t('dataTable.reorderDisabled')"
353
+ :aria-hidden="!state.orderActive"
354
+ @pointerdown.prevent="beginRowDrag(displayRow, $event)"
355
+ >
356
+ <wa-icon name="grip-vertical"></wa-icon>
357
+ </span>
358
+
359
+ <!-- Auswahl -->
360
+ <input
361
+ v-if="cell.kind === 'select' && selectable !== false"
362
+ class="vi-dt-checkbox"
363
+ :type="selectable === 'single' ? 'radio' : 'checkbox'"
364
+ :aria-label="t('dataTable.selectRow', { row: displayRow.index + 1 })"
365
+ :checked="state.rowSelection[displayRow.key] === 'checked'"
366
+ :indeterminate.prop="state.rowSelection[displayRow.key] === 'indeterminate'"
367
+ :disabled="displayRow.kind === 'data' && !canSelect(displayRow.row!)"
368
+ @click.stop="onSelectRow(displayRow, $event as MouseEvent)"
369
+ />
370
+
371
+ <!-- Aufklapp-Spalte (Baum, Gruppen, Detail-Panels) -->
372
+ <template v-if="cell.kind === 'expand'">
373
+ <wa-button
374
+ v-if="displayRow.expandable"
375
+ class="vi-dt-expand"
376
+ appearance="plain"
377
+ size="small"
378
+ :aria-expanded="displayRow.expanded"
379
+ :aria-label="displayRow.expanded ? 'Zeile einklappen' : 'Zeile ausklappen'"
380
+ @click.stop="toggleExpand(displayRow)"
381
+ >
382
+ <wa-icon :name="displayRow.expanded ? 'chevron-down' : 'chevron-right'"></wa-icon>
383
+ </wa-button>
384
+ </template>
385
+
386
+ <!-- Gruppenzeile: Wert + Anzahl in der ersten Datenspalte, sonst Aggregate -->
387
+ <template v-else-if="displayRow.kind === 'group' && cell.kind === 'data'">
388
+ <template v-if="cell.index === state.firstDataCellIndex">
389
+ <slot
390
+ name="group"
391
+ :group="displayRow.node"
392
+ >
393
+ <span class="vi-dt-group-value wa-ellipsis">{{ groupLabel(displayRow) }}</span>
394
+ <span class="vi-dt-group-count">{{ displayRow.node!.groupCount }}</span>
395
+ </slot>
396
+ </template>
397
+ <span
398
+ v-else
399
+ class="vi-dt-aggregate wa-ellipsis"
400
+ >
401
+ {{ aggregateText(cell, displayRow) }}
402
+ </span>
403
+ </template>
404
+
405
+ <!-- Datenzelle -->
406
+ <template v-else-if="cell.kind === 'data'">
407
+ <!-- Baumdaten: Führungslinien, dann Pfeil (oder Platzhalter). Je
408
+ Ebene ein Feld — nur dort, wo unter dem Vorfahren noch ein
409
+ Geschwister folgt, wird auch eine Linie gezeichnet. -->
410
+ <template v-if="state.isTree && cell.index === state.firstDataCellIndex">
411
+ <!-- Durchlauf-Spalten … -->
412
+ <span
413
+ v-for="(line, level) in displayRow.ancestorLines"
414
+ :key="level"
415
+ class="vi-dt-guide"
416
+ :class="{ 'is-line': line }"
417
+ aria-hidden="true"
418
+ ></span>
419
+ <!-- … und der eigene Winkel. -->
420
+ <span
421
+ v-if="displayRow.depth > 0"
422
+ class="vi-dt-guide"
423
+ :class="displayRow.isLastChild ? 'is-corner' : 'is-tee'"
424
+ aria-hidden="true"
425
+ ></span>
426
+ <wa-button
427
+ v-if="displayRow.expandable"
428
+ class="vi-dt-expand"
429
+ :class="{ 'is-empty': isEmptyBranch(displayRow) }"
430
+ appearance="plain"
431
+ size="small"
432
+ :disabled="isEmptyBranch(displayRow) || undefined"
433
+ :aria-expanded="displayRow.expanded"
434
+ :aria-busy="isLoadingChildren(displayRow.row)"
435
+ :aria-label="
436
+ isEmptyBranch(displayRow)
437
+ ? t('dataTable.noChildren')
438
+ : displayRow.expanded
439
+ ? t('dataTable.collapseRow')
440
+ : t('dataTable.expandRow')
441
+ "
442
+ @click.stop="toggleExpand(displayRow)"
443
+ >
444
+ <wa-spinner
445
+ v-if="isLoadingChildren(displayRow.row)"
446
+ class="vi-dt-expand-spinner"
447
+ ></wa-spinner>
448
+ <wa-icon
449
+ v-else
450
+ :name="displayRow.expanded ? 'chevron-down' : 'chevron-right'"
451
+ ></wa-icon>
452
+ </wa-button>
453
+ <!-- Blatt: derselbe Abstand wie zwischen Pfeil und Linien. -->
454
+ <span
455
+ v-else
456
+ class="vi-dt-leaf-gap"
457
+ aria-hidden="true"
458
+ ></span>
459
+ </template>
460
+ <slot
461
+ :name="`cell:${cell.col!.column.id}`"
462
+ :row="displayRow.row!"
463
+ :value="cell.col!.column.get(displayRow.row!)"
464
+ :column="cell.col!.column"
465
+ :index="displayRow.index"
466
+ >
467
+ <span
468
+ v-if="cell.col!.column.def.badge && badgesFor(cell.col!.column, displayRow.row!)"
469
+ class="vi-dt-badges"
470
+ >
471
+ <span
472
+ v-for="(badge, badgeIndex) in badgesFor(cell.col!.column, displayRow.row!)"
473
+ :key="badgeIndex"
474
+ class="vi-dt-badge"
475
+ :style="{ '--dt-badge-color': badge.color }"
476
+ >
477
+ {{ badge.label }}
478
+ </span>
479
+ </span>
480
+ <span
481
+ v-else
482
+ class="wa-ellipsis"
483
+ :title="cell.col!.column.text(displayRow.row!)"
484
+ >
485
+ {{ cell.col!.column.text(displayRow.row!) }}
486
+ </span>
487
+ </slot>
488
+ </template>
489
+ </div>
490
+ </div>
491
+ </template>
492
+ </div>
493
+
494
+ <!-- Spaltenfüße, am unteren Rand des Scroll-Bereichs -->
495
+ <div
496
+ v-if="state.hasColumnFooters"
497
+ class="vi-dt-foot"
498
+ role="rowgroup"
499
+ >
500
+ <div
501
+ class="vi-dt-row vi-dt-footrow"
502
+ role="row"
503
+ :style="state.gridStyle"
504
+ >
505
+ <div
506
+ v-for="cell in state.cellColumns"
507
+ :key="cell.key"
508
+ class="vi-dt-cell vi-dt-footcell"
509
+ :class="[
510
+ cell.kind === 'data' ? `is-align-${cell.col!.column.align}` : 'is-lead',
511
+ {
512
+ 'is-sticky': cell.sticky,
513
+ 'is-pin-edge': cell.pinEdge,
514
+ 'is-pin-start': cell.side === 'start',
515
+ 'is-pin-end': cell.side === 'end',
516
+ },
517
+ ]"
518
+ :style="cellStyle(cell)"
519
+ role="gridcell"
520
+ :data-column="cell.id"
521
+ >
522
+ <template v-if="cell.kind === 'data'">
523
+ <slot
524
+ :name="`footer:${cell.col!.column.id}`"
525
+ :rows="state.footerRows"
526
+ >
527
+ <span class="wa-ellipsis">{{ state.footerText[cell.col!.column.id] ?? "" }}</span>
528
+ </slot>
529
+ </template>
530
+ </div>
531
+ </div>
532
+ </div>
533
+
534
+ <!-- Leer / keine Treffer -->
535
+ <div
536
+ v-if="state.showEmpty"
537
+ class="vi-dt-state"
538
+ >
539
+ <slot name="empty">
540
+ <div class="wa-stack wa-gap-2xs wa-align-items-center">
541
+ <wa-icon name="table-cells"></wa-icon>
542
+ <span>{{ t("dataTable.noData") }}</span>
543
+ </div>
544
+ </slot>
545
+ </div>
546
+ <div
547
+ v-else-if="state.showNoResults"
548
+ class="vi-dt-state"
549
+ >
550
+ <slot name="no-results">
551
+ <div class="wa-stack wa-gap-2xs wa-align-items-center">
552
+ <wa-icon name="magnifying-glass"></wa-icon>
553
+ <span>{{ t("dataTable.noResults") }}</span>
554
+ </div>
555
+ </slot>
556
+ </div>
557
+
558
+ <!-- Ladeschleier (Server-Modus) -->
559
+ <div
560
+ v-if="state.isLoading"
561
+ class="vi-dt-loading"
562
+ >
563
+ <slot name="loading"><wa-spinner class="vi-dt-spinner"></wa-spinner></slot>
564
+ </div>
565
+ </div>
566
+
567
+ <!-- ── Nachladen (Cursor-Quellen) ───────────────────────────────── -->
568
+ <div
569
+ v-if="state.cursorMode"
570
+ class="vi-dt-more wa-split wa-gap-s wa-flex-nowrap"
571
+ >
572
+ <span class="vi-dt-moreinfo">{{ state.loadedText }}</span>
573
+ <div class="wa-cluster wa-gap-2xs wa-flex-nowrap">
574
+ <!-- Platz für Aufrufer-Inhalte in der Leiste (z.B. Reload-Action). -->
575
+ <slot name="cursor-start" />
576
+ <select
577
+ class="vi-dt-pagesize"
578
+ :aria-label="t('dataTable.perPageAria')"
579
+ :value="pageSize"
580
+ @change="onCursorPageSize(Number(($event.target as HTMLSelectElement).value))"
581
+ >
582
+ <option
583
+ v-for="option in state.cursorPageSizes"
584
+ :key="option"
585
+ :value="option"
586
+ >
587
+ {{ t("dataTable.perPage", { count: option }) }}
588
+ </option>
589
+ </select>
590
+ <template v-if="hasMore">
591
+ <wa-button
592
+ class="vi-dt-more-btn"
593
+ size="small"
594
+ appearance="outlined"
595
+ :loading="state.isLoading || undefined"
596
+ @click="emit('load-more', { pages: 1, pageSize })"
597
+ >
598
+ {{ loadMoreLabel ?? t("dataTable.loadMore") }}
599
+ </wa-button>
600
+ <wa-button
601
+ v-for="pages in loadMorePages"
602
+ :key="pages"
603
+ size="small"
604
+ appearance="outlined"
605
+ :disabled="state.isLoading || undefined"
606
+ :title="t('dataTable.loadPages', { pages })"
607
+ @click="emit('load-more', { pages, pageSize })"
608
+ >
609
+ +{{ pages }}
610
+ </wa-button>
611
+ <wa-button
612
+ size="small"
613
+ appearance="outlined"
614
+ :disabled="state.isLoading || undefined"
615
+ @click="emit('load-more', { pages: 'all', pageSize })"
616
+ >
617
+ {{ loadAllLabel ?? t("dataTable.loadAll") }}
618
+ </wa-button>
619
+ </template>
620
+ <slot name="cursor-end" />
621
+ </div>
622
+ </div>
623
+
624
+ <!-- ── Pager ────────────────────────────────────────────────────── -->
625
+ <div
626
+ v-if="paginate"
627
+ class="vi-dt-pager wa-split wa-gap-s wa-flex-nowrap"
628
+ >
629
+ <span class="vi-dt-pageinfo">
630
+ {{
631
+ t("dataTable.pageInfo", {
632
+ rows: state.filteredCount,
633
+ page: page + 1,
634
+ pages: state.pageCount,
635
+ })
636
+ }}
637
+ </span>
638
+ <div class="wa-cluster wa-gap-2xs wa-flex-nowrap">
639
+ <select
640
+ class="vi-dt-pagesize"
641
+ :aria-label="t('dataTable.rowsPerPageAria')"
642
+ :value="pageSize"
643
+ @change="setPageSize(Number(($event.target as HTMLSelectElement).value))"
644
+ >
645
+ <option
646
+ v-for="option in pageSizeOptions"
647
+ :key="option"
648
+ :value="option"
649
+ >
650
+ {{ t("dataTable.perPage", { count: option }) }}
651
+ </option>
652
+ </select>
653
+ <wa-button
654
+ class="vi-dt-first"
655
+ size="small"
656
+ appearance="outlined"
657
+ :disabled="page === 0 || undefined"
658
+ :title="t('dataTable.firstPage')"
659
+ @click="setPage(0)"
660
+ >
661
+ <wa-icon name="angles-left"></wa-icon>
662
+ </wa-button>
663
+ <wa-button
664
+ class="vi-dt-prev"
665
+ size="small"
666
+ appearance="outlined"
667
+ :disabled="page === 0 || undefined"
668
+ :title="t('dataTable.prevPage')"
669
+ @click="setPage(page - 1)"
670
+ >
671
+ <wa-icon name="chevron-left"></wa-icon>
672
+ </wa-button>
673
+ <wa-button
674
+ class="vi-dt-next"
675
+ size="small"
676
+ appearance="outlined"
677
+ :disabled="page >= state.pageCount - 1 || undefined"
678
+ :title="t('dataTable.nextPage')"
679
+ @click="setPage(page + 1)"
680
+ >
681
+ <wa-icon name="chevron-right"></wa-icon>
682
+ </wa-button>
683
+ <wa-button
684
+ class="vi-dt-last"
685
+ size="small"
686
+ appearance="outlined"
687
+ :disabled="page >= state.pageCount - 1 || undefined"
688
+ :title="t('dataTable.lastPage')"
689
+ @click="setPage(state.pageCount - 1)"
690
+ >
691
+ <wa-icon name="angles-right"></wa-icon>
692
+ </wa-button>
693
+ </div>
694
+ </div>
695
+
696
+ <!-- Filter-Popover je sichtbarer filterbarer Spalte (nach ihren Ankern!) -->
697
+ <wa-popover
698
+ v-for="column in state.filterColumns"
699
+ :key="column.id"
700
+ class="vi-dt-filter-panel"
701
+ :for="filterButtonId(column.id)"
702
+ placement="bottom-start"
703
+ >
704
+ <ColumnFilterPanel
705
+ :column="column"
706
+ :facets="state.facets[column.id]"
707
+ :model-value="filterValue(column.id)"
708
+ @update:model-value="setFilter(column.id, $event)"
709
+ />
710
+ </wa-popover>
711
+
712
+ <span
713
+ class="vi-dt-live"
714
+ aria-live="polite"
715
+ >
716
+ {{ state.announcement }}
717
+ </span>
718
+ </div>
719
+ </template>
720
+
721
+ <script setup lang="ts" generic="R extends Row">
722
+ import { useI18n } from "vue-i18n"
723
+ import { computed, inject, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch, shallowRef } from "vue"
724
+ import ColumnFilterPanel from "./ColumnFilterPanel.vue"
725
+ import { useColumnLayout, type LayoutColumn } from "./useColumnLayout"
726
+ import { useGridNavigation } from "./useGridNavigation"
727
+ import { useVirtualRows } from "./useVirtualRows"
728
+ import { DRAG_SESSION } from "../Browser/dragSession"
729
+ import { aggregateColumn, defaultText, findColumn, isEmptyFilter, nextSortState, resolveColumns } from "./columns"
730
+ import {
731
+ buildRows,
732
+ collectKeys,
733
+ flattenNodes,
734
+ isDescendant,
735
+ locateNode,
736
+ nestByParent,
737
+ orderValueFor,
738
+ paginateNodes,
739
+ selectionStateFor,
740
+ type BuiltRows,
741
+ type ChildRowsAccessor,
742
+ type NestedTree,
743
+ type SelectionState,
744
+ type NodeLocation,
745
+ } from "./rows"
746
+ import type {
747
+ ColumnFacets,
748
+ DataTableColumn,
749
+ DataTableRequest,
750
+ DataTableResponse,
751
+ DataTableState,
752
+ DisplayRow,
753
+ FilterEntry,
754
+ PinSide,
755
+ ResolvedColumn,
756
+ Row,
757
+ RowKey,
758
+ RowNode,
759
+ SortEntry,
760
+ CellBadge,
761
+ } from "./types"
762
+
763
+ const props = withDefaults(
764
+ defineProps<{
765
+ columns: DataTableColumn<R>[]
766
+ data?: R[]
767
+ /** Feld mit stabiler Zeilen-id — für Auswahl über Sortierung/Seiten hinweg. */
768
+ rowKey?: string | null
769
+ label?: string | null
770
+ selectable?: false | "single" | "multiple"
771
+ selectableRows?: ((row: R) => boolean) | null
772
+ /** Bei aktiver Auswahl wählt ein Linksklick auf die Zeile den Eintrag (Shift: Bereich). */
773
+ selectOnRowClick?: boolean
774
+ /**
775
+ * Nachlade-Baum (`loadChildren`) bei jedem Datenwechsel von außen
776
+ * zurücksetzen (Pfeile zu, Kinder-Cache leer). Standard false: ein Drop
777
+ * bleibt eine rein lokale Verschiebung und nichts klappt ungefragt zu —
778
+ * zurückgesetzt wird nur über `reload()` / `resetTree()`.
779
+ */
780
+ resetTreeOnDataChange?: boolean
781
+ /** Container-Id dieser Tabelle in einer umgebenden Drag-Session (Browser). */
782
+ dragSource?: string
783
+ striped?: boolean
784
+ size?: "xs" | "s" | "m" | "l" | "xl"
785
+ appearance?: "outlined" | "plain"
786
+ withSearch?: boolean
787
+ withColumnsMenu?: boolean
788
+ withColumnMenu?: boolean
789
+ resizable?: boolean
790
+ reorderable?: boolean
791
+ pinnable?: boolean
792
+ paginate?: boolean
793
+ pageSizeOptions?: number[]
794
+ groupBy?: string | string[] | null
795
+ childRows?: string | ((row: R) => R[] | undefined | null) | null
796
+ /**
797
+ * Feld mit dem Verweis auf die Elternzeile (ViUR: `parententry`). Damit
798
+ * baut die Komponente aus einer FLACHEN Liste selbst den Baum — `rowKey`
799
+ * ist dafür Pflicht. Bereits verschachtelte Daten laufen weiter über
800
+ * `childRows`, das dann Vorrang hat.
801
+ */
802
+ parentField?: string | null
803
+ /**
804
+ * Kinder einer Ebene bei Bedarf nachladen (ViUR: `list` mit `parententry`).
805
+ * Gesetzt heißt: JEDE Datenzeile bekommt einen Aufklapp-Pfeil, weil vorher
806
+ * unbekannt ist, ob es Kinder gibt. Nach dem Laden bleibt der Pfeil stehen
807
+ * und wird deaktiviert, wenn nichts kam — so springt das Layout nicht.
808
+ */
809
+ loadChildren?: ((row: R) => Promise<R[]>) | null
810
+ /**
811
+ * Weiß die Anwendung vorab, ob eine Zeile Kinder haben KANN (in ViUR etwa
812
+ * `node` vs. `leaf`), dann hier melden: `false` → gar kein Pfeil,
813
+ * `true`/`undefined` → Pfeil, geladen wird beim Aufklappen.
814
+ */
815
+ rowHasChildren?: ((row: R) => boolean | undefined) | null
816
+ filterFromLeafRows?: boolean
817
+ /** Zeilen bekommen ein Detail-Panel (Inhalt: Slot `row-detail`). */
818
+ rowDetail?: boolean
819
+ rowClass?: ((row: R) => string | null | undefined) | null
820
+ /**
821
+ * Feld mit der numerischen Reihenfolge (ViUR: "sortindex"). Gesetzt heißt:
822
+ * die Tabelle ist fest danach sortiert und die Zeilen lassen sich per
823
+ * Ziehen umsortieren — die Komponente meldet den neuen Wert über
824
+ * `row-reorder`, gespeichert wird außerhalb.
825
+ */
826
+ orderField?: string | null
827
+ /** Abstand für neue Werte am Anfang/Ende der Liste. */
828
+ orderStep?: number
829
+ sortDescFirst?: boolean
830
+ withoutSortRemoval?: boolean
831
+ maxMultiSort?: number
832
+ searchFn?: ((value: unknown, term: string, row: R) => boolean) | null
833
+ /** Server-Modus ohne Callback: die Komponente meldet `data-request`. */
834
+ /**
835
+ * Cursor-Quellen (z. B. ViUR-Listen) laden vorwärts nach statt Seiten zu
836
+ * springen. Ist `hasMore` gesetzt (auch `false`), erscheint statt des Pagers
837
+ * eine Leiste mit „Mehr laden"; das Laden macht der Aufrufer auf `load-more`.
838
+ */
839
+ hasMore?: boolean | null
840
+ loadMoreLabel?: string | null
841
+ loadAllLabel?: string | null
842
+ /** Schnellwahl „die nächsten N Seiten laden" in der Cursor-Leiste. */
843
+ loadMorePages?: number[]
844
+ /** Seitengrößen der Cursor-Leiste (ViUR: limit, maximal 99). */
845
+ cursorPageSizes?: number[]
846
+ server?: boolean
847
+ dataSource?: ((request: DataTableRequest) => Promise<DataTableResponse<R>>) | null
848
+ total?: number
849
+ loading?: boolean
850
+ filterDebounce?: number
851
+ /** true/false erzwingen, "auto" ab `virtualizeThreshold` Zeilen. */
852
+ virtualize?: boolean | "auto"
853
+ virtualizeThreshold?: number
854
+ rowHeight?: number
855
+ /**
856
+ * Deckel für den Scrollbereich. OHNE Angabe greift der CSS-Vorgabewert
857
+ * `--dt-max-height: 30rem` — dadurch bleibt die Variable von aussen
858
+ * ueberschreibbar (z. B. `--dt-max-height: none`, damit die Tabelle eine
859
+ * Flaeche fuellt). Ein gesetzter Wert steht inline und gewinnt.
860
+ */
861
+ maxHeight?: string
862
+ }>(),
863
+ {
864
+ data: () => [],
865
+ rowKey: null,
866
+ label: null,
867
+ selectable: false,
868
+ selectableRows: null,
869
+ selectOnRowClick: true,
870
+ resetTreeOnDataChange: false,
871
+ dragSource: "list",
872
+ striped: false,
873
+ size: "m",
874
+ appearance: "outlined",
875
+ withSearch: false,
876
+ withColumnsMenu: false,
877
+ withColumnMenu: false,
878
+ resizable: false,
879
+ reorderable: false,
880
+ pinnable: false,
881
+ paginate: false,
882
+ pageSizeOptions: () => [10, 20, 50, 100],
883
+ groupBy: null,
884
+ childRows: null,
885
+ parentField: null,
886
+ loadChildren: null,
887
+ rowHasChildren: null,
888
+ filterFromLeafRows: false,
889
+ rowDetail: false,
890
+ rowClass: null,
891
+ orderField: null,
892
+ orderStep: 1,
893
+ sortDescFirst: false,
894
+ withoutSortRemoval: false,
895
+ maxMultiSort: 0,
896
+ searchFn: null,
897
+ hasMore: null,
898
+ loadMoreLabel: null,
899
+ loadAllLabel: null,
900
+ loadMorePages: () => [5, 10],
901
+ cursorPageSizes: () => [10, 30, 50, 99],
902
+ server: false,
903
+ dataSource: null,
904
+ total: undefined,
905
+ loading: undefined,
906
+ filterDebounce: 250,
907
+ virtualize: "auto",
908
+ virtualizeThreshold: 60,
909
+ rowHeight: 44,
910
+ // Kein Default: siehe maxHeight oben — der Deckel kommt aus dem CSS.
911
+ maxHeight: undefined,
912
+ }
913
+ )
914
+
915
+ // Zustand, den außen mitgeführt werden kann — je ein v-model.
916
+ const sort = defineModel<SortEntry[]>("sort", { default: () => [] })
917
+ const filters = defineModel<FilterEntry[]>("filters", { default: () => [] })
918
+ const search = defineModel<string>("search", { default: "" })
919
+ const selectedKeys = defineModel<RowKey[]>("selectedKeys", { default: () => [] })
920
+ const expandedKeys = defineModel<RowKey[]>("expandedKeys", { default: () => [] })
921
+ const page = defineModel<number>("page", { default: 0 })
922
+ const pageSize = defineModel<number>("pageSize", { default: 20 })
923
+
924
+ const emit = defineEmits<{
925
+ "cell-click": [payload: { column: string; value: unknown; row: R; index: number }]
926
+ /** Doppelklick auf eine Datenzeile — typisch zum Öffnen des Datensatzes. */
927
+ "row-dblclick": [payload: { key: RowKey; row: R; index: number; originalEvent: MouseEvent }]
928
+ "cell-contextmenu": [payload: { column: string; value: unknown; row: R; index: number; originalEvent: MouseEvent }]
929
+ "sort-change": [sort: SortEntry[]]
930
+ "filter-change": [payload: { search: string; filters: FilterEntry[] }]
931
+ "row-select": [payload: { selectedKeys: RowKey[]; rows: R[] }]
932
+ "row-expand": [payload: { key: RowKey; row?: R }]
933
+ "row-collapse": [payload: { key: RowKey; row?: R }]
934
+ "column-move": [payload: { columnOrder: string[]; finished: boolean }]
935
+ "column-resize": [payload: { column: string; width: number; finished: boolean }]
936
+ "column-pin": [payload: { column: string; side: PinSide | false }]
937
+ "column-visibility-change": [payload: { column: string; visible: boolean }]
938
+ "page-change": [payload: { page: number; pageSize: number }]
939
+ "row-reorder": [
940
+ payload: {
941
+ row: R
942
+ key: RowKey
943
+ /** Neuer Wert für das Feld aus `orderField`. */
944
+ order: number
945
+ /** Neue Elternzeile (Baum); null = oberste Ebene. */
946
+ parent: RowKey | null
947
+ /** true, wenn sich der Elternteil geändert hat (dann `move` statt `edit`). */
948
+ parentChanged: boolean
949
+ /** Position unter den neuen Geschwistern. */
950
+ toIndex: number
951
+ /** Nachbarn tragen denselben Wert — die Liste sollte neu durchnummeriert werden. */
952
+ needsReindex: boolean
953
+ },
954
+ ]
955
+ "load-more": [payload: { pages: number | "all"; pageSize: number }]
956
+ "data-request": [request: DataTableRequest]
957
+ "data-error": [payload: { error: unknown; request: DataTableRequest }]
958
+ "children-error": [payload: { error: unknown; key: RowKey; row: R }]
959
+ }>()
960
+
961
+ const uid = useId()
962
+
963
+ // DOM-Refs (erlaubte Ausnahme).
964
+ const scrollerEl = ref<HTMLElement>()
965
+ const bodyEl = ref<HTMLElement>()
966
+
967
+ // Nicht-reaktive Interna.
968
+ let headerObserver: ResizeObserver | undefined
969
+ let detailObserver: ResizeObserver | undefined
970
+ let debounceTimer = 0
971
+ let abortController: AbortController | undefined
972
+ let lastSelectedIndex: number | null = null
973
+
974
+ // Umschließende Drag-Session (Browser): erlaubt, Zeilen AUSSERHALB der Tabelle
975
+ // fallen zu lassen (Navigations-Baum, Ordnerzeilen). Ohne Browser: null.
976
+ const dragSession = inject(DRAG_SESSION, null)
977
+
978
+ const { t } = useI18n()
979
+
980
+ /**
981
+ * Optimistische Verschiebungen (Drag & Drop / Alt+Pfeil): rowKey → neuer
982
+ * Ordnungswert und ggf. neuer Elternteil. Damit verschwindet die Zeile sofort
983
+ * an ihrer alten Stelle und taucht am Ziel (auch IN einem Ordner) auf, ohne
984
+ * auf das Speichern und Neuladen zu warten. Sobald der Aufrufer neue Daten
985
+ * liefert (props.data / Server-Antwort), werden die Überlagerungen verworfen —
986
+ * dann gilt wieder allein der Datenbestand.
987
+ */
988
+ const moveOverrides = shallowRef(new Map<RowKey, { order: number; parent?: RowKey | null }>())
989
+
990
+ /**
991
+ * Optimistische Verschiebungen im NACHLADE-Modus (`loadChildren`): dort leben
992
+ * die Kinder nicht in `props.data`, sondern im Cache `state.loadedChildren` —
993
+ * eine Verschiebung nimmt die Zeile daher direkt aus dem Quell-Bucket und
994
+ * setzt eine Kopie in den Ziel-Bucket. Für die oberste Ebene (props.data darf
995
+ * nicht mutiert werden) gibt es eine Ausblendliste und eingefügte Kopien.
996
+ * Beides wird — wie moveOverrides — verworfen, sobald neue Daten kommen.
997
+ */
998
+ const lazyMoves = shallowRef({ hidden: new Set<RowKey>(), injected: [] as R[] })
999
+
1000
+ const LEAD_WIDTH = 44
1001
+ const ORDER_WIDTH = 36
1002
+
1003
+ /** Ablegen: davor, dahinter oder hinein (wie admin4). */
1004
+ type DropMode = "before" | "after" | "inside"
1005
+
1006
+ interface CellColumn {
1007
+ key: string
1008
+ index: number
1009
+ kind: "select" | "expand" | "data" | "order"
1010
+ id: string
1011
+ sticky: boolean
1012
+ side: PinSide | null
1013
+ offset: number
1014
+ pinEdge: boolean
1015
+ col?: LayoutColumn<R>
1016
+ }
1017
+
1018
+ const layout = useColumnLayout<R>({
1019
+ columns: () => cols(),
1020
+ resizable: () => props.resizable,
1021
+ reorderable: () => props.reorderable,
1022
+ pinnable: () => props.pinnable,
1023
+ onResize: (column, width, finished) => emit("column-resize", { column, width, finished }),
1024
+ onMove: (columnOrder, finished) => emit("column-move", { columnOrder, finished }),
1025
+ onPin: (column, side) => emit("column-pin", { column, side }),
1026
+ onVisibilityChange: (column, visible) => emit("column-visibility-change", { column, visible }),
1027
+ })
1028
+
1029
+ const state = reactive({
1030
+ /** Server-Modus: Zeilen und Gesamtzahl aus der letzten Antwort. */
1031
+ serverRows: [] as R[],
1032
+ serverTotal: -1,
1033
+ fetching: false,
1034
+ announcement: "",
1035
+
1036
+ columns: computed((): ResolvedColumn<R>[] => resolveColumns(props.columns)),
1037
+
1038
+ groupByList: computed((): string[] => {
1039
+ const value = props.groupBy
1040
+ if (!value) return []
1041
+ return (Array.isArray(value) ? value : value.split(/[\s,]+/)).filter(Boolean)
1042
+ }),
1043
+
1044
+ isServer: computed((): boolean => props.server || Boolean(props.dataSource)),
1045
+
1046
+ /**
1047
+ * Hierarchie aus einer flachen Liste (parentField). Null, sobald die Daten
1048
+ * schon verschachtelt sind oder die Voraussetzungen fehlen.
1049
+ */
1050
+ nested: computed((): NestedTree<R> | null => {
1051
+ if (props.childRows || !props.parentField) return null
1052
+ if (!props.rowKey) {
1053
+ console.warn("[DataTable] `parent-field` braucht `row-key`, um Eltern und Kinder zu verbinden")
1054
+ return null
1055
+ }
1056
+ return nestByParent(state.rows as R[], props.rowKey, props.parentField)
1057
+ }),
1058
+
1059
+ /**
1060
+ * Zugriff auf die Kinder: aus `childRows`, aus dem gebauten Baum
1061
+ * (`parentField`) und/oder aus den nachgeladenen Ebenen — in dieser
1062
+ * Reihenfolge zusammengeführt.
1063
+ */
1064
+ childAccessor: computed((): ChildRowsAccessor<R> | null => {
1065
+ const nested = state.nested as NestedTree<R> | null
1066
+ const staticAccess: ChildRowsAccessor<R> | null =
1067
+ props.childRows ?? (nested ? (row: R) => nested.children.get(row[props.rowKey!] as RowKey) : null)
1068
+
1069
+ if (!props.loadChildren) return staticAccess
1070
+
1071
+ return (row: R) => {
1072
+ const own =
1073
+ typeof staticAccess === "function"
1074
+ ? staticAccess(row)
1075
+ : staticAccess
1076
+ ? (row[staticAccess] as R[] | undefined)
1077
+ : undefined
1078
+ if (own?.length) return own
1079
+ return state.loadedChildren[String(rowKeyOf(row))] as R[] | undefined
1080
+ }
1081
+ }),
1082
+
1083
+ isTree: computed((): boolean => Boolean(state.childAccessor)),
1084
+ isLoading: computed((): boolean => props.loading ?? state.fetching),
1085
+
1086
+ /** Nachgeladene Kinder je Zeilenschlüssel (leeres Array = es gibt keine). */
1087
+ loadedChildren: {} as Record<string, R[]>,
1088
+ /** Zeilen, deren Kinder gerade geladen werden. */
1089
+ loadingChildren: {} as Record<string, boolean>,
1090
+
1091
+ /** Laufende Zeilen-Ziehgeste (feste Reihenfolge). */
1092
+ rowDrag: null as {
1093
+ key: RowKey
1094
+ overKey: RowKey | null
1095
+ /** Wie admin4: davor, dahinter oder HINEIN (neuer Elternteil). */
1096
+ mode: DropMode
1097
+ } | null,
1098
+
1099
+ rows: computed((): R[] => {
1100
+ let base = props.dataSource ? (state.serverRows as R[]) : props.data
1101
+ if (props.rowKey && (lazyMoves.value.hidden.size || lazyMoves.value.injected.length)) {
1102
+ const key = props.rowKey
1103
+ const { hidden, injected } = lazyMoves.value
1104
+ base = base.filter((row) => !hidden.has(row[key] as RowKey)).concat(injected)
1105
+ }
1106
+ if (moveOverrides.value.size && props.rowKey) {
1107
+ const key = props.rowKey
1108
+ base = base.map((row) => {
1109
+ const override = moveOverrides.value.get(row[key] as RowKey)
1110
+ if (!override) return row
1111
+ const patched = { ...row }
1112
+ if (props.orderField) (patched as Record<string, unknown>)[props.orderField] = override.order
1113
+ if (props.parentField && override.parent !== undefined) {
1114
+ ;(patched as Record<string, unknown>)[props.parentField] = override.parent
1115
+ }
1116
+ return patched
1117
+ })
1118
+ }
1119
+ // Feste Reihenfolge: numerisch aufsteigend — auch im SERVER-Modus. Nur so
1120
+ // rückt eine per Drag & Drop verschobene Zeile SOFORT an ihren Platz, ohne
1121
+ // die Ebene neu zu laden (den neuen Wert liefert moveOverrides oben).
1122
+ // Eine aktive Spaltensortierung hat Vorrang: dann ordnet im Client-Modus die
1123
+ // Pipeline und im Server-Modus der Server.
1124
+ if (!props.orderField || sort.value.length > 0) return base
1125
+ const field = props.orderField
1126
+ return [...base].sort((a, b) => Number(a[field] ?? 0) - Number(b[field] ?? 0))
1127
+ }),
1128
+
1129
+ /**
1130
+ * Umsortieren ist nur in der festen Reihenfolge sinnvoll — nicht bei
1131
+ * Gruppierung und nicht, während eine Spaltensortierung aktiv ist.
1132
+ *
1133
+ * Im Server-Modus ist es ausdrücklich erlaubt: die Reihenfolge kommt dort vom
1134
+ * Server (`orderby` auf dem Feld), der neue Wert wird aus den Nachbarn
1135
+ * berechnet und per `row-reorder` nach außen gemeldet — gespeichert wird
1136
+ * ohnehin außerhalb.
1137
+ */
1138
+ orderActive: computed(
1139
+ (): boolean => Boolean(props.orderField) && !state.groupByList.length && sort.value.length === 0
1140
+ ),
1141
+
1142
+ sort: computed((): SortEntry[] => sort.value),
1143
+
1144
+ /** Filter/Suche/Sortierung/Gruppierung — im Server-Modus macht das der Server. */
1145
+ built: computed(
1146
+ (): BuiltRows<R> =>
1147
+ buildRows<R>({
1148
+ data: (state.nested as NestedTree<R> | null)?.roots ?? (state.rows as R[]),
1149
+ columns: state.columns,
1150
+ sort: state.isServer ? [] : sort.value,
1151
+ filters: state.isServer ? [] : filters.value,
1152
+ search: state.isServer ? "" : search.value,
1153
+ searchFn: props.searchFn,
1154
+ childRows: state.childAccessor as ChildRowsAccessor<R> | null,
1155
+ filterFromLeafRows: props.filterFromLeafRows,
1156
+ groupBy: state.groupByList,
1157
+ rowKey: props.rowKey,
1158
+ })
1159
+ ),
1160
+
1161
+ /** Cursor-Modus: der Aufrufer steuert das Nachladen (siehe hasMore). */
1162
+ cursorMode: computed((): boolean => props.hasMore !== null && props.hasMore !== undefined),
1163
+ /** Seitengrößen der Cursor-Leiste — die aktuelle Größe ist immer wählbar. */
1164
+ cursorPageSizes: computed((): number[] => {
1165
+ const options = new Set(props.cursorPageSizes)
1166
+ options.add(pageSize.value)
1167
+ return [...options].sort((a, b) => a - b)
1168
+ }),
1169
+ /** „N Einträge" bzw. „N von M" — M nur, wenn der Server eine Gesamtzahl kennt. */
1170
+ loadedText: computed((): string => {
1171
+ const loaded = state.rows.length
1172
+ // Lokale Suche/Filter aktiv: die Zahl der TREFFER zeigen, nicht den
1173
+ // Ladezähler — sonst wirkt die Leiste, als wäre nichts gefiltert.
1174
+ if (!state.isServer && state.filteredCount !== loaded) {
1175
+ return t("dataTable.filteredOf", { filtered: state.filteredCount, loaded })
1176
+ }
1177
+ const total = props.total ?? (state.serverTotal >= 0 ? state.serverTotal : -1)
1178
+ if (total >= 0 && total !== loaded) return t("dataTable.loadedOf", { loaded, total })
1179
+ if (loaded === 1) return t("dataTable.loadedOne")
1180
+ return t("dataTable.loaded", { loaded })
1181
+ }),
1182
+
1183
+ facets: computed((): Record<string, ColumnFacets> => state.built.facets),
1184
+
1185
+ filteredCount: computed((): number =>
1186
+ state.isServer ? (props.total ?? state.serverTotal ?? state.rows.length) : state.built.filteredCount
1187
+ ),
1188
+
1189
+ pageCount: computed((): number => {
1190
+ if (!props.paginate) return 1
1191
+ const total = state.filteredCount
1192
+ return Math.max(1, Math.ceil(total / Math.max(1, pageSize.value)))
1193
+ }),
1194
+
1195
+ pagedNodes: computed((): RowNode<R>[] =>
1196
+ props.paginate && !state.isServer ? paginateNodes(state.built.nodes, page.value, pageSize.value) : state.built.nodes
1197
+ ),
1198
+
1199
+ expandedSet: computed((): Set<RowKey> => new Set(expandedKeys.value)),
1200
+ selectedSet: computed((): Set<RowKey> => new Set(selectedKeys.value)),
1201
+
1202
+ displayRows: computed((): DisplayRow<R>[] =>
1203
+ flattenNodes(state.pagedNodes as RowNode<R>[], {
1204
+ isExpanded: (key) => state.expandedSet.has(key),
1205
+ withDetail: props.rowDetail,
1206
+ // Beim Nachladen ist vorher unbekannt, ob eine Zeile Kinder hat: Pfeil
1207
+ // zeigen, solange das Gegenteil nicht feststeht.
1208
+ mayHaveChildren: props.loadChildren
1209
+ ? (node) => node.kind === "data" && Boolean(node.row) && childState(node.row!) !== "none"
1210
+ : undefined,
1211
+ })
1212
+ ),
1213
+
1214
+ /** Index in der Renderliste je Zeilenschlüssel — für Tastatur und Fokus. */
1215
+ rowIndexOf: computed((): Record<string, number> => {
1216
+ const map: Record<string, number> = {}
1217
+ state.displayRows.forEach((row, index) => {
1218
+ map[row.key] = index
1219
+ })
1220
+ return map
1221
+ }),
1222
+
1223
+ dataDisplayRows: computed((): DisplayRow<R>[] => state.displayRows.filter((row) => row.kind === "data")),
1224
+
1225
+ rowSelection: computed((): Record<string, SelectionState> => {
1226
+ const map: Record<string, SelectionState> = {}
1227
+ for (const row of state.displayRows) {
1228
+ if (row.kind === "detail" || !row.node) continue
1229
+ map[row.key] =
1230
+ row.kind === "data" && !row.node.children.length
1231
+ ? state.selectedSet.has(row.nodeKey)
1232
+ ? "checked"
1233
+ : "unchecked"
1234
+ : selectionStateFor(row.node, state.selectedSet)
1235
+ }
1236
+ return map
1237
+ }),
1238
+
1239
+ selectAllState: computed((): SelectionState => {
1240
+ const keys = selectableKeysOnPage()
1241
+ if (!keys.length) return "unchecked"
1242
+ const hits = keys.filter((key) => state.selectedSet.has(key)).length
1243
+ return hits === 0 ? "unchecked" : hits === keys.length ? "checked" : "indeterminate"
1244
+ }),
1245
+
1246
+ sortIndex: computed((): Record<string, number | undefined> => {
1247
+ const map: Record<string, number | undefined> = {}
1248
+ sort.value.forEach((entry, index) => {
1249
+ map[entry.id] = index
1250
+ })
1251
+ return map
1252
+ }),
1253
+ sortDirs: computed((): Record<string, boolean> => {
1254
+ const map: Record<string, boolean> = {}
1255
+ for (const entry of sort.value) map[entry.id] = entry.desc
1256
+ return map
1257
+ }),
1258
+
1259
+ /** Führende Spalten (Auswahl, Aufklappen) plus die sichtbaren Datenspalten. */
1260
+ cellColumns: computed((): CellColumn[] => {
1261
+ const cells: CellColumn[] = []
1262
+ let lead = 0
1263
+ if (props.orderField) {
1264
+ cells.push({
1265
+ key: "__order",
1266
+ index: cells.length,
1267
+ kind: "order",
1268
+ id: "__order",
1269
+ sticky: true,
1270
+ side: "start",
1271
+ offset: lead,
1272
+ pinEdge: false,
1273
+ })
1274
+ lead += ORDER_WIDTH
1275
+ }
1276
+ if (props.selectable !== false) {
1277
+ cells.push({
1278
+ key: "__select",
1279
+ index: cells.length,
1280
+ kind: "select",
1281
+ id: "__select",
1282
+ sticky: true,
1283
+ side: "start",
1284
+ offset: lead,
1285
+ pinEdge: false,
1286
+ })
1287
+ lead += LEAD_WIDTH
1288
+ }
1289
+ if (state.hasExpandColumn) {
1290
+ cells.push({
1291
+ key: "__expand",
1292
+ index: cells.length,
1293
+ kind: "expand",
1294
+ id: "__expand",
1295
+ sticky: true,
1296
+ side: "start",
1297
+ offset: lead,
1298
+ pinEdge: false,
1299
+ })
1300
+ lead += LEAD_WIDTH
1301
+ }
1302
+ for (const col of layout.state.visibleColumns as LayoutColumn<R>[]) {
1303
+ cells.push({
1304
+ key: col.column.id,
1305
+ index: cells.length,
1306
+ kind: "data",
1307
+ id: col.column.id,
1308
+ sticky: Boolean(col.pinned),
1309
+ side: col.pinned || null,
1310
+ offset: col.pinned === "start" ? col.offset + lead : col.offset,
1311
+ pinEdge: col.pinEdge,
1312
+ col,
1313
+ })
1314
+ }
1315
+ return cells
1316
+ }),
1317
+
1318
+ firstDataCellIndex: computed((): number => state.cellColumns.find((cell) => cell.kind === "data")?.index ?? 0),
1319
+
1320
+ /**
1321
+ * Eigene Aufklapp-Spalte nur für Gruppen und Detail-Panels. Bei Baumdaten
1322
+ * sitzt der Pfeil IN der ersten Datenspalte — nur dort rückt er mit der Ebene
1323
+ * ein, und genau das macht die Hierarchie sichtbar.
1324
+ */
1325
+ hasExpandColumn: computed((): boolean => Boolean(props.rowDetail || state.groupByList.length)),
1326
+
1327
+ gridStyle: computed((): Record<string, string> => {
1328
+ const tracks: string[] = []
1329
+ if (props.orderField) tracks.push(`${ORDER_WIDTH}px`)
1330
+ if (props.selectable !== false) tracks.push(`${LEAD_WIDTH}px`)
1331
+ if (state.hasExpandColumn) tracks.push(`${LEAD_WIDTH}px`)
1332
+ for (const col of layout.state.visibleColumns as LayoutColumn<R>[]) tracks.push(col.track)
1333
+ return { gridTemplateColumns: tracks.join(" ") }
1334
+ }),
1335
+
1336
+ hostStyle: computed((): Record<string, string> => {
1337
+ const style: Record<string, string> = { "--dt-row-height": `${props.rowHeight}px` }
1338
+ // Nur setzen, wenn angegeben — sonst bliebe der Inline-Wert gegen jede
1339
+ // Regel von aussen stehen und `--dt-max-height` waere keine Variable mehr.
1340
+ if (props.maxHeight) style["--dt-max-height"] = props.maxHeight
1341
+ return style
1342
+ }),
1343
+
1344
+ gridRole: computed((): string => (state.isTree ? "treegrid" : "grid")),
1345
+
1346
+ // Panel-Props sind nicht generisch — hier auf die Basisform bringen.
1347
+ filterColumns: computed((): ResolvedColumn[] =>
1348
+ (layout.state.visibleColumns as LayoutColumn<R>[])
1349
+ .map((col) => col.column as unknown as ResolvedColumn)
1350
+ .filter((column) => column.filterable)
1351
+ ),
1352
+
1353
+ footerRows: computed((): R[] => (state.isTree ? state.built.topLevelRows : state.built.dataRows)),
1354
+
1355
+ hasColumnFooters: computed((): boolean =>
1356
+ (layout.state.visibleColumns as LayoutColumn<R>[]).some((col) => col.column.def.footer !== undefined)
1357
+ ),
1358
+
1359
+ footerText: computed((): Record<string, string> => {
1360
+ const map: Record<string, string> = {}
1361
+ for (const col of layout.state.visibleColumns as LayoutColumn<R>[]) {
1362
+ const footer = col.column.def.footer
1363
+ if (footer === undefined) continue
1364
+ map[col.column.id] = typeof footer === "function" ? footer(state.footerRows) : footer
1365
+ }
1366
+ return map
1367
+ }),
1368
+
1369
+ virtualizeActive: computed((): boolean =>
1370
+ props.virtualize === "auto" ? state.displayRows.length > props.virtualizeThreshold : Boolean(props.virtualize)
1371
+ ),
1372
+
1373
+ hasFilterOrSearch: computed(
1374
+ (): boolean => Boolean(search.value.trim()) || filters.value.some((filter) => !isEmptyFilter(filter.value))
1375
+ ),
1376
+
1377
+ showEmpty: computed((): boolean => !state.displayRows.length && !state.hasFilterOrSearch && !state.isLoading),
1378
+ showNoResults: computed((): boolean => !state.displayRows.length && state.hasFilterOrSearch && !state.isLoading),
1379
+ showToolbar: computed((): boolean => props.withSearch || props.withColumnsMenu),
1380
+ })
1381
+
1382
+ // reactive() entfaltet die Generics zu `Row`; diese Helfer geben den Zugriffen
1383
+ // ihren konkreten Typ zurück.
1384
+ const cols = (): ResolvedColumn<R>[] => state.columns as ResolvedColumn<R>[]
1385
+ const displayed = (): DisplayRow<R>[] => state.displayRows as DisplayRow<R>[]
1386
+ const dataRowsOf = (): R[] => state.built.dataRows as R[]
1387
+ const selectedSetOf = (): Set<RowKey> => state.selectedSet as Set<RowKey>
1388
+
1389
+ const virtual = useVirtualRows<R>(scrollerEl, {
1390
+ rows: () => displayed(),
1391
+ rowHeight: () => props.rowHeight,
1392
+ enabled: () => state.virtualizeActive,
1393
+ })
1394
+
1395
+ const nav = useGridNavigation(scrollerEl, {
1396
+ rowCount: () => state.displayRows.length,
1397
+ colCount: () => state.cellColumns.length,
1398
+ pageRows: () => Math.max(1, Math.floor((scrollerEl.value?.clientHeight ?? 400) / props.rowHeight)),
1399
+ onHeaderActivate: (col, shift) => {
1400
+ const cell = state.cellColumns[col]
1401
+ if (cell?.kind === "data") toggleSort(cell.col!.column, shift)
1402
+ else if (cell?.kind === "select") toggleSelectAll()
1403
+ },
1404
+ onCellActivate: (rowIndex, col) => {
1405
+ const displayRow = state.displayRows[rowIndex]
1406
+ const cell = state.cellColumns[col]
1407
+ if (!displayRow) return
1408
+ const onTreeCell = state.isTree && cell?.kind === "data" && cell.index === state.firstDataCellIndex
1409
+ if (displayRow.expandable && (cell?.kind === "expand" || displayRow.kind === "group" || onTreeCell)) {
1410
+ toggleExpand(displayRow)
1411
+ return
1412
+ }
1413
+ if (displayRow.kind === "data" && cell?.kind === "data") {
1414
+ emit("cell-click", {
1415
+ column: cell.col!.column.id,
1416
+ value: cell.col!.column.get(displayRow.row!),
1417
+ row: displayRow.row!,
1418
+ index: displayRow.index,
1419
+ })
1420
+ }
1421
+ },
1422
+ onToggleSelect: (rowIndex) => {
1423
+ const displayRow = state.displayRows[rowIndex]
1424
+ if (displayRow) toggleRowSelection(displayRow, false)
1425
+ },
1426
+ onExtendSelection: (rowIndex) => {
1427
+ const displayRow = state.displayRows[rowIndex]
1428
+ if (displayRow) toggleRowSelection(displayRow, true)
1429
+ },
1430
+ onSelectAll: () => selectAllOnPage(true),
1431
+ onCopy: () => void copySelectedRows(),
1432
+ onContextMenu: (rowIndex, col) => {
1433
+ const displayRow = state.displayRows[rowIndex]
1434
+ const cell = state.cellColumns[col]
1435
+ if (displayRow?.kind !== "data" || cell?.kind !== "data") return
1436
+ emit("cell-contextmenu", {
1437
+ column: cell.col!.column.id,
1438
+ value: cell.col!.column.get(displayRow.row!),
1439
+ row: displayRow.row!,
1440
+ index: displayRow.index,
1441
+ originalEvent: new MouseEvent("contextmenu"),
1442
+ })
1443
+ },
1444
+ onMoveRow: (rowIndex, delta) => moveRowByKeyboard(rowIndex, delta),
1445
+ onMoveColumn: (col, delta) => {
1446
+ const cell = state.cellColumns[col]
1447
+ if (cell?.kind === "data" && cell.col!.movable) layout.shiftColumn(cell.col!.column.id, delta)
1448
+ },
1449
+ onResizeColumn: (col, delta) => {
1450
+ const cell = state.cellColumns[col]
1451
+ if (cell?.kind !== "data" || !cell.col!.resizable) return
1452
+ layout.setWidth(cell.col!.column.id, cell.col!.width + delta)
1453
+ emit("column-resize", {
1454
+ column: cell.col!.column.id,
1455
+ width: cell.col!.width + delta,
1456
+ finished: true,
1457
+ })
1458
+ },
1459
+ })
1460
+
1461
+ // ── Darstellung ──────────────────────────────────────────────────────
1462
+
1463
+ function cellStyle(cell: CellColumn): Record<string, string> {
1464
+ if (!cell.sticky) return {}
1465
+ return cell.side === "end"
1466
+ ? { position: "sticky", insetInlineEnd: `${cell.offset}px`, zIndex: "2" }
1467
+ : { position: "sticky", insetInlineStart: `${cell.offset}px`, zIndex: "2" }
1468
+ }
1469
+
1470
+ /**
1471
+ * Einrückung von Baum- und Gruppenebenen. Sie sitzt auf der ERSTEN Datenspalte
1472
+ * — in der schmalen Aufklapp-Spalte (feste Breite) wäre sie unsichtbar.
1473
+ */
1474
+ /**
1475
+ * Einrückung der Ebenen. Im Baum übernehmen das die Führungsfelder
1476
+ * (`.vi-dt-guide`) — dort bleibt es beim normalen Padding. Gruppen ohne Baum
1477
+ * haben keine Führungslinien und werden weiter über Padding eingerückt.
1478
+ */
1479
+ function indentStyle(cell: CellColumn, displayRow: DisplayRow<R>): Record<string, string> {
1480
+ if (cell.index !== state.firstDataCellIndex || displayRow.depth === 0) return {}
1481
+ if (state.isTree) return {}
1482
+ return {
1483
+ paddingInlineStart: `calc(var(--dt-cell-padding) + var(--dt-indent) * ${displayRow.depth})`,
1484
+ }
1485
+ }
1486
+
1487
+ function cellClass(cell: CellColumn, displayRow: DisplayRow<R>): string {
1488
+ const source = cell.col?.column.def.cellClass
1489
+ if (!source || !displayRow.row) return ""
1490
+ if (typeof source === "string") return source
1491
+ return source(cell.col!.column.get(displayRow.row), displayRow.row) ?? ""
1492
+ }
1493
+
1494
+ function ariaSort(cell: CellColumn): "none" | "ascending" | "descending" | undefined {
1495
+ if (cell.kind !== "data" || !cell.col!.column.sortable) return undefined
1496
+ const index = state.sortIndex[cell.col!.column.id]
1497
+ if (index === undefined) return "none"
1498
+ return state.sortDirs[cell.col!.column.id] ? "descending" : "ascending"
1499
+ }
1500
+
1501
+ function filterButtonId(columnId: string): string {
1502
+ return `${uid}-filter-${columnId.replace(/[^a-zA-Z0-9_-]/g, "_")}`
1503
+ }
1504
+
1505
+ function filterValue(columnId: string): unknown {
1506
+ return filters.value.find((filter) => filter.id === columnId)?.value
1507
+ }
1508
+
1509
+ function groupLabel(displayRow: DisplayRow<R>): string {
1510
+ return defaultText(displayRow.node?.groupValue)
1511
+ }
1512
+
1513
+ function aggregateText(cell: CellColumn, displayRow: DisplayRow<R>): string {
1514
+ if (cell.kind !== "data" || !displayRow.node) return ""
1515
+ const column = cell.col!.column
1516
+ const value = displayRow.node.aggregates?.[column.id]
1517
+ if (value === undefined) return ""
1518
+ const first = displayRow.node.groupRows?.[0]
1519
+ return column.def.formatter && first ? column.def.formatter(value, first) : defaultText(value)
1520
+ }
1521
+
1522
+ // ── Sortierung ───────────────────────────────────────────────────────
1523
+
1524
+ function toggleSort(column: ResolvedColumn<R>, multi: boolean): void {
1525
+ if (!column.sortable) return
1526
+ // Der NEUE Wert wird lokal gehalten und weitergegeben — nicht über das Modell
1527
+ // zurückgelesen (siehe scheduleFetch: bei gebundenem v-model kommt die
1528
+ // Aktualisierung erst mit dem Prop der Eltern).
1529
+ const next = nextSortState(sort.value, column as ResolvedColumn, {
1530
+ multi,
1531
+ descFirst: props.sortDescFirst,
1532
+ withoutRemoval: props.withoutSortRemoval,
1533
+ maxMultiSort: props.maxMultiSort,
1534
+ })
1535
+ sort.value = next
1536
+ emit("sort-change", next)
1537
+ setPage(0, false)
1538
+ if (state.isServer) scheduleFetch(false, { sort: next })
1539
+ }
1540
+
1541
+ function applySort(columnId: string, desc: boolean): void {
1542
+ const next = [{ id: columnId, desc }]
1543
+ sort.value = next
1544
+ emit("sort-change", next)
1545
+ setPage(0, false)
1546
+ if (state.isServer) scheduleFetch(false, { sort: next })
1547
+ }
1548
+
1549
+ function onHeaderClick(cell: CellColumn, event: MouseEvent): void {
1550
+ nav.setActive(-1, cell.index, false)
1551
+ if (cell.kind !== "data") return
1552
+ const target = event.target as HTMLElement
1553
+ // Klicks auf Bedienelemente im Kopf (Filter, Menü, Pin, Resize) nicht sortieren.
1554
+ if (target.closest(".vi-dt-filter-button, .vi-dt-column-menu, .vi-dt-pin, .vi-dt-resize")) return
1555
+ toggleSort(cell.col!.column, event.shiftKey)
1556
+ }
1557
+
1558
+ // ── Suche & Filter ───────────────────────────────────────────────────
1559
+
1560
+ function onSearchInput(value: string): void {
1561
+ search.value = value
1562
+ setPage(0, false)
1563
+ emit("filter-change", { search: value, filters: filters.value })
1564
+ if (state.isServer) scheduleFetch(true, { search: value })
1565
+ }
1566
+
1567
+ function setFilter(columnId: string, value: unknown): void {
1568
+ const rest = filters.value.filter((filter) => filter.id !== columnId)
1569
+ const next = isEmptyFilter(value) ? rest : [...rest, { id: columnId, value }]
1570
+ filters.value = next
1571
+ setPage(0, false)
1572
+ emit("filter-change", { search: search.value, filters: next })
1573
+ if (state.isServer) scheduleFetch(true, { filters: next })
1574
+ }
1575
+
1576
+ // ── Auswahl ──────────────────────────────────────────────────────────
1577
+
1578
+ /** Plaketten einer Zelle (Spalten mit `badge`-Funktion, z. B. select/bool). */
1579
+ function badgesFor(column: ResolvedColumn<R>, row: R): CellBadge[] | null {
1580
+ const badges = column.def.badge?.(column.get(row), row)
1581
+ return badges?.length ? badges : null
1582
+ }
1583
+
1584
+ function canSelect(row: R): boolean {
1585
+ return props.selectableRows ? props.selectableRows(row) : true
1586
+ }
1587
+
1588
+ /**
1589
+ * Auswählbare Schlüssel der aktuellen Seite — inklusive eingeklappter
1590
+ * Teilbäume und Gruppenmitglieder, aber ohne gesperrte Zeilen
1591
+ * (`selectableRows`). Nur Wurzelknoten der Seite besuchen, sonst zählen
1592
+ * ausgeklappte Kinder doppelt.
1593
+ */
1594
+ function selectableKeysOnPage(): RowKey[] {
1595
+ const keys: RowKey[] = []
1596
+ const seen = new Set<RowKey>()
1597
+
1598
+ const visit = (node: RowNode<R>): void => {
1599
+ if (node.kind === "data" && node.row && !seen.has(node.key)) {
1600
+ if (canSelect(node.row)) {
1601
+ seen.add(node.key)
1602
+ keys.push(node.key)
1603
+ }
1604
+ }
1605
+ for (const child of node.children) visit(child)
1606
+ }
1607
+
1608
+ for (const displayRow of displayed()) {
1609
+ if (displayRow.kind === "detail" || displayRow.depth !== 0 || !displayRow.node) continue
1610
+ visit(displayRow.node as RowNode<R>)
1611
+ }
1612
+ return keys
1613
+ }
1614
+
1615
+ function commitSelection(keys: RowKey[]): void {
1616
+ selectedKeys.value = keys
1617
+ emit("row-select", { selectedKeys: keys, rows: resolveSelectedRows(keys) })
1618
+ }
1619
+
1620
+ function resolveSelectedRows(keys: RowKey[]): R[] {
1621
+ const wanted = new Set(keys)
1622
+ return dataRowsOf().filter((row: R, index: number) => {
1623
+ const key = props.rowKey ? (row[props.rowKey] as RowKey) : index
1624
+ return wanted.has(key)
1625
+ })
1626
+ }
1627
+
1628
+ function toggleRowSelection(displayRow: DisplayRow<R>, range: boolean): void {
1629
+ if (props.selectable === false || displayRow.kind === "detail") return
1630
+ if (displayRow.kind === "data" && displayRow.row && !canSelect(displayRow.row)) return
1631
+
1632
+ if (props.selectable === "single") {
1633
+ commitSelection(state.selectedSet.has(displayRow.nodeKey) ? [] : [displayRow.nodeKey])
1634
+ lastSelectedIndex = displayRow.index
1635
+ return
1636
+ }
1637
+
1638
+ const selected: Set<RowKey> = new Set(selectedSetOf())
1639
+
1640
+ if (range && lastSelectedIndex !== null && displayRow.kind === "data") {
1641
+ const from = Math.min(lastSelectedIndex, displayRow.index)
1642
+ const to = Math.max(lastSelectedIndex, displayRow.index)
1643
+ for (const candidate of state.dataDisplayRows as DisplayRow<R>[]) {
1644
+ if (candidate.index < from || candidate.index > to) continue
1645
+ if (candidate.row && !canSelect(candidate.row)) continue
1646
+ selected.add(candidate.nodeKey)
1647
+ }
1648
+ commitSelection([...selected])
1649
+ return
1650
+ }
1651
+
1652
+ // Baum/Gruppe: Kinder mitnehmen.
1653
+ const keys = displayRow.node ? collectKeys(displayRow.node) : [displayRow.nodeKey]
1654
+ const selectable = keys.filter((key) => {
1655
+ const row = findRowByKey(key)
1656
+ return !row || canSelect(row)
1657
+ })
1658
+ const allSelected = selectable.every((key) => selected.has(key))
1659
+ for (const key of selectable) {
1660
+ if (allSelected) selected.delete(key)
1661
+ else selected.add(key)
1662
+ }
1663
+ if (displayRow.kind === "data") lastSelectedIndex = displayRow.index
1664
+ commitSelection([...selected])
1665
+ }
1666
+
1667
+ function findRowByKey(key: RowKey): R | undefined {
1668
+ if (!props.rowKey) return undefined
1669
+ return dataRowsOf().find((row: R) => (row[props.rowKey!] as RowKey) === key)
1670
+ }
1671
+
1672
+ /**
1673
+ * Auswahl per Checkbox. Absichtlich am `click` und nicht am `change`: nur das
1674
+ * Klick-Ereignis trägt `shiftKey`, und darauf beruht die Bereichsauswahl.
1675
+ */
1676
+ function onSelectRow(displayRow: DisplayRow<R>, event: MouseEvent): void {
1677
+ toggleRowSelection(displayRow, event.shiftKey)
1678
+ }
1679
+ // Klick auf die ZEILE (nicht das Kästchen): verhält sich wie ein Dateimanager.
1680
+ // Ein schlichter Klick ersetzt die Auswahl durch diese Zeile; Strg/Cmd nimmt
1681
+ // sie zusätzlich auf bzw. wieder heraus; Shift wählt den Bereich. Das Kästchen
1682
+ // selbst (onSelectRow) toggelt weiterhin additiv.
1683
+ // Bei gedrückter Strg/Cmd- oder Shift-Taste dient der Klick der Mehrfachauswahl —
1684
+ // die Text-Markierung des Browsers wird dafür unterdrückt (preventDefault auf
1685
+ // mousedown verhindert den Markierungsbeginn, der Click kommt trotzdem an).
1686
+ function onCellMouseDown(event: MouseEvent): void {
1687
+ if (props.selectable === false || !props.selectOnRowClick) return
1688
+ if (event.ctrlKey || event.metaKey || event.shiftKey) event.preventDefault()
1689
+ }
1690
+ function selectRowFromClick(displayRow: DisplayRow<R>, event: MouseEvent): void {
1691
+ if (props.selectable !== "multiple" || event.ctrlKey || event.metaKey || event.shiftKey) {
1692
+ toggleRowSelection(displayRow, event.shiftKey)
1693
+ return
1694
+ }
1695
+ if (displayRow.kind === "detail") return
1696
+ if (displayRow.kind === "data" && displayRow.row && !canSelect(displayRow.row)) return
1697
+ // Baum/Gruppe: Kinder mitnehmen (wie beim Kästchen).
1698
+ const keys = (displayRow.node ? collectKeys(displayRow.node) : [displayRow.nodeKey]).filter((key) => {
1699
+ const row = findRowByKey(key)
1700
+ return !row || canSelect(row)
1701
+ })
1702
+ if (displayRow.kind === "data") lastSelectedIndex = displayRow.index
1703
+ commitSelection(keys)
1704
+ }
1705
+
1706
+ function selectAllOnPage(force?: boolean): void {
1707
+ const keys = selectableKeysOnPage()
1708
+ const allSelected = keys.length > 0 && keys.every((key) => selectedSetOf().has(key))
1709
+ const selected: Set<RowKey> = new Set(selectedSetOf())
1710
+ const shouldSelect = force ?? !allSelected
1711
+ for (const key of keys) {
1712
+ if (shouldSelect) selected.add(key)
1713
+ else selected.delete(key)
1714
+ }
1715
+ commitSelection([...selected])
1716
+ state.announcement = shouldSelect
1717
+ ? t("dataTable.rowsSelected", { count: keys.length })
1718
+ : t("dataTable.selectionCleared")
1719
+ }
1720
+
1721
+ function toggleSelectAll(): void {
1722
+ selectAllOnPage()
1723
+ }
1724
+
1725
+ // ── Kinder nachladen ─────────────────────────────────────────────────
1726
+
1727
+ function rowKeyOf(row: R): RowKey {
1728
+ return props.rowKey ? (row[props.rowKey] as RowKey) : ""
1729
+ }
1730
+
1731
+ /**
1732
+ * Was wissen wir über die Kinder dieser Zeile?
1733
+ * "none" → sicher keine (die Anwendung sagt es über `rowHasChildren`)
1734
+ * "empty" → nachgeladen, es gab keine → Pfeil bleibt, aber deaktiviert
1735
+ * "loaded" → nachgeladen, es gibt Kinder
1736
+ * "unknown" → noch nicht geladen → Pfeil aktiv
1737
+ */
1738
+ function childState(row: R): "none" | "empty" | "loaded" | "unknown" {
1739
+ if (props.rowHasChildren?.(row) === false) return "none"
1740
+ const cached = state.loadedChildren[String(rowKeyOf(row))] as R[] | undefined
1741
+ if (!cached) return "unknown"
1742
+ return cached.length ? "loaded" : "empty"
1743
+ }
1744
+
1745
+ function isLoadingChildren(row?: R): boolean {
1746
+ return row ? Boolean(state.loadingChildren[String(rowKeyOf(row))]) : false
1747
+ }
1748
+
1749
+ /** Pfeil ohne Wirkung: nachgeladen und es gibt nichts. */
1750
+ function isEmptyBranch(displayRow: DisplayRow<R>): boolean {
1751
+ if (!props.loadChildren || displayRow.kind !== "data" || !displayRow.row) return false
1752
+ return childState(displayRow.row) === "empty" && !displayRow.node?.children.length
1753
+ }
1754
+
1755
+ /** Kinder einer Zeile holen (einmalig; `force` lädt erneut). */
1756
+ async function fetchChildren(row: R, force = false): Promise<void> {
1757
+ if (!props.loadChildren) return
1758
+ const key = String(rowKeyOf(row))
1759
+ if (!force && state.loadedChildren[key]) return
1760
+ if (state.loadingChildren[key]) return
1761
+
1762
+ state.loadingChildren[key] = true
1763
+ try {
1764
+ const children = await props.loadChildren(row)
1765
+ state.loadedChildren[key] = (children ?? []) as (typeof state.loadedChildren)[string]
1766
+ } catch (error) {
1767
+ emit("children-error", { error, key: rowKeyOf(row), row })
1768
+ console.warn("[DataTable] Kinder konnten nicht geladen werden", error)
1769
+ } finally {
1770
+ delete state.loadingChildren[key]
1771
+ }
1772
+ }
1773
+
1774
+ // ── Aufklappen ───────────────────────────────────────────────────────
1775
+
1776
+ function toggleExpand(displayRow: DisplayRow<R>): void {
1777
+ if (!displayRow.expandable) return
1778
+ if (isEmptyBranch(displayRow) || isLoadingChildren(displayRow.row)) return
1779
+
1780
+ // Erst laden, dann aufklappen — sonst klappt eine leere Ebene auf und
1781
+ // wieder zu, sobald die Antwort kommt.
1782
+ if (props.loadChildren && displayRow.kind === "data" && displayRow.row) {
1783
+ const row = displayRow.row
1784
+ if (childState(row) === "unknown" && !displayRow.node?.children.length) {
1785
+ void fetchChildren(row).then(() => {
1786
+ if (childState(row) !== "empty") expand(displayRow.nodeKey, row)
1787
+ })
1788
+ return
1789
+ }
1790
+ }
1791
+
1792
+ const keys = new Set(expandedKeys.value)
1793
+ if (keys.has(displayRow.nodeKey)) {
1794
+ keys.delete(displayRow.nodeKey)
1795
+ expandedKeys.value = [...keys]
1796
+ emit("row-collapse", { key: displayRow.nodeKey, row: displayRow.row })
1797
+ } else {
1798
+ keys.add(displayRow.nodeKey)
1799
+ expandedKeys.value = [...keys]
1800
+ emit("row-expand", { key: displayRow.nodeKey, row: displayRow.row })
1801
+ }
1802
+ }
1803
+
1804
+ /** Zeile aufklappen und melden. */
1805
+ function expand(key: RowKey, row?: R): void {
1806
+ if (expandedKeys.value.includes(key)) return
1807
+ expandedKeys.value = [...expandedKeys.value, key]
1808
+ emit("row-expand", { key, row })
1809
+ }
1810
+
1811
+ function allExpandableKeys(nodes: RowNode<R>[] = state.built.nodes as RowNode<R>[], into: RowKey[] = []): RowKey[] {
1812
+ for (const node of nodes) {
1813
+ if (node.children.length || (props.rowDetail && node.kind === "data")) into.push(node.key)
1814
+ if (node.children.length) allExpandableKeys(node.children, into)
1815
+ }
1816
+ return into
1817
+ }
1818
+
1819
+ // ── Zellklicks ───────────────────────────────────────────────────────
1820
+
1821
+ /**
1822
+ * Doppelklick auf einer Datenzeile. Bedienelemente in der Zeile (Auswahl,
1823
+ * Aufklappen, Aktionen) lösen ihn nicht aus — dort doppelt zu klicken meint
1824
+ * nicht „Datensatz öffnen".
1825
+ */
1826
+ function onRowDoubleClick(displayRow: DisplayRow<R>, event: MouseEvent): void {
1827
+ if (displayRow.kind !== "data" || !displayRow.row) return
1828
+ const target = event.target as HTMLElement
1829
+ if (target.closest("wa-checkbox, wa-button, a, input, select, .vi-dt-expand")) return
1830
+ // Ein Doppelklick löst zwei Klicks aus, die die Auswahl per Klick wieder
1831
+ // aufheben würden — die Zeile bleibt/wird ausgewählt.
1832
+ if (props.selectable !== false && props.selectOnRowClick && canSelect(displayRow.row)) {
1833
+ const key = displayRow.nodeKey
1834
+ if (!state.selectedSet.has(key)) {
1835
+ commitSelection(props.selectable === "single" ? [key] : [...selectedSetOf(), key])
1836
+ }
1837
+ }
1838
+ emit("row-dblclick", {
1839
+ key: displayRow.nodeKey,
1840
+ row: displayRow.row,
1841
+ index: displayRow.index,
1842
+ originalEvent: event,
1843
+ })
1844
+ }
1845
+
1846
+ function onCellClick(displayRow: DisplayRow<R>, cell: CellColumn, event: MouseEvent): void {
1847
+ nav.setActive(state.rowIndexOf[displayRow.key] ?? 0, cell.index, false)
1848
+ if (displayRow.kind === "group") {
1849
+ toggleExpand(displayRow)
1850
+ return
1851
+ }
1852
+ if (cell.kind !== "data" || !displayRow.row) return
1853
+ const target = event.target as HTMLElement
1854
+ if (target.closest(".vi-dt-expand, .vi-dt-checkbox")) return
1855
+ // Zeile per Klick auswählen — außer der Klick trifft ein interaktives Element
1856
+ // in der Zelle (Link, Button, Eingabefeld), das sich selbst um den Klick kümmert.
1857
+ if (
1858
+ props.selectable !== false &&
1859
+ props.selectOnRowClick &&
1860
+ displayRow.kind === "data" &&
1861
+ !target.closest("a, button, input, select, textarea, label, [contenteditable], wa-button, wa-checkbox, wa-switch")
1862
+ ) {
1863
+ selectRowFromClick(displayRow, event)
1864
+ }
1865
+ emit("cell-click", {
1866
+ column: cell.col!.column.id,
1867
+ value: cell.col!.column.get(displayRow.row),
1868
+ row: displayRow.row,
1869
+ index: displayRow.index,
1870
+ })
1871
+ }
1872
+
1873
+ function onCellContextMenu(displayRow: DisplayRow<R>, cell: CellColumn, event: MouseEvent): void {
1874
+ if (cell.kind !== "data" || !displayRow.row) return
1875
+ emit("cell-contextmenu", {
1876
+ column: cell.col!.column.id,
1877
+ value: cell.col!.column.get(displayRow.row),
1878
+ row: displayRow.row,
1879
+ index: displayRow.index,
1880
+ originalEvent: event,
1881
+ })
1882
+ }
1883
+
1884
+ // ── Feste Reihenfolge: Zeilen umsortieren ────────────────────────────
1885
+
1886
+ /**
1887
+ * Zeilen werden mit ZEIGER-Ereignissen gezogen, nicht mit HTML5-Drag-and-Drop.
1888
+ * Grund: `dragover` verlangt ein akzeptierendes Ziel und feuert je nach
1889
+ * Umgebung unzuverlässig (bei Touch gar nicht) — die Einfügemarke bliebe dann
1890
+ * aus und die Zielposition wäre Zufall. Pointer-Events verhalten sich überall
1891
+ * gleich, funktionieren mit Touch und sind testbar.
1892
+ */
1893
+ function beginRowDrag(displayRow: DisplayRow<R>, event: PointerEvent): void {
1894
+ if (!state.orderActive || displayRow.kind !== "data") return
1895
+ state.rowDrag = { key: displayRow.nodeKey, overKey: null, mode: "after" }
1896
+
1897
+ if (dragSession) {
1898
+ const keys = movingKeysFor(displayRow.nodeKey)
1899
+ dragSession.start({
1900
+ keys,
1901
+ rows: keys.map((movingKey) => findRowByKey(movingKey) ?? displayRow.row!).filter(Boolean) as R[],
1902
+ source: props.dragSource,
1903
+ })
1904
+ }
1905
+
1906
+ const move = (moveEvent: PointerEvent) => updateRowDrag(moveEvent.clientX, moveEvent.clientY)
1907
+ const up = (upEvent: PointerEvent) => {
1908
+ window.removeEventListener("pointermove", move)
1909
+ window.removeEventListener("pointerup", up)
1910
+ updateRowDrag(upEvent.clientX, upEvent.clientY)
1911
+ commitRowDrag()
1912
+ }
1913
+ window.addEventListener("pointermove", move)
1914
+ window.addEventListener("pointerup", up)
1915
+ event.stopPropagation()
1916
+ }
1917
+
1918
+ /**
1919
+ * Zeile unter dem Zeiger bestimmen und die Zone ableiten. Wie im admin4 gibt es
1920
+ * drei: oberes Viertel = davor, unteres Viertel = dahinter, die Mitte = HINEIN
1921
+ * (die Zeile wird Kind des Ziels). Ohne Baum bleibt nur davor/dahinter.
1922
+ */
1923
+ function updateRowDrag(clientX: number, clientY: number): void {
1924
+ const drag = state.rowDrag
1925
+ if (!drag) return
1926
+ // Erst die umgebende Session fragen: liefert sie ein Ziel (Baumknoten oder
1927
+ // Ordnerzeile), übernimmt sie Anzeige und Drop — der interne Indikator ruht.
1928
+ if (dragSession?.state.active) {
1929
+ const external = dragSession.update(clientX, clientY)
1930
+ if (external) {
1931
+ drag.overKey = null
1932
+ return
1933
+ }
1934
+ }
1935
+ const element = document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>(".vi-dt-row.is-data")
1936
+ if (!element || !scrollerEl.value?.contains(element)) return
1937
+ const target = displayed().find((row) => row.key === element.dataset.key)
1938
+ if (!target || target.nodeKey === drag.key) return
1939
+
1940
+ // In den eigenen Teilbaum ziehen ist nicht möglich — er hinge an sich selbst.
1941
+ if (isDescendant(state.built.nodes as RowNode<R>[], drag.key, target.nodeKey)) {
1942
+ drag.overKey = null
1943
+ return
1944
+ }
1945
+
1946
+ const rect = element.getBoundingClientRect()
1947
+ const offset = (clientY - rect.top) / rect.height
1948
+ drag.overKey = target.nodeKey
1949
+ if (!state.isTree) drag.mode = offset > 0.5 ? "after" : "before"
1950
+ else if (offset < 0.25) drag.mode = "before"
1951
+ else if (offset > 0.75) drag.mode = "after"
1952
+ else drag.mode = "inside"
1953
+ }
1954
+
1955
+ /** Nachlade-Modus: Zeile aus ihrem alten Bucket nehmen und am Ziel einsetzen. */
1956
+ function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, insertAt: number, order: number): void {
1957
+ const row = source.node.row
1958
+ if (!row) return
1959
+ const key = source.node.key
1960
+ const moved = { ...row } as R
1961
+ if (props.orderField) (moved as Record<string, unknown>)[props.orderField] = order
1962
+ if (props.parentField) (moved as Record<string, unknown>)[props.parentField] = targetParent
1963
+
1964
+ const hidden = new Set(lazyMoves.value.hidden)
1965
+ let injected = lazyMoves.value.injected.filter((candidate) => (candidate[props.rowKey!] as RowKey) !== key)
1966
+
1967
+ // Aus der Quelle entfernen …
1968
+ if (source.parentKey === null) {
1969
+ hidden.add(key) // oberste Ebene: props.data bleibt unangetastet, nur ausblenden
1970
+ } else {
1971
+ const bucket = state.loadedChildren[String(source.parentKey)] as R[] | undefined
1972
+ if (bucket) {
1973
+ state.loadedChildren[String(source.parentKey)] = bucket.filter(
1974
+ (candidate) => (candidate[props.rowKey!] as RowKey) !== key
1975
+ ) as (typeof state.loadedChildren)[string]
1976
+ }
1977
+ }
1978
+
1979
+ // … und am Ziel einsetzen.
1980
+ if (targetParent === null) {
1981
+ // Original (falls es in props.data steht) bleibt ausgeblendet; sichtbar
1982
+ // ist die Kopie mit neuem Ordnungswert.
1983
+ hidden.add(key)
1984
+ injected = [...injected, moved]
1985
+ } else {
1986
+ const bucket = state.loadedChildren[String(targetParent)] as R[] | undefined
1987
+ if (bucket) {
1988
+ const next = [...bucket]
1989
+ next.splice(Math.max(0, Math.min(insertAt, next.length)), 0, moved)
1990
+ state.loadedChildren[String(targetParent)] = next as (typeof state.loadedChildren)[string]
1991
+ }
1992
+ // Bucket noch nie geladen: beim Aufklappen holt fetchChildren den Stand
1993
+ // vom Server — der enthält die Zeile nach dem Speichern ohnehin.
1994
+ }
1995
+
1996
+ lazyMoves.value = { hidden, injected }
1997
+ }
1998
+
1999
+ /**
2000
+ * Nachlade-Modus zurücksetzen: Kinder-Cache verwerfen und alle Zweige
2001
+ * zuklappen — die Pfeile stehen danach wieder auf „zu", und das nächste
2002
+ * Aufklappen holt den frischen Stand vom Server. Läuft bei `reload()` und
2003
+ * wenn der Aufrufer neue Daten liefert; ohne `loadChildren` (parentField-
2004
+ * Bäume) bleibt die Aufklapp-Situation erhalten, dort sind die Kinder ohnehin
2005
+ * immer aus den aktuellen Daten abgeleitet.
2006
+ */
2007
+ function resetLazyTree(): void {
2008
+ if (!props.loadChildren) return
2009
+ state.loadedChildren = {}
2010
+ state.loadingChildren = {}
2011
+ if (expandedKeys.value.length) expandedKeys.value = []
2012
+ }
2013
+
2014
+ // Drag & Drop ist rein CLIENTSEITIG: die Zeile wird lokal verschoben (Buckets/
2015
+ // Überlagerungen oben), `row-reorder` meldet den Zug zum Speichern im
2016
+ // Hintergrund — die Tabelle lädt danach NICHTS neu, und die App muss es auch
2017
+ // nicht. Kommen von außen neue Daten herein, sind sie maßgeblich: die
2018
+ // optimistischen Überlagerungen werden verworfen (frische Daten enthalten
2019
+ // einen gespeicherten Zug bereits). Der Nachlade-Baum bleibt dabei stehen —
2020
+ // zugeklappt und geleert wird er nur über `reload()`/`resetTree()` oder, per
2021
+ // Opt-in `resetTreeOnDataChange`, bei jedem Datenwechsel ohne vorangegangene
2022
+ // Verschiebung.
2023
+ watch(
2024
+ () => (props.dataSource ? (state.serverRows as R[]) : props.data),
2025
+ () => {
2026
+ const afterMove =
2027
+ moveOverrides.value.size > 0 || lazyMoves.value.hidden.size > 0 || lazyMoves.value.injected.length > 0
2028
+ if (moveOverrides.value.size) moveOverrides.value = new Map()
2029
+ if (lazyMoves.value.hidden.size || lazyMoves.value.injected.length) {
2030
+ lazyMoves.value = { hidden: new Set(), injected: [] }
2031
+ }
2032
+ if (props.resetTreeOnDataChange && !afterMove) resetLazyTree()
2033
+ }
2034
+ )
2035
+
2036
+ function commitRowDrag(): void {
2037
+ const drag = state.rowDrag
2038
+ state.rowDrag = null
2039
+ // Ein Session-Ziel (Baum/Ordner außerhalb der internen Logik) hat Vorrang:
2040
+ // der Browser übernimmt den Zug, hier passiert dann nichts mehr.
2041
+ if (dragSession?.state.active) {
2042
+ if (dragSession.state.over) {
2043
+ dragSession.drop()
2044
+ return
2045
+ }
2046
+ dragSession.cancel()
2047
+ }
2048
+ if (drag?.overKey !== null && drag?.overKey !== undefined) {
2049
+ moveRowTo(drag.key, drag.overKey, drag.mode)
2050
+ }
2051
+ }
2052
+
2053
+ /**
2054
+ * Zeile an eine neue Stelle setzen und den neuen Ordnungswert melden.
2055
+ *
2056
+ * Gerechnet wird immer im Kreis der künftigen GESCHWISTER — bei „hinein" also
2057
+ * unter den Kindern des Ziels, sonst unter den Geschwistern des Ziels. Nur so
2058
+ * ergibt der Wert im Baum Sinn: `sortindex` gilt je Elternteil.
2059
+ */
2060
+ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2061
+ if (!props.orderField || key === targetKey) return
2062
+ const field = props.orderField
2063
+ const nodes = state.built.nodes as RowNode<R>[]
2064
+
2065
+ const target = locateNode(nodes, targetKey)
2066
+ if (!target) return
2067
+
2068
+ // Mehrfachauswahl: gehört die gezogene Zeile zur Auswahl, wandert die ganze
2069
+ // Auswahl mit — in Anzeige-Reihenfolge. Zeilen, die nicht ans Ziel dürfen
2070
+ // (das Ziel selbst, Vorfahren des Ziels), bleiben außen vor.
2071
+ const moving = movingKeysFor(key).filter(
2072
+ (candidate) => candidate !== targetKey && !isDescendant(nodes, candidate, targetKey)
2073
+ )
2074
+ if (!moving.length) return
2075
+ const movingSet = new Set(moving)
2076
+
2077
+ const orderOf = (node: RowNode<R>): number => Number(node.row?.[field] ?? 0)
2078
+
2079
+ let parentKey: RowKey | null
2080
+ let insertAt: number
2081
+ let values: number[]
2082
+
2083
+ if (mode === "inside") {
2084
+ // Neue Kinder: hinter die vorhandenen Kinder des Ziels.
2085
+ parentKey = targetKey
2086
+ const children = target.node.children.filter((child) => !movingSet.has(child.key))
2087
+ values = children.map(orderOf)
2088
+ insertAt = values.length
2089
+ } else {
2090
+ parentKey = target.parentKey
2091
+ const siblings = target.siblings.filter((node) => !movingSet.has(node.key))
2092
+ values = siblings.map(orderOf)
2093
+ const targetIndex = siblings.findIndex((node) => node.key === targetKey)
2094
+ insertAt = mode === "after" ? targetIndex + 1 : targetIndex
2095
+ }
2096
+
2097
+ let movedCount = 0
2098
+ let parentChangedAny = false
2099
+ const firstInsertAt = insertAt
2100
+
2101
+ for (const movingKey of moving) {
2102
+ const source = locateNode(nodes, movingKey)
2103
+ if (!source?.node.row) continue
2104
+
2105
+ const { order, needsReindex } = orderValueFor(values, insertAt, props.orderStep)
2106
+ // Den vergebenen Wert in die Nachbarliste übernehmen, damit die nächste
2107
+ // Zeile der Auswahl DAHINTER einsortiert wird (Reihenfolge bleibt erhalten).
2108
+ values.splice(Math.max(0, Math.min(values.length, insertAt)), 0, order)
2109
+
2110
+ // Optimistisch übernehmen: die Zeile verlässt ihre alte Stelle sofort und
2111
+ // erscheint am Ziel — beim Drop IN einen Ordner also unter dessen Kindern.
2112
+ if (props.rowKey) {
2113
+ if (props.loadChildren) {
2114
+ applyLazyMove(source, parentKey, insertAt, order)
2115
+ } else {
2116
+ const next = new Map(moveOverrides.value)
2117
+ next.set(movingKey, { order, parent: props.parentField ? parentKey : undefined })
2118
+ moveOverrides.value = next
2119
+ }
2120
+ }
2121
+
2122
+ emit("row-reorder", {
2123
+ row: source.node.row,
2124
+ key: movingKey,
2125
+ order,
2126
+ parent: parentKey,
2127
+ parentChanged: parentKey !== source.parentKey,
2128
+ toIndex: insertAt,
2129
+ needsReindex,
2130
+ })
2131
+ if (parentKey !== source.parentKey) parentChangedAny = true
2132
+ movedCount++
2133
+ insertAt++
2134
+ }
2135
+
2136
+ if (!movedCount) return
2137
+ const movedParams = { count: movedCount, position: firstInsertAt + 1 }
2138
+ state.announcement =
2139
+ movedCount > 1
2140
+ ? parentChangedAny
2141
+ ? t("dataTable.rowsMovedLevel", movedParams)
2142
+ : t("dataTable.rowsMoved", movedParams)
2143
+ : parentChangedAny
2144
+ ? t("dataTable.rowMovedLevel", movedParams)
2145
+ : t("dataTable.rowMoved", movedParams)
2146
+ }
2147
+
2148
+ /**
2149
+ * Welche Zeilen ein Zug bewegt: nur die gezogene — oder, wenn sie Teil der
2150
+ * Auswahl ist, alle ausgewählten Zeilen in Anzeige-Reihenfolge. Ausgewählte
2151
+ * Nachfahren einer ebenfalls ausgewählten Zeile wandern implizit mit ihrem
2152
+ * Teilbaum und werden nicht doppelt bewegt.
2153
+ */
2154
+ function movingKeysFor(key: RowKey): RowKey[] {
2155
+ if (props.selectable === false || !state.selectedSet.has(key) || state.selectedSet.size < 2) return [key]
2156
+ const selected = state.selectedSet as Set<RowKey>
2157
+ const nodes = state.built.nodes as RowNode<R>[]
2158
+ const ordered: RowKey[] = []
2159
+ for (const row of state.dataDisplayRows as DisplayRow<R>[]) {
2160
+ if (selected.has(row.nodeKey)) ordered.push(row.nodeKey)
2161
+ }
2162
+ // dataDisplayRows enthält nur SICHTBARE Zeilen — ausgewählte Zeilen in
2163
+ // zugeklappten Zweigen hängen wir hinten an, damit sie nicht liegenbleiben.
2164
+ const seen = new Set(ordered)
2165
+ for (const candidate of selected) {
2166
+ if (!seen.has(candidate)) ordered.push(candidate)
2167
+ }
2168
+ const selectedList = [...selected]
2169
+ const result = ordered.filter(
2170
+ (candidate) => !selectedList.some((other) => other !== candidate && isDescendant(nodes, other, candidate))
2171
+ )
2172
+ return result.length ? result : [key]
2173
+ }
2174
+
2175
+ /** Zeilenschlüssel wie in der Pipeline (rowKey oder Indexpfad). */
2176
+ function keyOfRow(row: R, index: number): RowKey {
2177
+ return props.rowKey ? (row[props.rowKey] as RowKey) : String(index)
2178
+ }
2179
+
2180
+ /**
2181
+ * Tastatur: Zeile um eine Position verschieben (Alt + ↑/↓) — innerhalb ihrer
2182
+ * GESCHWISTER. Ein Elternwechsel bleibt der Maus vorbehalten; per Tastatur
2183
+ * wäre nicht erkennbar, in welche Ebene man zielt.
2184
+ */
2185
+ function moveRowByKeyboard(rowIndex: number, delta: number): void {
2186
+ if (!state.orderActive) return
2187
+ const displayRow = state.displayRows[rowIndex]
2188
+ if (displayRow?.kind !== "data") return
2189
+ const location = locateNode(state.built.nodes as RowNode<R>[], displayRow.nodeKey)
2190
+ if (!location) return
2191
+ const neighbour = location.siblings[location.index + delta]
2192
+ if (!neighbour) return
2193
+ moveRowTo(displayRow.nodeKey, neighbour.key, delta > 0 ? "after" : "before")
2194
+ nav.setActive(rowIndex + delta, nav.state.col)
2195
+ }
2196
+
2197
+ // ── Spalten: Resize, Reorder, Pin ────────────────────────────────────
2198
+
2199
+ function onResizeStart(columnId: string, event: PointerEvent): void {
2200
+ layout.startResize(columnId, event.clientX)
2201
+ const move = (moveEvent: PointerEvent) => layout.moveResize(moveEvent.clientX)
2202
+ const up = () => {
2203
+ layout.endResize()
2204
+ window.removeEventListener("pointermove", move)
2205
+ window.removeEventListener("pointerup", up)
2206
+ }
2207
+ window.addEventListener("pointermove", move)
2208
+ window.addEventListener("pointerup", up)
2209
+ }
2210
+
2211
+ function onDragStart(cell: CellColumn, event: DragEvent): void {
2212
+ if (cell.kind !== "data" || !cell.col!.movable) return
2213
+ event.dataTransfer?.setData("text/plain", cell.col!.column.id)
2214
+ layout.startDrag(cell.col!.column.id, event.clientX, event.clientY)
2215
+ }
2216
+
2217
+ function onDragOver(cell: CellColumn, event: DragEvent): void {
2218
+ if (!layout.state.dragging || cell.kind !== "data") return
2219
+ event.preventDefault()
2220
+ layout.dragOver(cell.col!.column.id, event.clientX, event.clientY)
2221
+ }
2222
+
2223
+ function pinColumn(columnId: string, side: PinSide | false): void {
2224
+ layout.pinColumn(columnId, side)
2225
+ }
2226
+
2227
+ /**
2228
+ * Spalte auf die Breite ihres breitesten Inhalts bringen.
2229
+ *
2230
+ * Gemessen wird der TEXT, nicht die Box: der Inhalt sitzt in einem
2231
+ * Block-Element, das die Zelle ausfüllt — dessen scrollWidth wäre also immer
2232
+ * die aktuelle Spaltenbreite, und Autosize könnte nie verkleinern. Ein Range
2233
+ * über die Knoteninhalte liefert die echte Textbreite; für Komponenten-Inhalte
2234
+ * (Badges, Tags) fällt die Messung auf scrollWidth zurück.
2235
+ */
2236
+ function autoSizeColumn(columnId: string): void {
2237
+ const cells = scrollerEl.value?.querySelectorAll<HTMLElement>(`[data-column="${columnId}"]`)
2238
+ if (!cells?.length) return
2239
+ const range = document.createRange()
2240
+ let widest = 0
2241
+ for (const cell of cells) {
2242
+ const content = cell.querySelector<HTMLElement>(".wa-ellipsis, .vi-dt-hlabel") ?? cell
2243
+ let measured = 0
2244
+ if (content.firstChild) {
2245
+ range.selectNodeContents(content)
2246
+ measured = range.getBoundingClientRect().width
2247
+ }
2248
+ widest = Math.max(widest, Math.max(measured, content.scrollWidth === content.clientWidth ? 0 : content.scrollWidth))
2249
+ }
2250
+ const width = Math.ceil(widest) + 40 // Polsterung + Werkzeuge im Kopf
2251
+ layout.setWidth(columnId, width)
2252
+ emit("column-resize", { column: columnId, width, finished: true })
2253
+ }
2254
+
2255
+ function autoSizeColumns(): void {
2256
+ for (const col of layout.state.visibleColumns as LayoutColumn<R>[]) autoSizeColumn(col.column.id)
2257
+ }
2258
+
2259
+ function sizeColumnsToFit(): void {
2260
+ const available = scrollerEl.value?.clientWidth ?? 0
2261
+ const columns = layout.state.visibleColumns as LayoutColumn<R>[]
2262
+ if (!available || !columns.length) return
2263
+ let lead = 0
2264
+ if (props.orderField) lead += ORDER_WIDTH
2265
+ if (props.selectable !== false) lead += LEAD_WIDTH
2266
+ if (state.hasExpandColumn) lead += LEAD_WIDTH
2267
+ const each = Math.floor((available - lead) / columns.length)
2268
+ for (const col of columns) layout.setWidth(col.column.id, each)
2269
+ }
2270
+
2271
+ function resetColumns(): void {
2272
+ layout.resetColumns()
2273
+ }
2274
+
2275
+ // ── Paginierung ──────────────────────────────────────────────────────
2276
+
2277
+ function setPage(next: number, notify = true): void {
2278
+ const clamped = Math.max(0, Math.min(state.pageCount - 1, next))
2279
+ if (clamped === page.value) return
2280
+ page.value = clamped
2281
+ if (notify) emit("page-change", { page: clamped, pageSize: pageSize.value })
2282
+ nav.clamp()
2283
+ if (state.isServer) scheduleFetch(false, { page: clamped })
2284
+ }
2285
+
2286
+ /** Cursor-Leiste: neue Seitengröße — der Aufrufer lädt damit neu (page-change). */
2287
+ function onCursorPageSize(next: number): void {
2288
+ pageSize.value = next
2289
+ emit("page-change", { page: 0, pageSize: next })
2290
+ }
2291
+
2292
+ function setPageSize(next: number): void {
2293
+ pageSize.value = next
2294
+ page.value = 0
2295
+ emit("page-change", { page: 0, pageSize: next })
2296
+ if (state.isServer) scheduleFetch(false, { page: 0, pageSize: next })
2297
+ }
2298
+
2299
+ // ── Server-Modus ─────────────────────────────────────────────────────
2300
+
2301
+ /**
2302
+ * Was der nächste Server-Abruf gegenüber den Modellen abweichend verwenden soll.
2303
+ *
2304
+ * Nötig, weil Vue bei GEBUNDENEM `v-model` den lokalen Wert eines
2305
+ * `defineModel` nicht sofort setzt: die Aktualisierung kommt erst über den Prop
2306
+ * der Eltern. Ein Lesen direkt nach dem Schreiben liefert also den alten Wert —
2307
+ * unbemerkt, solange niemand `v-model:sort` & Co. bindet, und beim ersten
2308
+ * Sortierklick sichtbar falsch, sobald doch.
2309
+ */
2310
+ type RequestOverrides = Partial<Pick<DataTableRequest, "sort" | "filters" | "search" | "page" | "pageSize">>
2311
+ let pendingRequest: RequestOverrides = {}
2312
+
2313
+ function buildRequest(signal: AbortSignal): DataTableRequest {
2314
+ const request: DataTableRequest = {
2315
+ sort: pendingRequest.sort ?? sort.value,
2316
+ filters: pendingRequest.filters ?? filters.value,
2317
+ search: pendingRequest.search ?? search.value,
2318
+ page: pendingRequest.page ?? page.value,
2319
+ pageSize: pendingRequest.pageSize ?? pageSize.value,
2320
+ signal,
2321
+ }
2322
+ pendingRequest = {}
2323
+ return request
2324
+ }
2325
+
2326
+ function scheduleFetch(debounced: boolean, overrides: RequestOverrides = {}): void {
2327
+ if (!state.isServer) return
2328
+ pendingRequest = { ...pendingRequest, ...overrides }
2329
+ if (debounceTimer) window.clearTimeout(debounceTimer)
2330
+ if (!debounced) {
2331
+ void fetchRows()
2332
+ return
2333
+ }
2334
+ debounceTimer = window.setTimeout(() => {
2335
+ debounceTimer = 0
2336
+ void fetchRows()
2337
+ }, props.filterDebounce)
2338
+ }
2339
+
2340
+ async function fetchRows(): Promise<void> {
2341
+ abortController?.abort()
2342
+ abortController = new AbortController()
2343
+ const request = buildRequest(abortController.signal)
2344
+
2345
+ if (!props.dataSource) {
2346
+ emit("data-request", request)
2347
+ return
2348
+ }
2349
+
2350
+ state.fetching = true
2351
+ try {
2352
+ const response = await props.dataSource(request)
2353
+ if (request.signal.aborted) return
2354
+ state.serverRows = response.rows as typeof state.serverRows
2355
+ state.serverTotal = response.total
2356
+ } catch (error) {
2357
+ if ((error as Error)?.name === "AbortError") return
2358
+ emit("data-error", { error, request })
2359
+ console.warn("[DataTable] Datenabruf fehlgeschlagen", error)
2360
+ } finally {
2361
+ if (!request.signal.aborted) state.fetching = false
2362
+ }
2363
+ }
2364
+
2365
+ function reload(): void {
2366
+ scheduleFetch(false)
2367
+ // Ohne dataSource ersetzt der Aufrufer props.data selbst (der Watch oben
2368
+ // greift dann); den Nachlade-Baum setzen wir in jedem Fall zurück.
2369
+ if (!props.dataSource) resetLazyTree()
2370
+ }
2371
+
2372
+ // ── Kopieren & CSV ───────────────────────────────────────────────────
2373
+
2374
+ interface ExportOptions {
2375
+ columnIds?: string[]
2376
+ includeHeaders?: boolean
2377
+ delimiter?: string
2378
+ escapeFormulas?: boolean
2379
+ }
2380
+
2381
+ function exportColumns(columnIds?: string[]): ResolvedColumn<R>[] {
2382
+ const visible = (layout.state.visibleColumns as LayoutColumn<R>[]).map((col) => col.column)
2383
+ if (!columnIds) return visible
2384
+ const resolved: ResolvedColumn<R>[] = []
2385
+ for (const id of columnIds) {
2386
+ const column = cols().find((item) => item.id === id)
2387
+ if (column) resolved.push(column)
2388
+ }
2389
+ return resolved
2390
+ }
2391
+
2392
+ function escapeCell(text: string, delimiter: string, escapeFormulas: boolean): string {
2393
+ let value = text
2394
+ if (escapeFormulas && /^[=+\-@]/.test(value) && Number.isNaN(Number(value))) value = `'${value}`
2395
+ return value.includes(delimiter) || value.includes('"') || value.includes("\n")
2396
+ ? `"${value.replace(/"/g, '""')}"`
2397
+ : value
2398
+ }
2399
+
2400
+ function serializeRows(rows: R[], options: ExportOptions = {}): string {
2401
+ const delimiter = options.delimiter ?? ","
2402
+ const columns = exportColumns(options.columnIds)
2403
+ const lines: string[] = []
2404
+ if (options.includeHeaders !== false) {
2405
+ lines.push(columns.map((column) => escapeCell(column.label, delimiter, false)).join(delimiter))
2406
+ }
2407
+ for (const row of rows) {
2408
+ lines.push(
2409
+ columns.map((column) => escapeCell(column.text(row), delimiter, options.escapeFormulas ?? false)).join(delimiter)
2410
+ )
2411
+ }
2412
+ return lines.join("\n")
2413
+ }
2414
+
2415
+ function getDataAsCsv(options: ExportOptions = {}): string {
2416
+ return serializeRows(dataRowsOf(), options)
2417
+ }
2418
+
2419
+ function exportDataAsCsv(options: ExportOptions & { fileName?: string } = {}): void {
2420
+ const csv = getDataAsCsv(options)
2421
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" })
2422
+ const url = URL.createObjectURL(blob)
2423
+ const link = document.createElement("a")
2424
+ link.href = url
2425
+ link.download = options.fileName ?? "export.csv"
2426
+ link.click()
2427
+ URL.revokeObjectURL(url)
2428
+ }
2429
+
2430
+ async function copySelectedRows(options: ExportOptions & { format?: "tsv" | "csv" } = {}): Promise<number> {
2431
+ const selected = resolveSelectedRows(selectedKeys.value)
2432
+ const rows = selected.length ? selected : dataRowsOf()
2433
+ const text = serializeRows(rows, {
2434
+ ...options,
2435
+ delimiter: options.format === "csv" ? "," : "\t",
2436
+ })
2437
+ try {
2438
+ await navigator.clipboard?.writeText(text)
2439
+ } catch {
2440
+ // Clipboard kann verweigert werden (Berechtigung, unsicherer Kontext).
2441
+ }
2442
+ state.announcement = t("dataTable.rowsCopied", { count: rows.length })
2443
+ return rows.length
2444
+ }
2445
+
2446
+ // ── Zustand sichern / laden ──────────────────────────────────────────
2447
+
2448
+ function getState(): DataTableState {
2449
+ return {
2450
+ version: 1,
2451
+ columnOrder: layout.currentOrder(),
2452
+ columnWidths: { ...layout.state.widths },
2453
+ columnVisibility: Object.fromEntries(cols().map((column) => [column.id, layout.isVisible(column)])),
2454
+ columnPinning: {
2455
+ start: cols()
2456
+ .filter((column) => layout.pinOf(column) === "start")
2457
+ .map((column) => column.id),
2458
+ end: cols()
2459
+ .filter((column) => layout.pinOf(column) === "end")
2460
+ .map((column) => column.id),
2461
+ },
2462
+ sort: sort.value,
2463
+ filters: filters.value,
2464
+ search: search.value,
2465
+ selectedKeys: selectedKeys.value,
2466
+ expandedKeys: expandedKeys.value,
2467
+ page: page.value,
2468
+ pageSize: pageSize.value,
2469
+ }
2470
+ }
2471
+
2472
+ function setState(next: Partial<DataTableState>): void {
2473
+ if (next.columnOrder) layout.state.order = [...next.columnOrder]
2474
+ if (next.columnWidths) layout.state.widths = { ...next.columnWidths }
2475
+ if (next.columnVisibility) layout.state.visibility = { ...next.columnVisibility }
2476
+ if (next.columnPinning) {
2477
+ const pinning: Record<string, PinSide | false> = {}
2478
+ for (const id of next.columnPinning.start) pinning[id] = "start"
2479
+ for (const id of next.columnPinning.end) pinning[id] = "end"
2480
+ layout.state.pinning = pinning
2481
+ }
2482
+ if (next.sort) sort.value = [...next.sort]
2483
+ if (next.filters) filters.value = [...next.filters]
2484
+ if (next.search !== undefined) search.value = next.search
2485
+ if (next.selectedKeys) selectedKeys.value = [...next.selectedKeys]
2486
+ if (next.expandedKeys) expandedKeys.value = [...next.expandedKeys]
2487
+ if (next.page !== undefined) page.value = next.page
2488
+ if (next.pageSize !== undefined) pageSize.value = next.pageSize
2489
+ layout.pruneUnknown()
2490
+ }
2491
+
2492
+ function resetState(): void {
2493
+ layout.resetColumns()
2494
+ sort.value = []
2495
+ filters.value = []
2496
+ search.value = ""
2497
+ expandedKeys.value = []
2498
+ }
2499
+
2500
+ defineExpose({
2501
+ // Zeilen
2502
+ getVisibleRows: (): R[] =>
2503
+ displayed()
2504
+ .filter((row) => row.kind === "data")
2505
+ .map((row) => row.row!),
2506
+ getProcessedRows: (): R[] => dataRowsOf(),
2507
+ getSelectedRows: (): R[] => resolveSelectedRows(selectedKeys.value),
2508
+ scrollToIndex: virtual.scrollToIndex,
2509
+ // Aufklappen
2510
+ expandRow: (key: RowKey) => {
2511
+ if (!expandedKeys.value.includes(key)) expandedKeys.value = [...expandedKeys.value, key]
2512
+ },
2513
+ collapseRow: (key: RowKey) => {
2514
+ expandedKeys.value = expandedKeys.value.filter((item) => item !== key)
2515
+ },
2516
+ expandAllRows: () => {
2517
+ expandedKeys.value = allExpandableKeys()
2518
+ },
2519
+ collapseAllRows: () => {
2520
+ expandedKeys.value = []
2521
+ },
2522
+ // Spalten
2523
+ toggleColumn: (id: string, visible: boolean) => layout.toggleColumn(id, visible, true),
2524
+ pinColumn: (id: string, side: PinSide | false) => layout.pinColumn(id, side, true),
2525
+ getColumnPin: layout.getColumnPin,
2526
+ autoSizeColumn,
2527
+ autoSizeColumns,
2528
+ sizeColumnsToFit,
2529
+ resetColumns,
2530
+ getColumnFacets: (id: string): ColumnFacets => (state.facets as Record<string, ColumnFacets>)[id] ?? { values: [] },
2531
+ // Daten
2532
+ reload,
2533
+ /** Nachlade-Baum zurücksetzen: Pfeile zu, Kinder-Cache leer (frisch beim Aufklappen). */
2534
+ resetTree: resetLazyTree,
2535
+ /** Nachgeladene Kinder einer Zeile verwerfen und erneut holen. */
2536
+ reloadChildren: async (key: RowKey): Promise<void> => {
2537
+ const row = findRowByKey(key)
2538
+ if (!row) return
2539
+ delete state.loadedChildren[String(key)]
2540
+ await fetchChildren(row, true)
2541
+ },
2542
+ /** Nachgeladene Kinder einer Zeile (undefined = noch nicht geladen). */
2543
+ getLoadedChildren: (key: RowKey): R[] | undefined => state.loadedChildren[String(key)] as R[] | undefined,
2544
+ getDataAsCsv,
2545
+ exportDataAsCsv,
2546
+ copySelectedRows,
2547
+ // Zustand
2548
+ getState,
2549
+ setState,
2550
+ resetState,
2551
+ focus: () => void nav.focusActive(),
2552
+ })
2553
+
2554
+ // ── Messen, Beobachten, Reagieren ────────────────────────────────────
2555
+
2556
+ /** Kopfzellen messen — die Breiten sind die Grundlage der Sticky-Abstände. */
2557
+ function observeHeaderCells(): void {
2558
+ const scroller = scrollerEl.value
2559
+ if (!scroller || !headerObserver) return
2560
+ headerObserver.disconnect()
2561
+ for (const cell of scroller.querySelectorAll<HTMLElement>(".vi-dt-hcell[data-column]")) {
2562
+ headerObserver.observe(cell)
2563
+ }
2564
+ }
2565
+
2566
+ /** Detail-Panels haben freie Höhe → für die Virtualisierung messen. */
2567
+ function measureDetailRows(): void {
2568
+ const body = bodyEl.value
2569
+ if (!body || !detailObserver) return
2570
+ detailObserver.disconnect()
2571
+ for (const row of body.querySelectorAll<HTMLElement>("[data-detail]")) {
2572
+ virtual.measureRow(row.dataset.detail!, row.getBoundingClientRect().height)
2573
+ detailObserver.observe(row)
2574
+ }
2575
+ }
2576
+
2577
+ onMounted(() => {
2578
+ headerObserver = new ResizeObserver((entries) => {
2579
+ for (const entry of entries) {
2580
+ const id = (entry.target as HTMLElement).dataset.column
2581
+ if (id && !id.startsWith("__")) layout.setMeasured(id, entry.contentRect.width)
2582
+ }
2583
+ })
2584
+ detailObserver = new ResizeObserver((entries) => {
2585
+ for (const entry of entries) {
2586
+ const key = (entry.target as HTMLElement).dataset.detail
2587
+ if (key) virtual.measureRow(key, entry.contentRect.height)
2588
+ }
2589
+ })
2590
+ virtual.syncViewport()
2591
+ void nextTick(() => {
2592
+ observeHeaderCells()
2593
+ measureDetailRows()
2594
+ })
2595
+ if (state.isServer) scheduleFetch(false)
2596
+ })
2597
+
2598
+ onBeforeUnmount(() => {
2599
+ headerObserver?.disconnect()
2600
+ detailObserver?.disconnect()
2601
+ abortController?.abort()
2602
+ if (debounceTimer) window.clearTimeout(debounceTimer)
2603
+ })
2604
+
2605
+ watch(
2606
+ () => layout.state.visibleColumns.length,
2607
+ () => void nextTick(observeHeaderCells)
2608
+ )
2609
+
2610
+ watch(
2611
+ () => props.columns,
2612
+ () => {
2613
+ layout.pruneUnknown()
2614
+ void nextTick(observeHeaderCells)
2615
+ }
2616
+ )
2617
+
2618
+ watch(
2619
+ () => state.displayRows.length,
2620
+ () => {
2621
+ nav.clamp()
2622
+ void nextTick(measureDetailRows)
2623
+ }
2624
+ )
2625
+
2626
+ watch(
2627
+ () => expandedKeys.value.length,
2628
+ () => void nextTick(measureDetailRows)
2629
+ )
2630
+
2631
+ // Datenwechsel: Seite in den gültigen Bereich ziehen.
2632
+ watch(
2633
+ () => state.filteredCount,
2634
+ () => {
2635
+ if (page.value > state.pageCount - 1) page.value = state.pageCount - 1
2636
+ }
2637
+ )
2638
+ </script>
2639
+
2640
+ <style scoped>
2641
+ /* Größen und Farben spiegeln die CSS-Variablen von <wa-data-grid>. */
2642
+ .vi-dt {
2643
+ --dt-border-color: var(--wa-color-surface-border);
2644
+ --dt-border-width: var(--wa-border-width-s);
2645
+ --dt-header-background: var(--wa-color-surface-lowered);
2646
+ --dt-row-hover: var(--wa-color-neutral-fill-quiet);
2647
+ --dt-selected: var(--wa-color-brand-fill-quiet);
2648
+ --dt-stripe: color-mix(in oklab, var(--wa-color-neutral-fill-quiet) 55%, transparent);
2649
+ --dt-accent: var(--wa-color-brand-fill-loud);
2650
+ --dt-cell-padding: var(--wa-space-m);
2651
+ --dt-indent: 1.5em;
2652
+ --dt-toggle-size: 1.5rem;
2653
+ display: flex;
2654
+ flex-direction: column;
2655
+ min-inline-size: 0;
2656
+ /* Bezugsrahmen für absolut positionierte Kinder — vor allem .vi-dt-live.
2657
+ Ohne das hängt die Live-Region am initialen Container (body) und entkommt
2658
+ JEDEM `overflow: hidden` der Vorfahren: sie liegt dann auf der
2659
+ Dokument-Position ihres Gitters und spannt den Scrollbereich der ganzen
2660
+ Seite auf (bei acht Gittern untereinander mehrere Tausend Pixel). */
2661
+ position: relative;
2662
+ color: var(--wa-color-text-normal);
2663
+ font-size: var(--wa-font-size-s);
2664
+ }
2665
+ .vi-dt.is-outlined {
2666
+ border: var(--dt-border-width) solid var(--dt-border-color);
2667
+ border-radius: var(--wa-border-radius-m);
2668
+ background-color: var(--wa-color-surface-default);
2669
+ overflow: hidden;
2670
+ }
2671
+
2672
+ /* Größen: Zeilenhöhe, Polsterung und Textskala zusammen. */
2673
+ .vi-dt.is-size-xs {
2674
+ --dt-row-height: 2rem;
2675
+ --dt-cell-padding: var(--wa-space-xs);
2676
+ font-size: var(--wa-font-size-xs);
2677
+ }
2678
+ .vi-dt.is-size-s {
2679
+ --dt-row-height: 2.5rem;
2680
+ --dt-cell-padding: var(--wa-space-s);
2681
+ font-size: var(--wa-font-size-s);
2682
+ }
2683
+ .vi-dt.is-size-l {
2684
+ --dt-row-height: 3.5rem;
2685
+ --dt-cell-padding: var(--wa-space-l);
2686
+ font-size: var(--wa-font-size-m);
2687
+ }
2688
+ .vi-dt.is-size-xl {
2689
+ --dt-row-height: 4rem;
2690
+ --dt-cell-padding: var(--wa-space-xl);
2691
+ font-size: var(--wa-font-size-l);
2692
+ }
2693
+
2694
+ .vi-dt-toolbar {
2695
+ padding: var(--wa-space-s);
2696
+ border-block-end: var(--dt-border-width) solid var(--dt-border-color);
2697
+ }
2698
+ .vi-dt-search {
2699
+ min-inline-size: 14rem;
2700
+ }
2701
+
2702
+ /* Ein Scroll-Bereich für Kopf, Körper und Fuß — nur so bleiben gepinnte
2703
+ Spalten und die Kopfzeile beim Scrollen in beiden Richtungen bündig. */
2704
+ .vi-dt-scroller {
2705
+ position: relative;
2706
+ overflow: auto;
2707
+ /* Streckt sich, wenn die Tabelle eine feste Höhe hat (z. B. in einer
2708
+ füllenden Ansicht) — sonst bleibt sie inhaltshoch. Der Deckel gilt
2709
+ weiterhin für inhaltshohe Tabellen; wer füllen will, setzt von aussen
2710
+ `--dt-max-height: none`. */
2711
+ flex: 1 1 auto;
2712
+ min-block-size: 0;
2713
+ max-block-size: var(--dt-max-height, 30rem);
2714
+ }
2715
+ .vi-dt-head {
2716
+ position: sticky;
2717
+ inset-block-start: 0;
2718
+ z-index: 3;
2719
+ }
2720
+ /* Die Zeile gibt nur den Farbton als Variable vor und ordnet die Zellen an —
2721
+ gemalt wird in den ZELLEN.
2722
+ Grund: Die Zeilenbox ist nur so breit wie der sichtbare Bereich, ihre
2723
+ Spalten-Tracks können aber darüber hinausragen (waagerechtes Scrollen).
2724
+ Ein Hintergrund oder Rahmen auf der Zeile bricht deshalb mitten im Gitter
2725
+ ab — sichtbar als fehlende Trennlinie unter gepinnten Spalten, sobald man
2726
+ scrollt. Die Zellen decken dagegen die gesamte Breite ab. */
2727
+ .vi-dt-row {
2728
+ --dt-row-bg: var(--wa-color-surface-default);
2729
+ display: grid;
2730
+ align-items: stretch;
2731
+ min-block-size: var(--dt-row-height, 2.75rem);
2732
+ background-color: transparent;
2733
+ }
2734
+ .vi-dt-headrow,
2735
+ .vi-dt-footrow {
2736
+ --dt-row-bg: var(--dt-header-background);
2737
+ font-weight: 600;
2738
+ }
2739
+ .vi-dt.is-striped .vi-dt-row.is-odd {
2740
+ --dt-row-bg: var(--dt-stripe);
2741
+ }
2742
+ /* Rangfolge der Zeilenfarbe: Auswahl > Hover > Streifen > Grundfläche.
2743
+ Die Streifenregel braucht `.is-striped` am Wurzelelement und wiegt damit
2744
+ schwerer als ein schlichtes `.vi-dt-row.is-data:hover` — ohne die folgenden
2745
+ Präfixe bliebe auf ungeraden Zeilen weder Hover noch Auswahl sichtbar. */
2746
+ .vi-dt .vi-dt-row.is-data:hover {
2747
+ --dt-row-bg: var(--dt-row-hover);
2748
+ }
2749
+ /* Auswahl per Zeilenklick: die Zeile signalisiert Klickbarkeit. */
2750
+ .vi-dt.is-selectable .vi-dt-row.is-data {
2751
+ cursor: pointer;
2752
+ }
2753
+ .vi-dt .vi-dt-row.is-data.is-selected {
2754
+ --dt-row-bg: var(--dt-selected);
2755
+ }
2756
+ .vi-dt .vi-dt-row.is-data.is-selected:hover {
2757
+ --dt-row-bg: color-mix(in oklab, var(--dt-selected) 75%, var(--dt-row-hover));
2758
+ }
2759
+ .vi-dt-row.is-group {
2760
+ --dt-row-bg: var(--wa-color-surface-lowered);
2761
+ font-weight: 600;
2762
+ }
2763
+
2764
+ .vi-dt-badges {
2765
+ display: inline-flex;
2766
+ gap: var(--wa-space-2xs, 0.25rem);
2767
+ flex-wrap: wrap;
2768
+ min-inline-size: 0;
2769
+ }
2770
+ .vi-dt-badge {
2771
+ --dt-badge-color: var(--wa-color-neutral-60, #71717a);
2772
+ display: inline-block;
2773
+ max-inline-size: 100%;
2774
+ overflow: hidden;
2775
+ text-overflow: ellipsis;
2776
+ white-space: nowrap;
2777
+ padding: 0.1em 0.55em;
2778
+ border-radius: var(--wa-border-radius-pill, 999px);
2779
+ font-size: 0.85em;
2780
+ font-weight: 600;
2781
+ line-height: 1.4;
2782
+ background: color-mix(in oklab, var(--dt-badge-color) 16%, var(--wa-color-surface-default, #fff));
2783
+ border: 1px solid color-mix(in oklab, var(--dt-badge-color) 45%, transparent);
2784
+ color: color-mix(in oklab, var(--dt-badge-color) 80%, var(--wa-color-text-normal, #222));
2785
+ }
2786
+ .vi-dt-cell {
2787
+ display: flex;
2788
+ align-items: center;
2789
+ gap: var(--wa-space-2xs);
2790
+ min-inline-size: 0;
2791
+ padding-inline: var(--dt-cell-padding);
2792
+ /* Farbton und Trennlinie trägt jede Zelle selbst (siehe .vi-dt-row). */
2793
+ background-color: var(--dt-row-bg);
2794
+ border-block-end: var(--dt-border-width) solid var(--dt-border-color);
2795
+ overflow: hidden;
2796
+ }
2797
+ .vi-dt-cell.is-lead {
2798
+ padding-inline: var(--wa-space-2xs);
2799
+ justify-content: center;
2800
+ }
2801
+ .vi-dt-cell.is-align-center {
2802
+ justify-content: center;
2803
+ }
2804
+ .vi-dt-cell.is-align-end {
2805
+ justify-content: flex-end;
2806
+ }
2807
+ .vi-dt-cell:focus-visible {
2808
+ outline: var(--wa-focus-ring);
2809
+ outline-offset: -2px;
2810
+ }
2811
+ /* Gepinnte Zellen liegen ÜBER dem waagerecht scrollenden Inhalt und müssen ihn
2812
+ deckend verbergen. Die Zeilentöne (Streifen, Hover, Auswahl) sind
2813
+ teiltransparent (color-mix mit transparent), eine einfache
2814
+ `background-color: var(--dt-row-bg)` ließe den Inhalt also durchscheinen.
2815
+ Deshalb: deckende Grundfläche als Farbe, der Zeilenton als Auflage — das
2816
+ Ergebnis ist derselbe Farbwert wie bei den frei scrollenden Zellen. */
2817
+ .vi-dt-cell.is-sticky {
2818
+ background-color: var(--wa-color-surface-default);
2819
+ background-image: linear-gradient(var(--dt-row-bg), var(--dt-row-bg));
2820
+ }
2821
+ .vi-dt-cell.is-pin-edge.is-pin-start {
2822
+ box-shadow: inset -1px 0 0 var(--dt-accent);
2823
+ }
2824
+ .vi-dt-cell.is-pin-edge.is-pin-end {
2825
+ box-shadow: inset 1px 0 0 var(--dt-accent);
2826
+ }
2827
+
2828
+ .vi-dt-hcell.is-sortable {
2829
+ cursor: pointer;
2830
+ user-select: none;
2831
+ }
2832
+ .vi-dt-hcell.is-dragging {
2833
+ opacity: 0.5;
2834
+ }
2835
+ .vi-dt-hcell.is-drop-target {
2836
+ box-shadow: inset 2px 0 0 var(--dt-accent);
2837
+ }
2838
+ .vi-dt-hcell {
2839
+ padding-inline: var(--wa-space-s);
2840
+ }
2841
+ /* Die Beschriftung füllt den freien Platz — sonst rutschen Filter- und
2842
+ Menüknopf in die Mitte der Zelle und fangen den Sortier-Klick ab. */
2843
+ .vi-dt-hcell .vi-dt-hlabel {
2844
+ flex: 1 1 auto;
2845
+ min-inline-size: 0;
2846
+ align-self: center;
2847
+ }
2848
+ .vi-dt-hcell.is-align-end .vi-dt-hlabel {
2849
+ text-align: end;
2850
+ }
2851
+ .vi-dt-hcell.is-align-center .vi-dt-hlabel {
2852
+ text-align: center;
2853
+ }
2854
+ /* Spaltenmenü nur zeigen, wenn die Spalte im Fokus/Hover ist — sonst frisst es
2855
+ die Beschriftung. (wa-dropdown selbst ist display:contents, also am Trigger.)
2856
+ Aktive Filter bleiben immer sichtbar. */
2857
+ /* visibility statt display: der Knopf ist unsichtbar und NICHT anklickbar
2858
+ (fängt also den Sortier-Klick in der Zellmitte nicht ab), behält aber seinen
2859
+ Platz im Layout. Beides ist wichtig:
2860
+ - Mit display:none verlässt der Knopf das Layout. Ist sein Menü offen, hat
2861
+ das Popup plötzlich keinen Anker mehr und springt in die linke obere Ecke.
2862
+ - Außerdem würde die Kopfzeile bei jedem Hover-Wechsel umbrechen und offene
2863
+ Filter-Popover um die Knopfbreite verschieben. */
2864
+ .vi-dt-column-menu-button {
2865
+ visibility: hidden;
2866
+ }
2867
+ .vi-dt-hcell:hover .vi-dt-column-menu-button,
2868
+ .vi-dt-hcell:focus-within .vi-dt-column-menu-button,
2869
+ .vi-dt-column-menu[open] .vi-dt-column-menu-button {
2870
+ visibility: visible;
2871
+ }
2872
+ .vi-dt-filter-button {
2873
+ opacity: 0.55;
2874
+ }
2875
+ .vi-dt-hcell:hover .vi-dt-filter-button,
2876
+ .vi-dt-hcell:focus-within .vi-dt-filter-button,
2877
+ .vi-dt-filter-button.is-active {
2878
+ opacity: 1;
2879
+ }
2880
+ .vi-dt-sort,
2881
+ .vi-dt-pin {
2882
+ flex: 0 0 auto;
2883
+ font-size: 0.75em;
2884
+ color: var(--dt-accent);
2885
+ }
2886
+ .vi-dt-pin {
2887
+ cursor: pointer;
2888
+ }
2889
+ .vi-dt-sortnum {
2890
+ flex: 0 0 auto;
2891
+ font-size: 0.7em;
2892
+ font-variant-numeric: tabular-nums;
2893
+ padding-inline: 0.25em;
2894
+ border-radius: var(--wa-border-radius-s);
2895
+ background-color: var(--wa-color-brand-fill-quiet);
2896
+ color: var(--wa-color-brand-on-quiet);
2897
+ }
2898
+ .vi-dt-filter-button,
2899
+ .vi-dt-column-menu-button {
2900
+ flex: 0 0 auto;
2901
+ font-size: 0.75em;
2902
+ }
2903
+ .vi-dt-filter-button::part(base),
2904
+ .vi-dt-column-menu-button::part(base) {
2905
+ min-inline-size: 1.4rem;
2906
+ min-block-size: 1.4rem;
2907
+ padding: 0;
2908
+ }
2909
+ .vi-dt-filter-button.is-active::part(base) {
2910
+ color: var(--dt-accent);
2911
+ }
2912
+ .vi-dt-resize {
2913
+ position: absolute;
2914
+ inset-block: 0;
2915
+ inset-inline-end: 0;
2916
+ inline-size: 10px;
2917
+ z-index: 1;
2918
+ cursor: col-resize;
2919
+ touch-action: none;
2920
+ }
2921
+ .vi-dt-resize:hover {
2922
+ background-color: color-mix(in oklab, var(--dt-accent) 35%, transparent);
2923
+ }
2924
+ .vi-dt-hcell {
2925
+ position: relative;
2926
+ }
2927
+
2928
+ /* ── Baumstruktur sichtbar machen ──────────────────────────────────── */
2929
+ /* Einrückung allein liest sich schlecht. Deshalb pro Ebene eine senkrechte
2930
+ Führungslinie (als Hintergrund-Muster über die eingerückte Fläche) und ein
2931
+ kurzer Winkel zur Zeile selbst — wie in einem Dateibaum. */
2932
+ .vi-dt {
2933
+ --dt-guide: var(--wa-color-surface-border);
2934
+ }
2935
+ .vi-dt-cell.is-tree-cell {
2936
+ position: relative;
2937
+ }
2938
+ /* Ein Feld je Ebene. Die senkrechte Linie sitzt auf der MITTE des Feldes, damit
2939
+ sie durch die Aufklapp-Pfeile der jeweiligen Ebene läuft.
2940
+ is-line → durchgehende Linie (Vorfahre hat noch Geschwister)
2941
+ is-tee → „├" (diese Zeile hat noch Geschwister)
2942
+ is-corner → „└" (letztes Kind: darunter bleibt es leer)
2943
+ ohne alles → leeres Feld (der Teilbaum ist dort zu Ende)
2944
+ Der Arm endet am Rand des Feldes — er läuft NICHT über den Pfeil oder den
2945
+ Blatt-Platzhalter hinweg bis zum Text. */
2946
+ .vi-dt-guide {
2947
+ position: relative;
2948
+ flex: 0 0 auto;
2949
+ inline-size: var(--dt-indent);
2950
+ align-self: stretch;
2951
+ }
2952
+ .vi-dt-guide.is-line::before,
2953
+ .vi-dt-guide.is-tee::before,
2954
+ .vi-dt-guide.is-corner::before {
2955
+ content: "";
2956
+ position: absolute;
2957
+ inset-block: 0;
2958
+ inset-inline-start: calc(50% - 0.5px);
2959
+ inline-size: 1px;
2960
+ background-color: var(--dt-guide);
2961
+ }
2962
+ /* Ecke: die senkrechte Linie endet auf halber Höhe. */
2963
+ .vi-dt-guide.is-corner::before {
2964
+ inset-block: 0 50%;
2965
+ }
2966
+ /* Kurzer Arm von der Mitte zum Rand des Feldes. */
2967
+ .vi-dt-guide.is-tee::after,
2968
+ .vi-dt-guide.is-corner::after {
2969
+ content: "";
2970
+ position: absolute;
2971
+ inset-block-start: calc(50% - 0.5px);
2972
+ inset-inline: 50% 0;
2973
+ block-size: 1px;
2974
+ background-color: var(--dt-guide);
2975
+ }
2976
+
2977
+ /* Pfeil und Blatt-Platzhalter MÜSSEN gleich breit sein — sonst stehen die
2978
+ Beschriftungen einer Ebene nicht untereinander und die Einrückung wird
2979
+ teilweise aufgefressen (der wa-button ist von sich aus breiter). */
2980
+ .vi-dt-expand {
2981
+ flex: 0 0 auto;
2982
+ inline-size: var(--dt-toggle-size);
2983
+ }
2984
+ /* Nachgeladen und leer: der Pfeil bleibt an seinem Platz (kein Layoutsprung),
2985
+ ist aber sichtbar außer Betrieb. */
2986
+ .vi-dt-expand.is-empty {
2987
+ opacity: 0.3;
2988
+ cursor: default;
2989
+ }
2990
+ .vi-dt-expand-spinner {
2991
+ font-size: 0.75em;
2992
+ --track-width: 2px;
2993
+ }
2994
+ /* Blätter: gleicher Abstand wie zwischen Pfeil und Führungslinien. */
2995
+ .vi-dt-leaf-gap {
2996
+ flex: 0 0 auto;
2997
+ inline-size: var(--wa-space-2xs);
2998
+ }
2999
+
3000
+ /* ── Feste Reihenfolge: Griff und Einfügemarke ─────────────────────── */
3001
+ .vi-dt-grip {
3002
+ display: inline-flex;
3003
+ align-items: center;
3004
+ justify-content: center;
3005
+ inline-size: 100%;
3006
+ color: var(--wa-color-text-quiet);
3007
+ cursor: grab;
3008
+ opacity: 0.55;
3009
+ font-size: 0.85em;
3010
+ }
3011
+ .vi-dt-row:hover .vi-dt-grip {
3012
+ opacity: 1;
3013
+ }
3014
+ .vi-dt-grip.is-disabled {
3015
+ cursor: not-allowed;
3016
+ opacity: 0.25;
3017
+ }
3018
+ .vi-dt-row.is-dragging {
3019
+ opacity: 0.5;
3020
+ }
3021
+ /* Marke, wo die Zeile landen würde. Als inset-Schatten, damit sie keine
3022
+ Layout-Höhe kostet und die Zeilen beim Ziehen nicht springen. */
3023
+ .vi-dt-row.is-drop-before .vi-dt-cell {
3024
+ box-shadow: inset 0 2px 0 var(--dt-accent);
3025
+ }
3026
+ .vi-dt-row.is-drop-after .vi-dt-cell {
3027
+ box-shadow: inset 0 -2px 0 var(--dt-accent);
3028
+ }
3029
+ /* „Hinein": die Zeile wird Elternteil — deshalb ein Rahmen um die ganze Zeile
3030
+ statt einer Linie an der Kante. */
3031
+ .vi-dt-row.is-drop-inside .vi-dt-cell {
3032
+ box-shadow:
3033
+ inset 0 2px 0 var(--dt-accent),
3034
+ inset 0 -2px 0 var(--dt-accent);
3035
+ }
3036
+ .vi-dt-row.is-drop-inside {
3037
+ --dt-row-bg: color-mix(in oklab, var(--dt-selected) 60%, transparent);
3038
+ }
3039
+
3040
+ /* Die Aufklapp-Spalte liegt links der Baumzelle — ihre Linie führt weiter,
3041
+ damit Pfeil und Struktur zusammenhängen. */
3042
+ .vi-dt-cell.is-lead.is-expand-cell {
3043
+ position: relative;
3044
+ }
3045
+ .vi-dt-expand::part(base) {
3046
+ inline-size: var(--dt-toggle-size);
3047
+ min-inline-size: var(--dt-toggle-size);
3048
+ block-size: var(--dt-toggle-size);
3049
+ min-block-size: var(--dt-toggle-size);
3050
+ padding: 0;
3051
+ }
3052
+ .vi-dt-group-count {
3053
+ padding-inline: var(--wa-space-2xs);
3054
+ border-radius: var(--wa-border-radius-pill);
3055
+ background-color: var(--wa-color-neutral-fill-quiet);
3056
+ font-size: 0.8em;
3057
+ font-variant-numeric: tabular-nums;
3058
+ }
3059
+ .vi-dt-aggregate {
3060
+ color: var(--wa-color-text-quiet);
3061
+ font-variant-numeric: tabular-nums;
3062
+ }
3063
+
3064
+ .vi-dt-detailrow {
3065
+ border-block-end: var(--dt-border-width) solid var(--dt-border-color);
3066
+ background-color: var(--wa-color-surface-lowered);
3067
+ }
3068
+ .vi-dt-detail {
3069
+ padding: var(--wa-space-m);
3070
+ }
3071
+
3072
+ .vi-dt-foot {
3073
+ position: sticky;
3074
+ inset-block-end: 0;
3075
+ z-index: 3;
3076
+ }
3077
+ .vi-dt-footrow .vi-dt-cell {
3078
+ border-block-end: none;
3079
+ border-block-start: var(--dt-border-width) solid var(--dt-border-color);
3080
+ }
3081
+
3082
+ .vi-dt-state {
3083
+ display: flex;
3084
+ align-items: center;
3085
+ justify-content: center;
3086
+ padding-block: var(--wa-space-2xl);
3087
+ color: var(--wa-color-text-quiet);
3088
+ }
3089
+ .vi-dt-loading {
3090
+ position: absolute;
3091
+ inset: 0;
3092
+ display: flex;
3093
+ align-items: center;
3094
+ justify-content: center;
3095
+ background-color: color-mix(in oklab, var(--wa-color-surface-default) 65%, transparent);
3096
+ z-index: 4;
3097
+ }
3098
+ .vi-dt-spinner {
3099
+ font-size: 2rem;
3100
+ }
3101
+
3102
+ .vi-dt-pager,
3103
+ .vi-dt-more {
3104
+ padding: var(--wa-space-s);
3105
+ border-block-start: var(--dt-border-width) solid var(--dt-border-color);
3106
+ font-size: var(--wa-font-size-s);
3107
+ }
3108
+ .vi-dt-more > * {
3109
+ flex: 0 0 auto;
3110
+ }
3111
+ .vi-dt-moreinfo {
3112
+ color: var(--wa-color-text-quiet);
3113
+ white-space: nowrap;
3114
+ }
3115
+ .vi-dt-pager > * {
3116
+ flex: 0 0 auto;
3117
+ }
3118
+ .vi-dt-pagesize,
3119
+ .vi-dt-pageinfo {
3120
+ white-space: nowrap;
3121
+ }
3122
+ .vi-dt-pageinfo {
3123
+ color: var(--wa-color-text-quiet);
3124
+ }
3125
+ .vi-dt-pagesize {
3126
+ font: inherit;
3127
+ min-inline-size: 6.5rem;
3128
+ padding: 0.15rem 0.35rem;
3129
+ border: var(--dt-border-width) solid var(--dt-border-color);
3130
+ border-radius: var(--wa-border-radius-s);
3131
+ background-color: var(--wa-color-surface-default);
3132
+ color: inherit;
3133
+ }
3134
+
3135
+ .vi-dt-checkbox {
3136
+ accent-color: var(--dt-accent);
3137
+ inline-size: 1rem;
3138
+ block-size: 1rem;
3139
+ }
3140
+ .vi-dt-colopt {
3141
+ font-size: var(--wa-font-size-s);
3142
+ cursor: pointer;
3143
+ }
3144
+ /* Nur für Screenreader: unsichtbar, aber vorhanden. Bezugsrahmen ist .vi-dt
3145
+ (position: relative), damit die Region im Gitter geclippt bleibt. */
3146
+ .vi-dt-live {
3147
+ position: absolute;
3148
+ inset-block-start: 0;
3149
+ inset-inline-start: 0;
3150
+ inline-size: 1px;
3151
+ block-size: 1px;
3152
+ overflow: hidden;
3153
+ clip-path: inset(50%);
3154
+ }
3155
+ </style>