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.
- package/README.md +79 -0
- package/index.d.ts +351 -0
- package/index.js +46 -0
- package/package.json +84 -0
- package/src/components/Browser/Browser.vue +496 -0
- package/src/components/Browser/TreeNav.vue +408 -0
- package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
- package/src/components/Browser/ViurBrowser.vue +271 -0
- package/src/components/Browser/dragSession.ts +134 -0
- package/src/components/Browser/index.ts +14 -0
- package/src/components/Browser/types.ts +30 -0
- package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
- package/src/components/DataTable/DataTable.vue +3155 -0
- package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
- package/src/components/DataTable/ViurDataTable.vue +331 -0
- package/src/components/DataTable/columns.ts +400 -0
- package/src/components/DataTable/index.ts +60 -0
- package/src/components/DataTable/rows.ts +446 -0
- package/src/components/DataTable/types.ts +267 -0
- package/src/components/DataTable/useColumnLayout.ts +294 -0
- package/src/components/DataTable/useGridNavigation.ts +193 -0
- package/src/components/DataTable/useVirtualRows.ts +126 -0
- package/src/components/DataTable/viurColumns.ts +330 -0
- package/src/forms/ViForm.d.vue.ts +106 -0
- package/src/forms/ViForm.vue +592 -0
- package/src/forms/ViFormCategory.vue +57 -0
- package/src/forms/bones/Bone.vue +1023 -0
- package/src/forms/bones/BoneActions.vue +127 -0
- package/src/forms/bones/BoneLabel.vue +122 -0
- package/src/forms/bones/BoneMultipleList.vue +91 -0
- package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
- package/src/forms/bones/BoneNestedWrapper.vue +155 -0
- package/src/forms/bones/BoneSingleEntry.vue +51 -0
- package/src/forms/bones/FilterBone.vue +694 -0
- package/src/forms/bones/actionbar/defaultBar.vue +98 -0
- package/src/forms/bones/actionbar/fileBar.vue +165 -0
- package/src/forms/bones/actionbar/relationalBar.vue +125 -0
- package/src/forms/bones/actions/ActionDialog.vue +70 -0
- package/src/forms/bones/actions/Debug.vue +106 -0
- package/src/forms/bones/actions/Delete.vue +13 -0
- package/src/forms/bones/actions/DescribeImage.vue +201 -0
- package/src/forms/bones/actions/Download.vue +27 -0
- package/src/forms/bones/actions/Placeholder.vue +14 -0
- package/src/forms/bones/actions/Translation.vue +238 -0
- package/src/forms/bones/actions/index.js +280 -0
- package/src/forms/bones/bone.d.ts +45 -0
- package/src/forms/bones/country_information.json +779 -0
- package/src/forms/bones/index.js +225 -0
- package/src/forms/bones/uploadAdapter.js +46 -0
- package/src/forms/bones/utils.js +284 -0
- package/src/forms/bones/view/base_item.vue +35 -0
- package/src/forms/bones/view/boneLogic.js +202 -0
- package/src/forms/bones/view/index.js +5 -0
- package/src/forms/bones/widgets/booleanBone.vue +83 -0
- package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
- package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
- package/src/forms/bones/widgets/codeBone.vue +214 -0
- package/src/forms/bones/widgets/colorBone.vue +75 -0
- package/src/forms/bones/widgets/dateBone.vue +114 -0
- package/src/forms/bones/widgets/emailBone.vue +100 -0
- package/src/forms/bones/widgets/fileBone.vue +273 -0
- package/src/forms/bones/widgets/jsonBone.vue +63 -0
- package/src/forms/bones/widgets/keyBone.vue +71 -0
- package/src/forms/bones/widgets/numericBone.vue +125 -0
- package/src/forms/bones/widgets/passwordBone.vue +215 -0
- package/src/forms/bones/widgets/phoneBone.vue +173 -0
- package/src/forms/bones/widgets/rawBone.vue +94 -0
- package/src/forms/bones/widgets/recordBone.vue +64 -0
- package/src/forms/bones/widgets/relationalBone.vue +164 -0
- package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
- package/src/forms/bones/widgets/selectBone.vue +122 -0
- package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
- package/src/forms/bones/widgets/spatialBone.vue +111 -0
- package/src/forms/bones/widgets/stringBone.vue +139 -0
- package/src/forms/bones/widgets/textBone.vue +641 -0
- package/src/forms/install.d.ts +4 -0
- package/src/forms/install.js +43 -0
- package/src/forms/layouts/LayloutList2Columns.vue +57 -0
- package/src/forms/layouts/LayoutCategory.vue +38 -0
- package/src/forms/layouts/LayoutList.vue +31 -0
- package/src/forms/utils.js +601 -0
- package/src/public/assets/icons/fontawesome-manifest.json +8 -0
- package/src/public/assets/icons/regular/file.svg +1 -0
- package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
- package/src/public/assets/icons/solid/bug.svg +1 -0
- package/src/public/assets/icons/solid/check.svg +1 -0
- package/src/public/assets/icons/solid/circle-info.svg +1 -0
- package/src/public/assets/icons/solid/compress.svg +1 -0
- package/src/public/assets/icons/solid/download.svg +1 -0
- package/src/public/assets/icons/solid/expand.svg +1 -0
- package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
- package/src/public/assets/icons/solid/plus.svg +1 -0
- package/src/public/assets/icons/solid/question.svg +1 -0
- package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
- package/src/public/assets/icons/solid/upload.svg +1 -0
- package/src/public/assets/icons/solid/xmark.svg +1 -0
- package/src/request/cache.js +183 -0
- package/src/request/configStore.js +516 -0
- package/src/request/envelope.js +211 -0
- package/src/request/index.js +35 -0
- package/src/request/messageStore.js +201 -0
- package/src/request/request.js +752 -0
- package/src/request/skel.js +90 -0
- package/src/theme/colorStore.js +168 -0
- package/src/translations/de.js +178 -0
- package/src/translations/en.js +177 -0
- package/src/user/LoginScreen.d.vue.ts +54 -0
- package/src/user/LoginScreen.vue +627 -0
- package/src/user/google.js +75 -0
- package/src/user/index.js +3 -0
- package/src/user/store.d.ts +59 -0
- package/src/user/store.js +183 -0
- package/src/webawesomeCompat.css +72 -0
- 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>
|