viur-ui 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +67 -41
  2. package/index.d.ts +18 -9
  3. package/index.js +6 -3
  4. package/package.json +6 -6
  5. package/src/components/Browser/Browser.vue +58 -58
  6. package/src/components/Browser/TreeNav.vue +32 -32
  7. package/src/components/Browser/ViurBrowser.d.vue.ts +21 -21
  8. package/src/components/Browser/ViurBrowser.vue +47 -44
  9. package/src/components/Browser/dragSession.ts +27 -27
  10. package/src/components/Browser/index.ts +3 -3
  11. package/src/components/Browser/types.ts +9 -9
  12. package/src/components/DataTable/ColumnFilterPanel.vue +7 -7
  13. package/src/components/DataTable/DataTable.vue +359 -351
  14. package/src/components/DataTable/ViurDataTable.d.vue.ts +26 -26
  15. package/src/components/DataTable/ViurDataTable.vue +51 -50
  16. package/src/components/DataTable/columns.ts +30 -30
  17. package/src/components/DataTable/index.ts +12 -12
  18. package/src/components/DataTable/rows.ts +71 -72
  19. package/src/components/DataTable/types.ts +55 -55
  20. package/src/components/DataTable/useColumnLayout.ts +28 -28
  21. package/src/components/DataTable/useGridNavigation.ts +74 -15
  22. package/src/components/DataTable/useVirtualRows.ts +10 -10
  23. package/src/components/DataTable/viurColumns.ts +69 -69
  24. package/src/forms/ViForm.d.vue.ts +32 -32
  25. package/src/forms/ViForm.vue +17 -16
  26. package/src/forms/ViFormCategory.vue +3 -1
  27. package/src/forms/bones/Bone.vue +125 -108
  28. package/src/forms/bones/BoneActions.vue +21 -21
  29. package/src/forms/bones/BoneLabel.vue +1 -1
  30. package/src/forms/bones/BoneMultipleWrapper.vue +3 -3
  31. package/src/forms/bones/BoneNestedWrapper.vue +3 -3
  32. package/src/forms/bones/BoneSingleEntry.vue +2 -2
  33. package/src/forms/bones/FilterBone.vue +3 -3
  34. package/src/forms/bones/actions/ActionDialog.vue +2 -2
  35. package/src/forms/bones/actions/Debug.vue +6 -6
  36. package/src/forms/bones/actions/Delete.vue +2 -2
  37. package/src/forms/bones/actions/DescribeImage.vue +4 -3
  38. package/src/forms/bones/actions/Download.vue +4 -4
  39. package/src/forms/bones/actions/Placeholder.vue +2 -2
  40. package/src/forms/bones/actions/index.js +72 -74
  41. package/src/forms/bones/bone.d.ts +19 -19
  42. package/src/forms/bones/uploadAdapter.js +3 -3
  43. package/src/forms/bones/utils.js +40 -41
  44. package/src/forms/bones/view/base_item.vue +1 -1
  45. package/src/forms/bones/view/boneLogic.js +1 -1
  46. package/src/forms/bones/widgets/booleanBone.vue +19 -19
  47. package/src/forms/bones/widgets/booleanBoneChoose.vue +2 -2
  48. package/src/forms/bones/widgets/booleanBoneSelect.vue +3 -3
  49. package/src/forms/bones/widgets/codeBone.vue +22 -22
  50. package/src/forms/bones/widgets/colorBone.vue +18 -18
  51. package/src/forms/bones/widgets/dateBone.vue +22 -22
  52. package/src/forms/bones/widgets/emailBone.vue +20 -20
  53. package/src/forms/bones/widgets/fileBone.vue +32 -31
  54. package/src/forms/bones/widgets/keyBone.vue +21 -21
  55. package/src/forms/bones/widgets/numericBone.vue +21 -21
  56. package/src/forms/bones/widgets/passwordBone.vue +34 -31
  57. package/src/forms/bones/widgets/phoneBone.vue +20 -20
  58. package/src/forms/bones/widgets/rawBone.vue +19 -19
  59. package/src/forms/bones/widgets/recordBone.vue +16 -16
  60. package/src/forms/bones/widgets/relationalBone.vue +26 -26
  61. package/src/forms/bones/widgets/selectBone.vue +23 -23
  62. package/src/forms/bones/widgets/selectBoneChoose.vue +2 -2
  63. package/src/forms/bones/widgets/spatialBone.vue +21 -21
  64. package/src/forms/bones/widgets/stringBone.vue +27 -27
  65. package/src/forms/bones/widgets/textBone.vue +28 -26
  66. package/src/forms/install.d.ts +1 -1
  67. package/src/forms/install.js +7 -0
  68. package/src/forms/layouts/LayloutList2Columns.vue +1 -1
  69. package/src/forms/layouts/LayoutCategory.vue +1 -1
  70. package/src/forms/layouts/LayoutList.vue +1 -1
  71. package/src/forms/utils.js +22 -22
  72. package/src/request/configStore.js +4 -4
  73. package/src/request/envelope.js +6 -6
  74. package/src/request/request.js +211 -121
  75. package/src/translations/de.js +31 -0
  76. package/src/translations/en.js +31 -0
  77. package/src/user/LoginScreen.d.vue.ts +24 -24
  78. package/src/user/LoginScreen.vue +6 -7
  79. package/src/user/store.d.ts +16 -16
  80. package/src/vite/safeHtml.js +73 -0
  81. package/src/vite/sanitizeVHtml.d.ts +4 -0
  82. package/src/vite/sanitizeVHtml.js +15 -0
  83. package/src/webawesomeCompat.css +0 -40
  84. package/src/public/assets/icons/fontawesome-manifest.json +0 -8
  85. package/src/public/assets/icons/regular/file.svg +0 -1
  86. package/src/public/assets/icons/solid/arrows-left-right.svg +0 -1
  87. package/src/public/assets/icons/solid/bug.svg +0 -1
  88. package/src/public/assets/icons/solid/check.svg +0 -1
  89. package/src/public/assets/icons/solid/circle-info.svg +0 -1
  90. package/src/public/assets/icons/solid/compress.svg +0 -1
  91. package/src/public/assets/icons/solid/download.svg +0 -1
  92. package/src/public/assets/icons/solid/expand.svg +0 -1
  93. package/src/public/assets/icons/solid/grip-vertical.svg +0 -1
  94. package/src/public/assets/icons/solid/plus.svg +0 -1
  95. package/src/public/assets/icons/solid/question.svg +0 -1
  96. package/src/public/assets/icons/solid/triangle-exclamation.svg +0 -1
  97. package/src/public/assets/icons/solid/upload.svg +0 -1
  98. package/src/public/assets/icons/solid/xmark.svg +0 -1
@@ -1,7 +1,7 @@
1
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. -->
2
+ <!-- Vue reimplementation of <wa-data-grid> (WebAwesome Pro). Columns are data,
3
+ cell contents come from slots (`cell:<id>`), mutable state
4
+ flows through v-model. Everything else lives in the composables. -->
5
5
  <div
6
6
  class="vi-dt"
7
7
  :class="[
@@ -15,7 +15,7 @@
15
15
  ]"
16
16
  :style="state.hostStyle"
17
17
  >
18
- <!-- ── Toolbar: Suche + Spaltenmenü ─────────────────────────────── -->
18
+ <!-- ── Toolbar: search + columns menu ───────────────────────────── -->
19
19
  <div
20
20
  v-if="state.showToolbar"
21
21
  class="vi-dt-toolbar wa-split wa-gap-s wa-flex-nowrap"
@@ -86,7 +86,7 @@
86
86
  </div>
87
87
  </div>
88
88
 
89
- <!-- ── Scroll-Bereich: Kopf, Körper, Spaltenfüße ────────────────── -->
89
+ <!-- ── Scroll area: header, body, column footers ────────────────── -->
90
90
  <div
91
91
  ref="scrollerEl"
92
92
  class="vi-dt-scroller"
@@ -138,7 +138,7 @@
138
138
  @dragend="layout.endDrag()"
139
139
  @drop.prevent="layout.endDrag()"
140
140
  >
141
- <!-- Auswahl-Kopfzelle: alles auf dieser Seite -->
141
+ <!-- Selection header cell: everything on this page -->
142
142
  <input
143
143
  v-if="cell.kind === 'select' && selectable === 'multiple'"
144
144
  class="vi-dt-checkbox vi-dt-select-all"
@@ -271,7 +271,7 @@
271
271
  v-for="displayRow in virtual.state.windowRows"
272
272
  :key="displayRow.key"
273
273
  >
274
- <!-- Detail-Panel: eigene Zeile über die volle Breite -->
274
+ <!-- Detail panel: its own full-width row -->
275
275
  <div
276
276
  v-if="displayRow.kind === 'detail'"
277
277
  class="vi-dt-detailrow"
@@ -310,12 +310,12 @@
310
310
  role="row"
311
311
  :style="state.gridStyle"
312
312
  :data-key="displayRow.key"
313
- @dblclick="onRowDoubleClick(displayRow, $event)"
314
313
  :aria-selected="
315
314
  selectable !== false && displayRow.kind === 'data' ? state.selectedSet.has(displayRow.nodeKey) : undefined
316
315
  "
317
316
  :aria-level="state.isTree ? displayRow.depth + 1 : undefined"
318
317
  :aria-expanded="displayRow.expandable ? displayRow.expanded : undefined"
318
+ @dblclick="onRowDoubleClick(displayRow, $event)"
319
319
  >
320
320
  <div
321
321
  v-for="cell in state.cellColumns"
@@ -344,7 +344,7 @@
344
344
  @click="onCellClick(displayRow, cell, $event)"
345
345
  @contextmenu="onCellContextMenu(displayRow, cell, $event)"
346
346
  >
347
- <!-- Ziehgriff der festen Reihenfolge -->
347
+ <!-- Drag handle for the fixed order -->
348
348
  <span
349
349
  v-if="cell.kind === 'order' && displayRow.kind === 'data'"
350
350
  class="vi-dt-grip"
@@ -356,7 +356,7 @@
356
356
  <wa-icon name="grip-vertical"></wa-icon>
357
357
  </span>
358
358
 
359
- <!-- Auswahl -->
359
+ <!-- Selection -->
360
360
  <input
361
361
  v-if="cell.kind === 'select' && selectable !== false"
362
362
  class="vi-dt-checkbox"
@@ -368,7 +368,7 @@
368
368
  @click.stop="onSelectRow(displayRow, $event as MouseEvent)"
369
369
  />
370
370
 
371
- <!-- Aufklapp-Spalte (Baum, Gruppen, Detail-Panels) -->
371
+ <!-- Expand column (tree, groups, detail panels) -->
372
372
  <template v-if="cell.kind === 'expand'">
373
373
  <wa-button
374
374
  v-if="displayRow.expandable"
@@ -376,14 +376,14 @@
376
376
  appearance="plain"
377
377
  size="small"
378
378
  :aria-expanded="displayRow.expanded"
379
- :aria-label="displayRow.expanded ? 'Zeile einklappen' : 'Zeile ausklappen'"
379
+ :aria-label="displayRow.expanded ? t('dataTable.collapseRow') : t('dataTable.expandRow')"
380
380
  @click.stop="toggleExpand(displayRow)"
381
381
  >
382
382
  <wa-icon :name="displayRow.expanded ? 'chevron-down' : 'chevron-right'"></wa-icon>
383
383
  </wa-button>
384
384
  </template>
385
385
 
386
- <!-- Gruppenzeile: Wert + Anzahl in der ersten Datenspalte, sonst Aggregate -->
386
+ <!-- Group row: value + count in the first data column, aggregates elsewhere -->
387
387
  <template v-else-if="displayRow.kind === 'group' && cell.kind === 'data'">
388
388
  <template v-if="cell.index === state.firstDataCellIndex">
389
389
  <slot
@@ -402,13 +402,13 @@
402
402
  </span>
403
403
  </template>
404
404
 
405
- <!-- Datenzelle -->
405
+ <!-- Data cell -->
406
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. -->
407
+ <!-- Tree data: guide lines, then arrow (or placeholder). One
408
+ slot per level — a line is only drawn where another
409
+ sibling still follows below the ancestor. -->
410
410
  <template v-if="state.isTree && cell.index === state.firstDataCellIndex">
411
- <!-- Durchlauf-Spalten … -->
411
+ <!-- Pass-through columns … -->
412
412
  <span
413
413
  v-for="(line, level) in displayRow.ancestorLines"
414
414
  :key="level"
@@ -416,7 +416,7 @@
416
416
  :class="{ 'is-line': line }"
417
417
  aria-hidden="true"
418
418
  ></span>
419
- <!-- … und der eigene Winkel. -->
419
+ <!-- … and the row's own corner. -->
420
420
  <span
421
421
  v-if="displayRow.depth > 0"
422
422
  class="vi-dt-guide"
@@ -450,7 +450,7 @@
450
450
  :name="displayRow.expanded ? 'chevron-down' : 'chevron-right'"
451
451
  ></wa-icon>
452
452
  </wa-button>
453
- <!-- Blatt: derselbe Abstand wie zwischen Pfeil und Linien. -->
453
+ <!-- Leaf: same spacing as between arrow and lines. -->
454
454
  <span
455
455
  v-else
456
456
  class="vi-dt-leaf-gap"
@@ -491,7 +491,7 @@
491
491
  </template>
492
492
  </div>
493
493
 
494
- <!-- Spaltenfüße, am unteren Rand des Scroll-Bereichs -->
494
+ <!-- Column footers, at the bottom edge of the scroll area -->
495
495
  <div
496
496
  v-if="state.hasColumnFooters"
497
497
  class="vi-dt-foot"
@@ -531,7 +531,7 @@
531
531
  </div>
532
532
  </div>
533
533
 
534
- <!-- Leer / keine Treffer -->
534
+ <!-- Empty / no results -->
535
535
  <div
536
536
  v-if="state.showEmpty"
537
537
  class="vi-dt-state"
@@ -555,7 +555,7 @@
555
555
  </slot>
556
556
  </div>
557
557
 
558
- <!-- Ladeschleier (Server-Modus) -->
558
+ <!-- Loading overlay (server mode) -->
559
559
  <div
560
560
  v-if="state.isLoading"
561
561
  class="vi-dt-loading"
@@ -564,14 +564,14 @@
564
564
  </div>
565
565
  </div>
566
566
 
567
- <!-- ── Nachladen (Cursor-Quellen) ───────────────────────────────── -->
567
+ <!-- ── Load more (cursor sources) ───────────────────────────────── -->
568
568
  <div
569
569
  v-if="state.cursorMode"
570
570
  class="vi-dt-more wa-split wa-gap-s wa-flex-nowrap"
571
571
  >
572
572
  <span class="vi-dt-moreinfo">{{ state.loadedText }}</span>
573
573
  <div class="wa-cluster wa-gap-2xs wa-flex-nowrap">
574
- <!-- Platz für Aufrufer-Inhalte in der Leiste (z.B. Reload-Action). -->
574
+ <!-- Slot for caller content in the bar (e.g. reload action). -->
575
575
  <slot name="cursor-start" />
576
576
  <select
577
577
  class="vi-dt-pagesize"
@@ -693,7 +693,7 @@
693
693
  </div>
694
694
  </div>
695
695
 
696
- <!-- Filter-Popover je sichtbarer filterbarer Spalte (nach ihren Ankern!) -->
696
+ <!-- Filter popover per visible filterable column (after their anchors!) -->
697
697
  <wa-popover
698
698
  v-for="column in state.filterColumns"
699
699
  :key="column.id"
@@ -722,11 +722,11 @@
722
722
  import { useI18n } from "vue-i18n"
723
723
  import { computed, inject, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch, shallowRef } from "vue"
724
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"
725
+ import { useColumnLayout, type LayoutColumn } from "./useColumnLayout.ts"
726
+ import { useGridNavigation } from "./useGridNavigation.ts"
727
+ import { useVirtualRows } from "./useVirtualRows.ts"
728
+ import { DRAG_SESSION } from "../Browser/dragSession.ts"
729
+ import { aggregateColumn, defaultText, findColumn, isEmptyFilter, nextSortState, resolveColumns } from "./columns.ts"
730
730
  import {
731
731
  buildRows,
732
732
  collectKeys,
@@ -742,7 +742,7 @@ import {
742
742
  type NestedTree,
743
743
  type SelectionState,
744
744
  type NodeLocation,
745
- } from "./rows"
745
+ } from "./rows.ts"
746
746
  import type {
747
747
  ColumnFacets,
748
748
  DataTableColumn,
@@ -758,27 +758,27 @@ import type {
758
758
  RowNode,
759
759
  SortEntry,
760
760
  CellBadge,
761
- } from "./types"
761
+ } from "./types.ts"
762
762
 
763
763
  const props = withDefaults(
764
764
  defineProps<{
765
765
  columns: DataTableColumn<R>[]
766
766
  data?: R[]
767
- /** Feld mit stabiler Zeilen-id — für Auswahl über Sortierung/Seiten hinweg. */
767
+ /** Field with a stable row id — for selection across sorting/pages. */
768
768
  rowKey?: string | null
769
769
  label?: string | null
770
770
  selectable?: false | "single" | "multiple"
771
771
  selectableRows?: ((row: R) => boolean) | null
772
- /** Bei aktiver Auswahl wählt ein Linksklick auf die Zeile den Eintrag (Shift: Bereich). */
772
+ /** With selection enabled, a left click on the row selects the entry (Shift: range). */
773
773
  selectOnRowClick?: boolean
774
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()`.
775
+ * Reset the lazy-loaded tree (`loadChildren`) on every external data
776
+ * change (arrows collapsed, children cache empty). Default false: a drop
777
+ * stays a purely local move and nothing collapses unasked —
778
+ * resetting only happens via `reload()` / `resetTree()`.
779
779
  */
780
780
  resetTreeOnDataChange?: boolean
781
- /** Container-Id dieser Tabelle in einer umgebenden Drag-Session (Browser). */
781
+ /** Container id of this table in an enclosing drag session (Browser). */
782
782
  dragSource?: string
783
783
  striped?: boolean
784
784
  size?: "xs" | "s" | "m" | "l" | "xl"
@@ -794,69 +794,69 @@ const props = withDefaults(
794
794
  groupBy?: string | string[] | null
795
795
  childRows?: string | ((row: R) => R[] | undefined | null) | null
796
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.
797
+ * Field referencing the parent row (ViUR: `parententry`). With it the
798
+ * component builds the tree itself from a FLAT list — `rowKey`
799
+ * is required for this. Already nested data still goes through
800
+ * `childRows`, which then takes precedence.
801
801
  */
802
802
  parentField?: string | null
803
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.
804
+ * Load the children of a level on demand (ViUR: `list` with `parententry`).
805
+ * When set, EVERY data row gets an expand arrow, because it is unknown
806
+ * beforehand whether there are children. After loading the arrow stays
807
+ * and is disabled if nothing came back — so the layout doesn't jump.
808
808
  */
809
809
  loadChildren?: ((row: R) => Promise<R[]>) | null
810
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.
811
+ * If the application knows in advance whether a row CAN have children (in ViUR e.g.
812
+ * `node` vs. `leaf`), report it here: `false` → no arrow at all,
813
+ * `true`/`undefined` → arrow, loading happens on expand.
814
814
  */
815
815
  rowHasChildren?: ((row: R) => boolean | undefined) | null
816
816
  filterFromLeafRows?: boolean
817
- /** Zeilen bekommen ein Detail-Panel (Inhalt: Slot `row-detail`). */
817
+ /** Rows get a detail panel (content: slot `row-detail`). */
818
818
  rowDetail?: boolean
819
819
  rowClass?: ((row: R) => string | null | undefined) | null
820
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.
821
+ * Field holding the numeric order (ViUR: "sortindex"). When set,
822
+ * the table is fixed-sorted by it and rows can be reordered by
823
+ * dragging — the component reports the new value via
824
+ * `row-reorder`, saving happens outside.
825
825
  */
826
826
  orderField?: string | null
827
- /** Abstand für neue Werte am Anfang/Ende der Liste. */
827
+ /** Step for new values at the start/end of the list. */
828
828
  orderStep?: number
829
829
  sortDescFirst?: boolean
830
830
  withoutSortRemoval?: boolean
831
831
  maxMultiSort?: number
832
832
  searchFn?: ((value: unknown, term: string, row: R) => boolean) | null
833
- /** Server-Modus ohne Callback: die Komponente meldet `data-request`. */
833
+ /** Server mode without callback: the component emits `data-request`. */
834
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`.
835
+ * Cursor sources (e.g. ViUR lists) load forward instead of jumping between
836
+ * pages. If `hasMore` is set (even `false`), a bar with "Load more" appears
837
+ * instead of the pager; the caller does the loading on `load-more`.
838
838
  */
839
839
  hasMore?: boolean | null
840
840
  loadMoreLabel?: string | null
841
841
  loadAllLabel?: string | null
842
- /** Schnellwahl „die nächsten N Seiten laden" in der Cursor-Leiste. */
842
+ /** Quick pick "load the next N pages" in the cursor bar. */
843
843
  loadMorePages?: number[]
844
- /** Seitengrößen der Cursor-Leiste (ViUR: limit, maximal 99). */
844
+ /** Page sizes of the cursor bar (ViUR: limit, max 99). */
845
845
  cursorPageSizes?: number[]
846
846
  server?: boolean
847
847
  dataSource?: ((request: DataTableRequest) => Promise<DataTableResponse<R>>) | null
848
848
  total?: number
849
849
  loading?: boolean
850
850
  filterDebounce?: number
851
- /** true/false erzwingen, "auto" ab `virtualizeThreshold` Zeilen. */
851
+ /** Force true/false, "auto" from `virtualizeThreshold` rows on. */
852
852
  virtualize?: boolean | "auto"
853
853
  virtualizeThreshold?: number
854
854
  rowHeight?: number
855
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.
856
+ * Cap for the scroll area. WITHOUT a value the CSS default
857
+ * `--dt-max-height: 30rem` applies — so the variable stays overridable
858
+ * from outside (e.g. `--dt-max-height: none`, so the table fills an
859
+ * area). A set value is applied inline and wins.
860
860
  */
861
861
  maxHeight?: string
862
862
  }>(),
@@ -907,12 +907,12 @@ const props = withDefaults(
907
907
  virtualize: "auto",
908
908
  virtualizeThreshold: 60,
909
909
  rowHeight: 44,
910
- // Kein Default: siehe maxHeight oben — der Deckel kommt aus dem CSS.
910
+ // No default: see maxHeight above — the cap comes from the CSS.
911
911
  maxHeight: undefined,
912
912
  }
913
913
  )
914
914
 
915
- // Zustand, den außen mitgeführt werden kann — je ein v-model.
915
+ // State that can be tracked from outside — one v-model each.
916
916
  const sort = defineModel<SortEntry[]>("sort", { default: () => [] })
917
917
  const filters = defineModel<FilterEntry[]>("filters", { default: () => [] })
918
918
  const search = defineModel<string>("search", { default: "" })
@@ -923,7 +923,7 @@ const pageSize = defineModel<number>("pageSize", { default: 20 })
923
923
 
924
924
  const emit = defineEmits<{
925
925
  "cell-click": [payload: { column: string; value: unknown; row: R; index: number }]
926
- /** Doppelklick auf eine Datenzeile — typisch zum Öffnen des Datensatzes. */
926
+ /** Double click on a data row — typically to open the record. */
927
927
  "row-dblclick": [payload: { key: RowKey; row: R; index: number; originalEvent: MouseEvent }]
928
928
  "cell-contextmenu": [payload: { column: string; value: unknown; row: R; index: number; originalEvent: MouseEvent }]
929
929
  "sort-change": [sort: SortEntry[]]
@@ -940,15 +940,15 @@ const emit = defineEmits<{
940
940
  payload: {
941
941
  row: R
942
942
  key: RowKey
943
- /** Neuer Wert für das Feld aus `orderField`. */
943
+ /** New value for the `orderField` field. */
944
944
  order: number
945
- /** Neue Elternzeile (Baum); null = oberste Ebene. */
945
+ /** New parent row (tree); null = top level. */
946
946
  parent: RowKey | null
947
- /** true, wenn sich der Elternteil geändert hat (dann `move` statt `edit`). */
947
+ /** true if the parent changed (then `move` instead of `edit`). */
948
948
  parentChanged: boolean
949
- /** Position unter den neuen Geschwistern. */
949
+ /** Position among the new siblings. */
950
950
  toIndex: number
951
- /** Nachbarn tragen denselben Wert — die Liste sollte neu durchnummeriert werden. */
951
+ /** Neighbours share the same value — the list should be renumbered. */
952
952
  needsReindex: boolean
953
953
  },
954
954
  ]
@@ -960,47 +960,47 @@ const emit = defineEmits<{
960
960
 
961
961
  const uid = useId()
962
962
 
963
- // DOM-Refs (erlaubte Ausnahme).
963
+ // DOM refs (allowed exception).
964
964
  const scrollerEl = ref<HTMLElement>()
965
965
  const bodyEl = ref<HTMLElement>()
966
966
 
967
- // Nicht-reaktive Interna.
967
+ // Non-reactive internals.
968
968
  let headerObserver: ResizeObserver | undefined
969
969
  let detailObserver: ResizeObserver | undefined
970
970
  let debounceTimer = 0
971
971
  let abortController: AbortController | undefined
972
972
  let lastSelectedIndex: number | null = null
973
973
 
974
- // Umschließende Drag-Session (Browser): erlaubt, Zeilen AUSSERHALB der Tabelle
975
- // fallen zu lassen (Navigations-Baum, Ordnerzeilen). Ohne Browser: null.
974
+ // Enclosing drag session (Browser): allows dropping rows OUTSIDE the table
975
+ // (navigation tree, folder rows). Without Browser: null.
976
976
  const dragSession = inject(DRAG_SESSION, null)
977
977
 
978
978
  const { t } = useI18n()
979
979
 
980
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.
981
+ * Optimistic moves (drag & drop / Alt+arrow): rowKey → new
982
+ * order value and, if applicable, new parent. This makes the row vanish immediately
983
+ * from its old position and appear at the target (also INSIDE a folder) without
984
+ * waiting for saving and reloading. As soon as the caller delivers new data
985
+ * (props.data / server response), the overrides are discarded —
986
+ * then the data alone is authoritative again.
987
987
  */
988
988
  const moveOverrides = shallowRef(new Map<RowKey, { order: number; parent?: RowKey | null }>())
989
989
 
990
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.
991
+ * Optimistic moves in LAZY-LOAD mode (`loadChildren`): there the children
992
+ * don't live in `props.data` but in the cache `state.loadedChildren` —
993
+ * so a move takes the row directly out of the source bucket and
994
+ * puts a copy into the target bucket. For the top level (props.data must
995
+ * not be mutated) there is a hide list and injected copies.
996
+ * Both are — like moveOverrides — discarded as soon as new data arrives.
997
997
  */
998
998
  const lazyMoves = shallowRef({ hidden: new Set<RowKey>(), injected: [] as R[] })
999
999
 
1000
1000
  const LEAD_WIDTH = 44
1001
1001
  const ORDER_WIDTH = 36
1002
1002
 
1003
- /** Ablegen: davor, dahinter oder hinein (wie admin4). */
1003
+ /** Drop: before, after or inside (like admin4). */
1004
1004
  type DropMode = "before" | "after" | "inside"
1005
1005
 
1006
1006
  interface CellColumn {
@@ -1027,7 +1027,7 @@ const layout = useColumnLayout<R>({
1027
1027
  })
1028
1028
 
1029
1029
  const state = reactive({
1030
- /** Server-Modus: Zeilen und Gesamtzahl aus der letzten Antwort. */
1030
+ /** Server mode: rows and total count from the last response. */
1031
1031
  serverRows: [] as R[],
1032
1032
  serverTotal: -1,
1033
1033
  fetching: false,
@@ -1044,8 +1044,8 @@ const state = reactive({
1044
1044
  isServer: computed((): boolean => props.server || Boolean(props.dataSource)),
1045
1045
 
1046
1046
  /**
1047
- * Hierarchie aus einer flachen Liste (parentField). Null, sobald die Daten
1048
- * schon verschachtelt sind oder die Voraussetzungen fehlen.
1047
+ * Hierarchy from a flat list (parentField). Null once the data is
1048
+ * already nested or the prerequisites are missing.
1049
1049
  */
1050
1050
  nested: computed((): NestedTree<R> | null => {
1051
1051
  if (props.childRows || !props.parentField) return null
@@ -1057,9 +1057,9 @@ const state = reactive({
1057
1057
  }),
1058
1058
 
1059
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.
1060
+ * Access to the children: from `childRows`, from the built tree
1061
+ * (`parentField`) and/or from the lazy-loaded levels — merged in this
1062
+ * order.
1063
1063
  */
1064
1064
  childAccessor: computed((): ChildRowsAccessor<R> | null => {
1065
1065
  const nested = state.nested as NestedTree<R> | null
@@ -1083,16 +1083,16 @@ const state = reactive({
1083
1083
  isTree: computed((): boolean => Boolean(state.childAccessor)),
1084
1084
  isLoading: computed((): boolean => props.loading ?? state.fetching),
1085
1085
 
1086
- /** Nachgeladene Kinder je Zeilenschlüssel (leeres Array = es gibt keine). */
1086
+ /** Lazy-loaded children per row key (empty array = there are none). */
1087
1087
  loadedChildren: {} as Record<string, R[]>,
1088
- /** Zeilen, deren Kinder gerade geladen werden. */
1088
+ /** Rows whose children are currently loading. */
1089
1089
  loadingChildren: {} as Record<string, boolean>,
1090
1090
 
1091
- /** Laufende Zeilen-Ziehgeste (feste Reihenfolge). */
1091
+ /** Ongoing row drag gesture (fixed order). */
1092
1092
  rowDrag: null as {
1093
1093
  key: RowKey
1094
1094
  overKey: RowKey | null
1095
- /** Wie admin4: davor, dahinter oder HINEIN (neuer Elternteil). */
1095
+ /** Like admin4: before, after or INSIDE (new parent). */
1096
1096
  mode: DropMode
1097
1097
  } | null,
1098
1098
 
@@ -1116,24 +1116,24 @@ const state = reactive({
1116
1116
  return patched
1117
1117
  })
1118
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.
1119
+ // Fixed order: numerically ascending — also in SERVER mode. Only this way
1120
+ // does a row moved via drag & drop IMMEDIATELY take its place without
1121
+ // reloading the level (the new value comes from moveOverrides above).
1122
+ // An active column sort takes precedence: then the pipeline orders in
1123
+ // client mode and the server in server mode.
1124
1124
  if (!props.orderField || sort.value.length > 0) return base
1125
1125
  const field = props.orderField
1126
1126
  return [...base].sort((a, b) => Number(a[field] ?? 0) - Number(b[field] ?? 0))
1127
1127
  }),
1128
1128
 
1129
1129
  /**
1130
- * Umsortieren ist nur in der festen Reihenfolge sinnvoll — nicht bei
1131
- * Gruppierung und nicht, während eine Spaltensortierung aktiv ist.
1130
+ * Reordering only makes sense in the fixed order — not with
1131
+ * grouping and not while a column sort is active.
1132
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.
1133
+ * In server mode it is explicitly allowed: the order comes from the
1134
+ * server there (`orderby` on the field), the new value is computed from the
1135
+ * neighbours and reported outward via `row-reorder` — saving happens
1136
+ * outside anyway.
1137
1137
  */
1138
1138
  orderActive: computed(
1139
1139
  (): boolean => Boolean(props.orderField) && !state.groupByList.length && sort.value.length === 0
@@ -1141,7 +1141,7 @@ const state = reactive({
1141
1141
 
1142
1142
  sort: computed((): SortEntry[] => sort.value),
1143
1143
 
1144
- /** Filter/Suche/Sortierung/Gruppierung — im Server-Modus macht das der Server. */
1144
+ /** Filter/search/sort/grouping — in server mode the server does this. */
1145
1145
  built: computed(
1146
1146
  (): BuiltRows<R> =>
1147
1147
  buildRows<R>({
@@ -1158,19 +1158,19 @@ const state = reactive({
1158
1158
  })
1159
1159
  ),
1160
1160
 
1161
- /** Cursor-Modus: der Aufrufer steuert das Nachladen (siehe hasMore). */
1161
+ /** Cursor mode: the caller controls loading more (see hasMore). */
1162
1162
  cursorMode: computed((): boolean => props.hasMore !== null && props.hasMore !== undefined),
1163
- /** Seitengrößen der Cursor-Leiste — die aktuelle Größe ist immer wählbar. */
1163
+ /** Page sizes of the cursor bar — the current size is always selectable. */
1164
1164
  cursorPageSizes: computed((): number[] => {
1165
1165
  const options = new Set(props.cursorPageSizes)
1166
1166
  options.add(pageSize.value)
1167
1167
  return [...options].sort((a, b) => a - b)
1168
1168
  }),
1169
- /** „N Einträge" bzw. „N von M" — M nur, wenn der Server eine Gesamtzahl kennt. */
1169
+ /** "N entries" or "N of M" — M only if the server knows a total count. */
1170
1170
  loadedText: computed((): string => {
1171
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.
1172
+ // Local search/filter active: show the number of MATCHES, not the
1173
+ // load counter — otherwise the bar looks as if nothing were filtered.
1174
1174
  if (!state.isServer && state.filteredCount !== loaded) {
1175
1175
  return t("dataTable.filteredOf", { filtered: state.filteredCount, loaded })
1176
1176
  }
@@ -1203,15 +1203,15 @@ const state = reactive({
1203
1203
  flattenNodes(state.pagedNodes as RowNode<R>[], {
1204
1204
  isExpanded: (key) => state.expandedSet.has(key),
1205
1205
  withDetail: props.rowDetail,
1206
- // Beim Nachladen ist vorher unbekannt, ob eine Zeile Kinder hat: Pfeil
1207
- // zeigen, solange das Gegenteil nicht feststeht.
1206
+ // With lazy loading it's unknown beforehand whether a row has children: show
1207
+ // the arrow as long as the opposite isn't established.
1208
1208
  mayHaveChildren: props.loadChildren
1209
1209
  ? (node) => node.kind === "data" && Boolean(node.row) && childState(node.row!) !== "none"
1210
1210
  : undefined,
1211
1211
  })
1212
1212
  ),
1213
1213
 
1214
- /** Index in der Renderliste je Zeilenschlüssel — für Tastatur und Fokus. */
1214
+ /** Index in the render list per row key — for keyboard and focus. */
1215
1215
  rowIndexOf: computed((): Record<string, number> => {
1216
1216
  const map: Record<string, number> = {}
1217
1217
  state.displayRows.forEach((row, index) => {
@@ -1256,7 +1256,7 @@ const state = reactive({
1256
1256
  return map
1257
1257
  }),
1258
1258
 
1259
- /** Führende Spalten (Auswahl, Aufklappen) plus die sichtbaren Datenspalten. */
1259
+ /** Leading columns (selection, expand) plus the visible data columns. */
1260
1260
  cellColumns: computed((): CellColumn[] => {
1261
1261
  const cells: CellColumn[] = []
1262
1262
  let lead = 0
@@ -1318,9 +1318,9 @@ const state = reactive({
1318
1318
  firstDataCellIndex: computed((): number => state.cellColumns.find((cell) => cell.kind === "data")?.index ?? 0),
1319
1319
 
1320
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.
1321
+ * Dedicated expand column only for groups and detail panels. With tree data
1322
+ * the arrow sits IN the first data column — only there does it indent with the
1323
+ * level, and that is exactly what makes the hierarchy visible.
1324
1324
  */
1325
1325
  hasExpandColumn: computed((): boolean => Boolean(props.rowDetail || state.groupByList.length)),
1326
1326
 
@@ -1335,15 +1335,15 @@ const state = reactive({
1335
1335
 
1336
1336
  hostStyle: computed((): Record<string, string> => {
1337
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.
1338
+ // Only set when given — otherwise the inline value would override every
1339
+ // outside rule and `--dt-max-height` would no longer be a variable.
1340
1340
  if (props.maxHeight) style["--dt-max-height"] = props.maxHeight
1341
1341
  return style
1342
1342
  }),
1343
1343
 
1344
1344
  gridRole: computed((): string => (state.isTree ? "treegrid" : "grid")),
1345
1345
 
1346
- // Panel-Props sind nicht generisch — hier auf die Basisform bringen.
1346
+ // Panel props aren't generic — cast to the base form here.
1347
1347
  filterColumns: computed((): ResolvedColumn[] =>
1348
1348
  (layout.state.visibleColumns as LayoutColumn<R>[])
1349
1349
  .map((col) => col.column as unknown as ResolvedColumn)
@@ -1379,8 +1379,8 @@ const state = reactive({
1379
1379
  showToolbar: computed((): boolean => props.withSearch || props.withColumnsMenu),
1380
1380
  })
1381
1381
 
1382
- // reactive() entfaltet die Generics zu `Row`; diese Helfer geben den Zugriffen
1383
- // ihren konkreten Typ zurück.
1382
+ // reactive() unwraps the generics to `Row`; these helpers give the accessors
1383
+ // their concrete type back.
1384
1384
  const cols = (): ResolvedColumn<R>[] => state.columns as ResolvedColumn<R>[]
1385
1385
  const displayed = (): DisplayRow<R>[] => state.displayRows as DisplayRow<R>[]
1386
1386
  const dataRowsOf = (): R[] => state.built.dataRows as R[]
@@ -1458,7 +1458,7 @@ const nav = useGridNavigation(scrollerEl, {
1458
1458
  },
1459
1459
  })
1460
1460
 
1461
- // ── Darstellung ──────────────────────────────────────────────────────
1461
+ // ── Rendering ────────────────────────────────────────────────────────
1462
1462
 
1463
1463
  function cellStyle(cell: CellColumn): Record<string, string> {
1464
1464
  if (!cell.sticky) return {}
@@ -1468,13 +1468,13 @@ function cellStyle(cell: CellColumn): Record<string, string> {
1468
1468
  }
1469
1469
 
1470
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.
1471
+ * Indentation of tree and group levels. It sits on the FIRST data column
1472
+ * — in the narrow expand column (fixed width) it would be invisible.
1473
1473
  */
1474
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.
1475
+ * Indentation of the levels. In the tree the guide slots handle this
1476
+ * (`.vi-dt-guide`) — there the normal padding stays. Groups without a tree
1477
+ * have no guide lines and are still indented via padding.
1478
1478
  */
1479
1479
  function indentStyle(cell: CellColumn, displayRow: DisplayRow<R>): Record<string, string> {
1480
1480
  if (cell.index !== state.firstDataCellIndex || displayRow.depth === 0) return {}
@@ -1519,13 +1519,13 @@ function aggregateText(cell: CellColumn, displayRow: DisplayRow<R>): string {
1519
1519
  return column.def.formatter && first ? column.def.formatter(value, first) : defaultText(value)
1520
1520
  }
1521
1521
 
1522
- // ── Sortierung ───────────────────────────────────────────────────────
1522
+ // ── Sorting ──────────────────────────────────────────────────────────
1523
1523
 
1524
1524
  function toggleSort(column: ResolvedColumn<R>, multi: boolean): void {
1525
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).
1526
+ // The NEW value is kept locally and passed on — not read back from the model
1527
+ // (see scheduleFetch: with a bound v-model the update only arrives
1528
+ // with the parent's prop).
1529
1529
  const next = nextSortState(sort.value, column as ResolvedColumn, {
1530
1530
  multi,
1531
1531
  descFirst: props.sortDescFirst,
@@ -1550,12 +1550,12 @@ function onHeaderClick(cell: CellColumn, event: MouseEvent): void {
1550
1550
  nav.setActive(-1, cell.index, false)
1551
1551
  if (cell.kind !== "data") return
1552
1552
  const target = event.target as HTMLElement
1553
- // Klicks auf Bedienelemente im Kopf (Filter, Menü, Pin, Resize) nicht sortieren.
1553
+ // Don't sort on clicks on header controls (filter, menu, pin, resize).
1554
1554
  if (target.closest(".vi-dt-filter-button, .vi-dt-column-menu, .vi-dt-pin, .vi-dt-resize")) return
1555
1555
  toggleSort(cell.col!.column, event.shiftKey)
1556
1556
  }
1557
1557
 
1558
- // ── Suche & Filter ───────────────────────────────────────────────────
1558
+ // ── Search & filter ──────────────────────────────────────────────────
1559
1559
 
1560
1560
  function onSearchInput(value: string): void {
1561
1561
  search.value = value
@@ -1573,9 +1573,9 @@ function setFilter(columnId: string, value: unknown): void {
1573
1573
  if (state.isServer) scheduleFetch(true, { filters: next })
1574
1574
  }
1575
1575
 
1576
- // ── Auswahl ──────────────────────────────────────────────────────────
1576
+ // ── Selection ────────────────────────────────────────────────────────
1577
1577
 
1578
- /** Plaketten einer Zelle (Spalten mit `badge`-Funktion, z. B. select/bool). */
1578
+ /** Badges of a cell (columns with a `badge` function, e.g. select/bool). */
1579
1579
  function badgesFor(column: ResolvedColumn<R>, row: R): CellBadge[] | null {
1580
1580
  const badges = column.def.badge?.(column.get(row), row)
1581
1581
  return badges?.length ? badges : null
@@ -1586,10 +1586,10 @@ function canSelect(row: R): boolean {
1586
1586
  }
1587
1587
 
1588
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.
1589
+ * Selectable keys of the current page — including collapsed
1590
+ * subtrees and group members, but without locked rows
1591
+ * (`selectableRows`). Only visit the page's root nodes, otherwise
1592
+ * expanded children count twice.
1593
1593
  */
1594
1594
  function selectableKeysOnPage(): RowKey[] {
1595
1595
  const keys: RowKey[] = []
@@ -1649,7 +1649,7 @@ function toggleRowSelection(displayRow: DisplayRow<R>, range: boolean): void {
1649
1649
  return
1650
1650
  }
1651
1651
 
1652
- // Baum/Gruppe: Kinder mitnehmen.
1652
+ // Tree/group: include children.
1653
1653
  const keys = displayRow.node ? collectKeys(displayRow.node) : [displayRow.nodeKey]
1654
1654
  const selectable = keys.filter((key) => {
1655
1655
  const row = findRowByKey(key)
@@ -1670,19 +1670,19 @@ function findRowByKey(key: RowKey): R | undefined {
1670
1670
  }
1671
1671
 
1672
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.
1673
+ * Selection via checkbox. Deliberately on `click` rather than `change`: only the
1674
+ * click event carries `shiftKey`, and range selection relies on it.
1675
1675
  */
1676
1676
  function onSelectRow(displayRow: DisplayRow<R>, event: MouseEvent): void {
1677
1677
  toggleRowSelection(displayRow, event.shiftKey)
1678
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).
1679
+ // Click on the ROW (not the checkbox): behaves like a file manager.
1680
+ // A plain click replaces the selection with this row; Ctrl/Cmd adds
1681
+ // or removes it; Shift selects the range. The checkbox
1682
+ // itself (onSelectRow) still toggles additively.
1683
+ // With Ctrl/Cmd or Shift held, the click serves multi-selection —
1684
+ // the browser's text selection is suppressed for this (preventDefault on
1685
+ // mousedown prevents the selection from starting, the click still arrives).
1686
1686
  function onCellMouseDown(event: MouseEvent): void {
1687
1687
  if (props.selectable === false || !props.selectOnRowClick) return
1688
1688
  if (event.ctrlKey || event.metaKey || event.shiftKey) event.preventDefault()
@@ -1694,7 +1694,7 @@ function selectRowFromClick(displayRow: DisplayRow<R>, event: MouseEvent): void
1694
1694
  }
1695
1695
  if (displayRow.kind === "detail") return
1696
1696
  if (displayRow.kind === "data" && displayRow.row && !canSelect(displayRow.row)) return
1697
- // Baum/Gruppe: Kinder mitnehmen (wie beim Kästchen).
1697
+ // Tree/group: include children (like the checkbox).
1698
1698
  const keys = (displayRow.node ? collectKeys(displayRow.node) : [displayRow.nodeKey]).filter((key) => {
1699
1699
  const row = findRowByKey(key)
1700
1700
  return !row || canSelect(row)
@@ -1722,18 +1722,18 @@ function toggleSelectAll(): void {
1722
1722
  selectAllOnPage()
1723
1723
  }
1724
1724
 
1725
- // ── Kinder nachladen ─────────────────────────────────────────────────
1725
+ // ── Lazy-load children ───────────────────────────────────────────────
1726
1726
 
1727
1727
  function rowKeyOf(row: R): RowKey {
1728
1728
  return props.rowKey ? (row[props.rowKey] as RowKey) : ""
1729
1729
  }
1730
1730
 
1731
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
1732
+ * What do we know about this row's children?
1733
+ * "none" → definitely none (the application says so via `rowHasChildren`)
1734
+ * "empty" → loaded, there were none → arrow stays, but disabled
1735
+ * "loaded" → loaded, there are children
1736
+ * "unknown" → not loaded yet → arrow active
1737
1737
  */
1738
1738
  function childState(row: R): "none" | "empty" | "loaded" | "unknown" {
1739
1739
  if (props.rowHasChildren?.(row) === false) return "none"
@@ -1746,13 +1746,13 @@ function isLoadingChildren(row?: R): boolean {
1746
1746
  return row ? Boolean(state.loadingChildren[String(rowKeyOf(row))]) : false
1747
1747
  }
1748
1748
 
1749
- /** Pfeil ohne Wirkung: nachgeladen und es gibt nichts. */
1749
+ /** Arrow without effect: loaded and there is nothing. */
1750
1750
  function isEmptyBranch(displayRow: DisplayRow<R>): boolean {
1751
1751
  if (!props.loadChildren || displayRow.kind !== "data" || !displayRow.row) return false
1752
1752
  return childState(displayRow.row) === "empty" && !displayRow.node?.children.length
1753
1753
  }
1754
1754
 
1755
- /** Kinder einer Zeile holen (einmalig; `force` lädt erneut). */
1755
+ /** Fetch a row's children (once; `force` reloads). */
1756
1756
  async function fetchChildren(row: R, force = false): Promise<void> {
1757
1757
  if (!props.loadChildren) return
1758
1758
  const key = String(rowKeyOf(row))
@@ -1771,20 +1771,22 @@ async function fetchChildren(row: R, force = false): Promise<void> {
1771
1771
  }
1772
1772
  }
1773
1773
 
1774
- // ── Aufklappen ───────────────────────────────────────────────────────
1774
+ // ── Expanding ────────────────────────────────────────────────────────
1775
1775
 
1776
1776
  function toggleExpand(displayRow: DisplayRow<R>): void {
1777
1777
  if (!displayRow.expandable) return
1778
1778
  if (isEmptyBranch(displayRow) || isLoadingChildren(displayRow.row)) return
1779
1779
 
1780
- // Erst laden, dann aufklappen — sonst klappt eine leere Ebene auf und
1781
- // wieder zu, sobald die Antwort kommt.
1780
+ // Load first, then expand — otherwise an empty level expands and
1781
+ // collapses again as soon as the response arrives.
1782
1782
  if (props.loadChildren && displayRow.kind === "data" && displayRow.row) {
1783
1783
  const row = displayRow.row
1784
1784
  if (childState(row) === "unknown" && !displayRow.node?.children.length) {
1785
- void fetchChildren(row).then(() => {
1786
- if (childState(row) !== "empty") expand(displayRow.nodeKey, row)
1787
- })
1785
+ void fetchChildren(row)
1786
+ .then(() => {
1787
+ if (childState(row) !== "empty") expand(displayRow.nodeKey, row)
1788
+ })
1789
+ .catch((error) => console.warn("[DataTable] Ebene konnte nicht aufgeklappt werden", error))
1788
1790
  return
1789
1791
  }
1790
1792
  }
@@ -1801,7 +1803,7 @@ function toggleExpand(displayRow: DisplayRow<R>): void {
1801
1803
  }
1802
1804
  }
1803
1805
 
1804
- /** Zeile aufklappen und melden. */
1806
+ /** Expand a row and emit the event. */
1805
1807
  function expand(key: RowKey, row?: R): void {
1806
1808
  if (expandedKeys.value.includes(key)) return
1807
1809
  expandedKeys.value = [...expandedKeys.value, key]
@@ -1816,19 +1818,19 @@ function allExpandableKeys(nodes: RowNode<R>[] = state.built.nodes as RowNode<R>
1816
1818
  return into
1817
1819
  }
1818
1820
 
1819
- // ── Zellklicks ───────────────────────────────────────────────────────
1821
+ // ── Cell clicks ──────────────────────────────────────────────────────
1820
1822
 
1821
1823
  /**
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".
1824
+ * Double click on a data row. Controls in the row (selection,
1825
+ * expand, actions) don't trigger it — double-clicking there doesn't mean
1826
+ * "open record".
1825
1827
  */
1826
1828
  function onRowDoubleClick(displayRow: DisplayRow<R>, event: MouseEvent): void {
1827
1829
  if (displayRow.kind !== "data" || !displayRow.row) return
1828
1830
  const target = event.target as HTMLElement
1829
1831
  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
+ // A double click fires two clicks, which would undo the click selection
1833
+ // again — the row stays/becomes selected.
1832
1834
  if (props.selectable !== false && props.selectOnRowClick && canSelect(displayRow.row)) {
1833
1835
  const key = displayRow.nodeKey
1834
1836
  if (!state.selectedSet.has(key)) {
@@ -1852,8 +1854,8 @@ function onCellClick(displayRow: DisplayRow<R>, cell: CellColumn, event: MouseEv
1852
1854
  if (cell.kind !== "data" || !displayRow.row) return
1853
1855
  const target = event.target as HTMLElement
1854
1856
  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
+ // Select the row on click — unless the click hits an interactive element
1858
+ // in the cell (link, button, input field) that handles the click itself.
1857
1859
  if (
1858
1860
  props.selectable !== false &&
1859
1861
  props.selectOnRowClick &&
@@ -1881,14 +1883,14 @@ function onCellContextMenu(displayRow: DisplayRow<R>, cell: CellColumn, event: M
1881
1883
  })
1882
1884
  }
1883
1885
 
1884
- // ── Feste Reihenfolge: Zeilen umsortieren ────────────────────────────
1886
+ // ── Fixed order: reordering rows ─────────────────────────────────────
1885
1887
 
1886
1888
  /**
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.
1889
+ * Rows are dragged with POINTER events, not with HTML5 drag and drop.
1890
+ * Reason: `dragover` requires an accepting target and fires unreliably
1891
+ * depending on the environment (not at all on touch) — the insertion marker would then
1892
+ * be missing and the target position would be random. Pointer events behave the same
1893
+ * everywhere, work with touch and are testable.
1892
1894
  */
1893
1895
  function beginRowDrag(displayRow: DisplayRow<R>, event: PointerEvent): void {
1894
1896
  if (!state.orderActive || displayRow.kind !== "data") return
@@ -1916,15 +1918,15 @@ function beginRowDrag(displayRow: DisplayRow<R>, event: PointerEvent): void {
1916
1918
  }
1917
1919
 
1918
1920
  /**
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.
1921
+ * Determine the row under the pointer and derive the zone. As in admin4 there are
1922
+ * three: top quarter = before, bottom quarter = after, the middle = INSIDE
1923
+ * (the row becomes a child of the target). Without a tree only before/after remain.
1922
1924
  */
1923
1925
  function updateRowDrag(clientX: number, clientY: number): void {
1924
1926
  const drag = state.rowDrag
1925
1927
  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
+ // Ask the enclosing session first: if it provides a target (tree node or
1929
+ // folder row), it takes over display and drop — the internal indicator rests.
1928
1930
  if (dragSession?.state.active) {
1929
1931
  const external = dragSession.update(clientX, clientY)
1930
1932
  if (external) {
@@ -1937,7 +1939,7 @@ function updateRowDrag(clientX: number, clientY: number): void {
1937
1939
  const target = displayed().find((row) => row.key === element.dataset.key)
1938
1940
  if (!target || target.nodeKey === drag.key) return
1939
1941
 
1940
- // In den eigenen Teilbaum ziehen ist nicht möglich — er hinge an sich selbst.
1942
+ // Dragging into its own subtree is impossible — it would hang from itself.
1941
1943
  if (isDescendant(state.built.nodes as RowNode<R>[], drag.key, target.nodeKey)) {
1942
1944
  drag.overKey = null
1943
1945
  return
@@ -1952,7 +1954,7 @@ function updateRowDrag(clientX: number, clientY: number): void {
1952
1954
  else drag.mode = "inside"
1953
1955
  }
1954
1956
 
1955
- /** Nachlade-Modus: Zeile aus ihrem alten Bucket nehmen und am Ziel einsetzen. */
1957
+ /** Lazy-load mode: take the row out of its old bucket and insert it at the target. */
1956
1958
  function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, insertAt: number, order: number): void {
1957
1959
  const row = source.node.row
1958
1960
  if (!row) return
@@ -1964,9 +1966,9 @@ function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, ins
1964
1966
  const hidden = new Set(lazyMoves.value.hidden)
1965
1967
  let injected = lazyMoves.value.injected.filter((candidate) => (candidate[props.rowKey!] as RowKey) !== key)
1966
1968
 
1967
- // Aus der Quelle entfernen …
1969
+ // Remove from the source …
1968
1970
  if (source.parentKey === null) {
1969
- hidden.add(key) // oberste Ebene: props.data bleibt unangetastet, nur ausblenden
1971
+ hidden.add(key) // top level: props.data stays untouched, just hide
1970
1972
  } else {
1971
1973
  const bucket = state.loadedChildren[String(source.parentKey)] as R[] | undefined
1972
1974
  if (bucket) {
@@ -1976,10 +1978,10 @@ function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, ins
1976
1978
  }
1977
1979
  }
1978
1980
 
1979
- // … und am Ziel einsetzen.
1981
+ // … and insert at the target.
1980
1982
  if (targetParent === null) {
1981
- // Original (falls es in props.data steht) bleibt ausgeblendet; sichtbar
1982
- // ist die Kopie mit neuem Ordnungswert.
1983
+ // The original (if it is in props.data) stays hidden; the copy with the
1984
+ // new order value is visible.
1983
1985
  hidden.add(key)
1984
1986
  injected = [...injected, moved]
1985
1987
  } else {
@@ -1989,20 +1991,20 @@ function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, ins
1989
1991
  next.splice(Math.max(0, Math.min(insertAt, next.length)), 0, moved)
1990
1992
  state.loadedChildren[String(targetParent)] = next as (typeof state.loadedChildren)[string]
1991
1993
  }
1992
- // Bucket noch nie geladen: beim Aufklappen holt fetchChildren den Stand
1993
- // vom Server — der enthält die Zeile nach dem Speichern ohnehin.
1994
+ // Bucket never loaded: on expand fetchChildren gets the current state
1995
+ // from the server — which contains the row after saving anyway.
1994
1996
  }
1995
1997
 
1996
1998
  lazyMoves.value = { hidden, injected }
1997
1999
  }
1998
2000
 
1999
2001
  /**
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.
2002
+ * Reset lazy-load mode: discard the children cache and collapse all
2003
+ * branches — the arrows are back to "collapsed" afterwards, and the next
2004
+ * expand fetches the fresh state from the server. Runs on `reload()` and
2005
+ * when the caller delivers new data; without `loadChildren` (parentField
2006
+ * trees) the expand state is kept, since there the children are always
2007
+ * derived from the current data anyway.
2006
2008
  */
2007
2009
  function resetLazyTree(): void {
2008
2010
  if (!props.loadChildren) return
@@ -2011,15 +2013,15 @@ function resetLazyTree(): void {
2011
2013
  if (expandedKeys.value.length) expandedKeys.value = []
2012
2014
  }
2013
2015
 
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.
2016
+ // Drag & drop is purely CLIENT-SIDE: the row is moved locally (buckets/
2017
+ // overrides above), `row-reorder` reports the move for saving in the
2018
+ // background — the table reloads NOTHING afterwards, and the app doesn't
2019
+ // have to either. When new data comes in from outside, it is authoritative: the
2020
+ // optimistic overrides are discarded (fresh data already contains
2021
+ // a saved move). The lazy-load tree stays as it is —
2022
+ // it is only collapsed and cleared via `reload()`/`resetTree()` or, via the
2023
+ // opt-in `resetTreeOnDataChange`, on every data change not preceded by a
2024
+ // move.
2023
2025
  watch(
2024
2026
  () => (props.dataSource ? (state.serverRows as R[]) : props.data),
2025
2027
  () => {
@@ -2036,8 +2038,8 @@ watch(
2036
2038
  function commitRowDrag(): void {
2037
2039
  const drag = state.rowDrag
2038
2040
  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
+ // A session target (tree/folder outside the internal logic) takes precedence:
2042
+ // the Browser takes over the move, nothing else happens here.
2041
2043
  if (dragSession?.state.active) {
2042
2044
  if (dragSession.state.over) {
2043
2045
  dragSession.drop()
@@ -2051,11 +2053,11 @@ function commitRowDrag(): void {
2051
2053
  }
2052
2054
 
2053
2055
  /**
2054
- * Zeile an eine neue Stelle setzen und den neuen Ordnungswert melden.
2056
+ * Move a row to a new position and report the new order value.
2055
2057
  *
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.
2058
+ * The computation is always among the future SIBLINGS — for "inside" that is
2059
+ * among the target's children, otherwise among the target's siblings. Only then
2060
+ * does the value make sense in the tree: `sortindex` applies per parent.
2059
2061
  */
2060
2062
  function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2061
2063
  if (!props.orderField || key === targetKey) return
@@ -2065,9 +2067,9 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2065
2067
  const target = locateNode(nodes, targetKey)
2066
2068
  if (!target) return
2067
2069
 
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.
2070
+ // Multi-selection: if the dragged row is part of the selection, the whole
2071
+ // selection moves along — in display order. Rows that may not go to the target
2072
+ // (the target itself, ancestors of the target) are left out.
2071
2073
  const moving = movingKeysFor(key).filter(
2072
2074
  (candidate) => candidate !== targetKey && !isDescendant(nodes, candidate, targetKey)
2073
2075
  )
@@ -2081,7 +2083,7 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2081
2083
  let values: number[]
2082
2084
 
2083
2085
  if (mode === "inside") {
2084
- // Neue Kinder: hinter die vorhandenen Kinder des Ziels.
2086
+ // New children: after the target's existing children.
2085
2087
  parentKey = targetKey
2086
2088
  const children = target.node.children.filter((child) => !movingSet.has(child.key))
2087
2089
  values = children.map(orderOf)
@@ -2103,12 +2105,12 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2103
2105
  if (!source?.node.row) continue
2104
2106
 
2105
2107
  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
+ // Add the assigned value to the neighbour list so the next
2109
+ // row of the selection is sorted in AFTER it (order is preserved).
2108
2110
  values.splice(Math.max(0, Math.min(values.length, insertAt)), 0, order)
2109
2111
 
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
+ // Apply optimistically: the row leaves its old position immediately and
2113
+ // appears at the target — on a drop INTO a folder, among its children.
2112
2114
  if (props.rowKey) {
2113
2115
  if (props.loadChildren) {
2114
2116
  applyLazyMove(source, parentKey, insertAt, order)
@@ -2146,10 +2148,10 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2146
2148
  }
2147
2149
 
2148
2150
  /**
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.
2151
+ * Which rows a move affects: only the dragged one — or, if it is part of the
2152
+ * selection, all selected rows in display order. Selected
2153
+ * descendants of an also-selected row move implicitly with their
2154
+ * subtree and are not moved twice.
2153
2155
  */
2154
2156
  function movingKeysFor(key: RowKey): RowKey[] {
2155
2157
  if (props.selectable === false || !state.selectedSet.has(key) || state.selectedSet.size < 2) return [key]
@@ -2159,8 +2161,8 @@ function movingKeysFor(key: RowKey): RowKey[] {
2159
2161
  for (const row of state.dataDisplayRows as DisplayRow<R>[]) {
2160
2162
  if (selected.has(row.nodeKey)) ordered.push(row.nodeKey)
2161
2163
  }
2162
- // dataDisplayRows enthält nur SICHTBARE Zeilen — ausgewählte Zeilen in
2163
- // zugeklappten Zweigen hängen wir hinten an, damit sie nicht liegenbleiben.
2164
+ // dataDisplayRows only contains VISIBLE rows — selected rows in
2165
+ // collapsed branches are appended so they aren't left behind.
2164
2166
  const seen = new Set(ordered)
2165
2167
  for (const candidate of selected) {
2166
2168
  if (!seen.has(candidate)) ordered.push(candidate)
@@ -2172,15 +2174,15 @@ function movingKeysFor(key: RowKey): RowKey[] {
2172
2174
  return result.length ? result : [key]
2173
2175
  }
2174
2176
 
2175
- /** Zeilenschlüssel wie in der Pipeline (rowKey oder Indexpfad). */
2177
+ /** Row key as in the pipeline (rowKey or index path). */
2176
2178
  function keyOfRow(row: R, index: number): RowKey {
2177
2179
  return props.rowKey ? (row[props.rowKey] as RowKey) : String(index)
2178
2180
  }
2179
2181
 
2180
2182
  /**
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.
2183
+ * Keyboard: move a row by one position (Alt + ↑/↓) — within its
2184
+ * SIBLINGS. Changing the parent is reserved for the mouse; via keyboard
2185
+ * it wouldn't be clear which level is being targeted.
2184
2186
  */
2185
2187
  function moveRowByKeyboard(rowIndex: number, delta: number): void {
2186
2188
  if (!state.orderActive) return
@@ -2194,7 +2196,7 @@ function moveRowByKeyboard(rowIndex: number, delta: number): void {
2194
2196
  nav.setActive(rowIndex + delta, nav.state.col)
2195
2197
  }
2196
2198
 
2197
- // ── Spalten: Resize, Reorder, Pin ────────────────────────────────────
2199
+ // ── Columns: resize, reorder, pin ────────────────────────────────────
2198
2200
 
2199
2201
  function onResizeStart(columnId: string, event: PointerEvent): void {
2200
2202
  layout.startResize(columnId, event.clientX)
@@ -2225,13 +2227,13 @@ function pinColumn(columnId: string, side: PinSide | false): void {
2225
2227
  }
2226
2228
 
2227
2229
  /**
2228
- * Spalte auf die Breite ihres breitesten Inhalts bringen.
2230
+ * Size a column to the width of its widest content.
2229
2231
  *
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.
2232
+ * The TEXT is measured, not the box: the content sits in a
2233
+ * block element that fills the cell — its scrollWidth would therefore always be
2234
+ * the current column width, and autosize could never shrink. A Range
2235
+ * over the node contents yields the real text width; for component content
2236
+ * (badges, tags) the measurement falls back to scrollWidth.
2235
2237
  */
2236
2238
  function autoSizeColumn(columnId: string): void {
2237
2239
  const cells = scrollerEl.value?.querySelectorAll<HTMLElement>(`[data-column="${columnId}"]`)
@@ -2247,7 +2249,7 @@ function autoSizeColumn(columnId: string): void {
2247
2249
  }
2248
2250
  widest = Math.max(widest, Math.max(measured, content.scrollWidth === content.clientWidth ? 0 : content.scrollWidth))
2249
2251
  }
2250
- const width = Math.ceil(widest) + 40 // Polsterung + Werkzeuge im Kopf
2252
+ const width = Math.ceil(widest) + 40 // padding + header tools
2251
2253
  layout.setWidth(columnId, width)
2252
2254
  emit("column-resize", { column: columnId, width, finished: true })
2253
2255
  }
@@ -2272,7 +2274,7 @@ function resetColumns(): void {
2272
2274
  layout.resetColumns()
2273
2275
  }
2274
2276
 
2275
- // ── Paginierung ──────────────────────────────────────────────────────
2277
+ // ── Pagination ───────────────────────────────────────────────────────
2276
2278
 
2277
2279
  function setPage(next: number, notify = true): void {
2278
2280
  const clamped = Math.max(0, Math.min(state.pageCount - 1, next))
@@ -2283,7 +2285,7 @@ function setPage(next: number, notify = true): void {
2283
2285
  if (state.isServer) scheduleFetch(false, { page: clamped })
2284
2286
  }
2285
2287
 
2286
- /** Cursor-Leiste: neue Seitengröße — der Aufrufer lädt damit neu (page-change). */
2288
+ /** Cursor bar: new page size — the caller reloads with it (page-change). */
2287
2289
  function onCursorPageSize(next: number): void {
2288
2290
  pageSize.value = next
2289
2291
  emit("page-change", { page: 0, pageSize: next })
@@ -2296,16 +2298,16 @@ function setPageSize(next: number): void {
2296
2298
  if (state.isServer) scheduleFetch(false, { page: 0, pageSize: next })
2297
2299
  }
2298
2300
 
2299
- // ── Server-Modus ─────────────────────────────────────────────────────
2301
+ // ── Server mode ──────────────────────────────────────────────────────
2300
2302
 
2301
2303
  /**
2302
- * Was der nächste Server-Abruf gegenüber den Modellen abweichend verwenden soll.
2304
+ * What the next server fetch should use instead of the model values.
2303
2305
  *
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.
2306
+ * Needed because with a BOUND `v-model`, Vue doesn't set the local value of a
2307
+ * `defineModel` immediately: the update only arrives via the parent's
2308
+ * prop. Reading right after writing therefore yields the old value —
2309
+ * unnoticed as long as nobody binds `v-model:sort` & co., and visibly wrong
2310
+ * on the first sort click as soon as someone does.
2309
2311
  */
2310
2312
  type RequestOverrides = Partial<Pick<DataTableRequest, "sort" | "filters" | "search" | "page" | "pageSize">>
2311
2313
  let pendingRequest: RequestOverrides = {}
@@ -2364,12 +2366,12 @@ async function fetchRows(): Promise<void> {
2364
2366
 
2365
2367
  function reload(): void {
2366
2368
  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
+ // Without dataSource the caller replaces props.data itself (the watch above
2370
+ // kicks in then); we reset the lazy-load tree in any case.
2369
2371
  if (!props.dataSource) resetLazyTree()
2370
2372
  }
2371
2373
 
2372
- // ── Kopieren & CSV ───────────────────────────────────────────────────
2374
+ // ── Copy & CSV ───────────────────────────────────────────────────────
2373
2375
 
2374
2376
  interface ExportOptions {
2375
2377
  columnIds?: string[]
@@ -2437,13 +2439,13 @@ async function copySelectedRows(options: ExportOptions & { format?: "tsv" | "csv
2437
2439
  try {
2438
2440
  await navigator.clipboard?.writeText(text)
2439
2441
  } catch {
2440
- // Clipboard kann verweigert werden (Berechtigung, unsicherer Kontext).
2442
+ // Clipboard may be denied (permission, insecure context).
2441
2443
  }
2442
2444
  state.announcement = t("dataTable.rowsCopied", { count: rows.length })
2443
2445
  return rows.length
2444
2446
  }
2445
2447
 
2446
- // ── Zustand sichern / laden ──────────────────────────────────────────
2448
+ // ── Save / load state ────────────────────────────────────────────────
2447
2449
 
2448
2450
  function getState(): DataTableState {
2449
2451
  return {
@@ -2498,7 +2500,7 @@ function resetState(): void {
2498
2500
  }
2499
2501
 
2500
2502
  defineExpose({
2501
- // Zeilen
2503
+ // Rows
2502
2504
  getVisibleRows: (): R[] =>
2503
2505
  displayed()
2504
2506
  .filter((row) => row.kind === "data")
@@ -2506,7 +2508,7 @@ defineExpose({
2506
2508
  getProcessedRows: (): R[] => dataRowsOf(),
2507
2509
  getSelectedRows: (): R[] => resolveSelectedRows(selectedKeys.value),
2508
2510
  scrollToIndex: virtual.scrollToIndex,
2509
- // Aufklappen
2511
+ // Expanding
2510
2512
  expandRow: (key: RowKey) => {
2511
2513
  if (!expandedKeys.value.includes(key)) expandedKeys.value = [...expandedKeys.value, key]
2512
2514
  },
@@ -2519,7 +2521,7 @@ defineExpose({
2519
2521
  collapseAllRows: () => {
2520
2522
  expandedKeys.value = []
2521
2523
  },
2522
- // Spalten
2524
+ // Columns
2523
2525
  toggleColumn: (id: string, visible: boolean) => layout.toggleColumn(id, visible, true),
2524
2526
  pinColumn: (id: string, side: PinSide | false) => layout.pinColumn(id, side, true),
2525
2527
  getColumnPin: layout.getColumnPin,
@@ -2528,32 +2530,32 @@ defineExpose({
2528
2530
  sizeColumnsToFit,
2529
2531
  resetColumns,
2530
2532
  getColumnFacets: (id: string): ColumnFacets => (state.facets as Record<string, ColumnFacets>)[id] ?? { values: [] },
2531
- // Daten
2533
+ // Data
2532
2534
  reload,
2533
- /** Nachlade-Baum zurücksetzen: Pfeile zu, Kinder-Cache leer (frisch beim Aufklappen). */
2535
+ /** Reset the lazy-load tree: arrows collapsed, children cache empty (fresh on expand). */
2534
2536
  resetTree: resetLazyTree,
2535
- /** Nachgeladene Kinder einer Zeile verwerfen und erneut holen. */
2537
+ /** Discard a row's lazy-loaded children and fetch them again. */
2536
2538
  reloadChildren: async (key: RowKey): Promise<void> => {
2537
2539
  const row = findRowByKey(key)
2538
2540
  if (!row) return
2539
2541
  delete state.loadedChildren[String(key)]
2540
- await fetchChildren(row, true)
2542
+ return await fetchChildren(row, true)
2541
2543
  },
2542
- /** Nachgeladene Kinder einer Zeile (undefined = noch nicht geladen). */
2544
+ /** A row's lazy-loaded children (undefined = not loaded yet). */
2543
2545
  getLoadedChildren: (key: RowKey): R[] | undefined => state.loadedChildren[String(key)] as R[] | undefined,
2544
2546
  getDataAsCsv,
2545
2547
  exportDataAsCsv,
2546
2548
  copySelectedRows,
2547
- // Zustand
2549
+ // State
2548
2550
  getState,
2549
2551
  setState,
2550
2552
  resetState,
2551
2553
  focus: () => void nav.focusActive(),
2552
2554
  })
2553
2555
 
2554
- // ── Messen, Beobachten, Reagieren ────────────────────────────────────
2556
+ // ── Measure, observe, react ──────────────────────────────────────────
2555
2557
 
2556
- /** Kopfzellen messen — die Breiten sind die Grundlage der Sticky-Abstände. */
2558
+ /** Measure header cells — the widths are the basis for the sticky offsets. */
2557
2559
  function observeHeaderCells(): void {
2558
2560
  const scroller = scrollerEl.value
2559
2561
  if (!scroller || !headerObserver) return
@@ -2563,7 +2565,7 @@ function observeHeaderCells(): void {
2563
2565
  }
2564
2566
  }
2565
2567
 
2566
- /** Detail-Panels haben freie Höhe → für die Virtualisierung messen. */
2568
+ /** Detail panels have free height → measure them for virtualization. */
2567
2569
  function measureDetailRows(): void {
2568
2570
  const body = bodyEl.value
2569
2571
  if (!body || !detailObserver) return
@@ -2628,7 +2630,7 @@ watch(
2628
2630
  () => void nextTick(measureDetailRows)
2629
2631
  )
2630
2632
 
2631
- // Datenwechsel: Seite in den gültigen Bereich ziehen.
2633
+ // Data change: clamp the page into the valid range.
2632
2634
  watch(
2633
2635
  () => state.filteredCount,
2634
2636
  () => {
@@ -2638,7 +2640,7 @@ watch(
2638
2640
  </script>
2639
2641
 
2640
2642
  <style scoped>
2641
- /* Größen und Farben spiegeln die CSS-Variablen von <wa-data-grid>. */
2643
+ /* Sizes and colors mirror the CSS variables of <wa-data-grid>. */
2642
2644
  .vi-dt {
2643
2645
  --dt-border-color: var(--wa-color-surface-border);
2644
2646
  --dt-border-width: var(--wa-border-width-s);
@@ -2653,11 +2655,11 @@ watch(
2653
2655
  display: flex;
2654
2656
  flex-direction: column;
2655
2657
  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). */
2658
+ /* Containing block for absolutely positioned children — above all .vi-dt-live.
2659
+ Without it the live region hangs off the initial container (body) and escapes
2660
+ EVERY `overflow: hidden` of its ancestors: it then sits at the
2661
+ document position of its grid and stretches the scroll area of the whole
2662
+ page (several thousand pixels with eight grids stacked). */
2661
2663
  position: relative;
2662
2664
  color: var(--wa-color-text-normal);
2663
2665
  font-size: var(--wa-font-size-s);
@@ -2669,7 +2671,7 @@ watch(
2669
2671
  overflow: hidden;
2670
2672
  }
2671
2673
 
2672
- /* Größen: Zeilenhöhe, Polsterung und Textskala zusammen. */
2674
+ /* Sizes: row height, padding and text scale together. */
2673
2675
  .vi-dt.is-size-xs {
2674
2676
  --dt-row-height: 2rem;
2675
2677
  --dt-cell-padding: var(--wa-space-xs);
@@ -2699,15 +2701,15 @@ watch(
2699
2701
  min-inline-size: 14rem;
2700
2702
  }
2701
2703
 
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
+ /* One scroll area for header, body and footer — only this way do pinned
2705
+ columns and the header row stay aligned when scrolling in both directions. */
2704
2706
  .vi-dt-scroller {
2705
2707
  position: relative;
2706
2708
  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`. */
2709
+ /* Stretches when the table has a fixed height (e.g. in a
2710
+ filling view) — otherwise it stays content-sized. The cap still
2711
+ applies to content-sized tables; to fill, set
2712
+ `--dt-max-height: none` from outside. */
2711
2713
  flex: 1 1 auto;
2712
2714
  min-block-size: 0;
2713
2715
  max-block-size: var(--dt-max-height, 30rem);
@@ -2717,17 +2719,18 @@ watch(
2717
2719
  inset-block-start: 0;
2718
2720
  z-index: 3;
2719
2721
  }
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. */
2722
+ /* The row only provides the tint as a variable and lays out the cells —
2723
+ painting happens in the CELLS, which always cover the full width of the grid.
2724
+ The row box itself grows to the full width of its column tracks
2725
+ (`min-inline-size: max-content`). Without that it is only as wide as the
2726
+ visible area while the tracks extend beyond it — and cells pinned to the
2727
+ start, being `position: sticky` inside the row, scroll away with the row
2728
+ once the table is scrolled further than one visible width. */
2727
2729
  .vi-dt-row {
2728
2730
  --dt-row-bg: var(--wa-color-surface-default);
2729
2731
  display: grid;
2730
2732
  align-items: stretch;
2733
+ min-inline-size: max-content;
2731
2734
  min-block-size: var(--dt-row-height, 2.75rem);
2732
2735
  background-color: transparent;
2733
2736
  }
@@ -2739,14 +2742,14 @@ watch(
2739
2742
  .vi-dt.is-striped .vi-dt-row.is-odd {
2740
2743
  --dt-row-bg: var(--dt-stripe);
2741
2744
  }
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. */
2745
+ /* Precedence of the row color: selection > hover > stripe > base surface.
2746
+ The stripe rule needs `.is-striped` on the root element and thus weighs
2747
+ more than a plain `.vi-dt-row.is-data:hover` — without the following
2748
+ prefixes neither hover nor selection would be visible on odd rows. */
2746
2749
  .vi-dt .vi-dt-row.is-data:hover {
2747
2750
  --dt-row-bg: var(--dt-row-hover);
2748
2751
  }
2749
- /* Auswahl per Zeilenklick: die Zeile signalisiert Klickbarkeit. */
2752
+ /* Selection via row click: the row signals that it is clickable. */
2750
2753
  .vi-dt.is-selectable .vi-dt-row.is-data {
2751
2754
  cursor: pointer;
2752
2755
  }
@@ -2789,10 +2792,15 @@ watch(
2789
2792
  gap: var(--wa-space-2xs);
2790
2793
  min-inline-size: 0;
2791
2794
  padding-inline: var(--dt-cell-padding);
2792
- /* Farbton und Trennlinie trägt jede Zelle selbst (siehe .vi-dt-row). */
2795
+ /* Each cell carries the tint and divider itself (see .vi-dt-row). */
2793
2796
  background-color: var(--dt-row-bg);
2794
2797
  border-block-end: var(--dt-border-width) solid var(--dt-border-color);
2795
2798
  overflow: hidden;
2799
+ /* Every row is its own grid, sized by `max-content` (see .vi-dt-row). If
2800
+ cell content counted towards that size, a row with a long text would
2801
+ size its flex columns differently from the others and the columns would
2802
+ no longer line up. Content still overflows and ellipsizes as before. */
2803
+ contain: inline-size;
2796
2804
  }
2797
2805
  .vi-dt-cell.is-lead {
2798
2806
  padding-inline: var(--wa-space-2xs);
@@ -2808,12 +2816,12 @@ watch(
2808
2816
  outline: var(--wa-focus-ring);
2809
2817
  outline-offset: -2px;
2810
2818
  }
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. */
2819
+ /* Pinned cells lie ABOVE the horizontally scrolling content and must hide it
2820
+ opaquely. The row tints (stripe, hover, selection) are
2821
+ semi-transparent (color-mix with transparent), so a plain
2822
+ `background-color: var(--dt-row-bg)` would let the content show through.
2823
+ Hence: an opaque base surface as the color, the row tint as an overlay — the
2824
+ result is the same color value as for the freely scrolling cells. */
2817
2825
  .vi-dt-cell.is-sticky {
2818
2826
  background-color: var(--wa-color-surface-default);
2819
2827
  background-image: linear-gradient(var(--dt-row-bg), var(--dt-row-bg));
@@ -2838,8 +2846,8 @@ watch(
2838
2846
  .vi-dt-hcell {
2839
2847
  padding-inline: var(--wa-space-s);
2840
2848
  }
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. */
2849
+ /* The label fills the free space — otherwise the filter and menu buttons
2850
+ slide into the middle of the cell and intercept the sort click. */
2843
2851
  .vi-dt-hcell .vi-dt-hlabel {
2844
2852
  flex: 1 1 auto;
2845
2853
  min-inline-size: 0;
@@ -2851,16 +2859,16 @@ watch(
2851
2859
  .vi-dt-hcell.is-align-center .vi-dt-hlabel {
2852
2860
  text-align: center;
2853
2861
  }
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. */
2862
+ /* Only show the column menu when the column is focused/hovered — otherwise it eats
2863
+ the label. (wa-dropdown itself is display:contents, so style the trigger.)
2864
+ Active filters always stay visible. */
2865
+ /* visibility instead of display: the button is invisible and NOT clickable
2866
+ (so it doesn't intercept the sort click in the middle of the cell), but keeps its
2867
+ place in the layout. Both matter:
2868
+ - With display:none the button leaves the layout. If its menu is open, the
2869
+ popup suddenly has no anchor anymore and jumps to the top left corner.
2870
+ - Also, the header row would reflow on every hover change and shift open
2871
+ filter popovers by the button width. */
2864
2872
  .vi-dt-column-menu-button {
2865
2873
  visibility: hidden;
2866
2874
  }
@@ -2925,24 +2933,24 @@ watch(
2925
2933
  position: relative;
2926
2934
  }
2927
2935
 
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. */
2936
+ /* ── Making the tree structure visible ─────────────────────────────── */
2937
+ /* Indentation alone reads poorly. Hence one vertical guide line per level
2938
+ (as a background pattern over the indented area) and a
2939
+ short corner to the row itself — like in a file tree. */
2932
2940
  .vi-dt {
2933
2941
  --dt-guide: var(--wa-color-surface-border);
2934
2942
  }
2935
2943
  .vi-dt-cell.is-tree-cell {
2936
2944
  position: relative;
2937
2945
  }
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
+ /* One slot per level. The vertical line sits in the MIDDLE of the slot so
2947
+ it runs through the expand arrows of the respective level.
2948
+ is-line → continuous line (ancestor still has siblings)
2949
+ is-tee → "├" (this row still has siblings)
2950
+ is-corner → "└" (last child: empty below)
2951
+ none → empty slot (the subtree ends there)
2952
+ The arm ends at the edge of the slot — it does NOT run past the arrow or the
2953
+ leaf placeholder up to the text. */
2946
2954
  .vi-dt-guide {
2947
2955
  position: relative;
2948
2956
  flex: 0 0 auto;
@@ -2959,11 +2967,11 @@ watch(
2959
2967
  inline-size: 1px;
2960
2968
  background-color: var(--dt-guide);
2961
2969
  }
2962
- /* Ecke: die senkrechte Linie endet auf halber Höhe. */
2970
+ /* Corner: the vertical line ends at half height. */
2963
2971
  .vi-dt-guide.is-corner::before {
2964
2972
  inset-block: 0 50%;
2965
2973
  }
2966
- /* Kurzer Arm von der Mitte zum Rand des Feldes. */
2974
+ /* Short arm from the middle to the edge of the slot. */
2967
2975
  .vi-dt-guide.is-tee::after,
2968
2976
  .vi-dt-guide.is-corner::after {
2969
2977
  content: "";
@@ -2974,15 +2982,15 @@ watch(
2974
2982
  background-color: var(--dt-guide);
2975
2983
  }
2976
2984
 
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). */
2985
+ /* Arrow and leaf placeholder MUST be equally wide — otherwise the labels of a
2986
+ level don't line up and the indentation gets
2987
+ partially eaten up (the wa-button is wider by itself). */
2980
2988
  .vi-dt-expand {
2981
2989
  flex: 0 0 auto;
2982
2990
  inline-size: var(--dt-toggle-size);
2983
2991
  }
2984
- /* Nachgeladen und leer: der Pfeil bleibt an seinem Platz (kein Layoutsprung),
2985
- ist aber sichtbar außer Betrieb. */
2992
+ /* Loaded and empty: the arrow stays in place (no layout jump),
2993
+ but is visibly out of service. */
2986
2994
  .vi-dt-expand.is-empty {
2987
2995
  opacity: 0.3;
2988
2996
  cursor: default;
@@ -2991,13 +2999,13 @@ watch(
2991
2999
  font-size: 0.75em;
2992
3000
  --track-width: 2px;
2993
3001
  }
2994
- /* Blätter: gleicher Abstand wie zwischen Pfeil und Führungslinien. */
3002
+ /* Leaves: same spacing as between arrow and guide lines. */
2995
3003
  .vi-dt-leaf-gap {
2996
3004
  flex: 0 0 auto;
2997
3005
  inline-size: var(--wa-space-2xs);
2998
3006
  }
2999
3007
 
3000
- /* ── Feste Reihenfolge: Griff und Einfügemarke ─────────────────────── */
3008
+ /* ── Fixed order: handle and insertion marker ──────────────────────── */
3001
3009
  .vi-dt-grip {
3002
3010
  display: inline-flex;
3003
3011
  align-items: center;
@@ -3018,16 +3026,16 @@ watch(
3018
3026
  .vi-dt-row.is-dragging {
3019
3027
  opacity: 0.5;
3020
3028
  }
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. */
3029
+ /* Marker where the row would land. As an inset shadow so it costs no
3030
+ layout height and rows don't jump while dragging. */
3023
3031
  .vi-dt-row.is-drop-before .vi-dt-cell {
3024
3032
  box-shadow: inset 0 2px 0 var(--dt-accent);
3025
3033
  }
3026
3034
  .vi-dt-row.is-drop-after .vi-dt-cell {
3027
3035
  box-shadow: inset 0 -2px 0 var(--dt-accent);
3028
3036
  }
3029
- /* „Hinein": die Zeile wird Elternteil — deshalb ein Rahmen um die ganze Zeile
3030
- statt einer Linie an der Kante. */
3037
+ /* "Inside": the row becomes the parent — hence a frame around the whole row
3038
+ instead of a line at the edge. */
3031
3039
  .vi-dt-row.is-drop-inside .vi-dt-cell {
3032
3040
  box-shadow:
3033
3041
  inset 0 2px 0 var(--dt-accent),
@@ -3037,8 +3045,8 @@ watch(
3037
3045
  --dt-row-bg: color-mix(in oklab, var(--dt-selected) 60%, transparent);
3038
3046
  }
3039
3047
 
3040
- /* Die Aufklapp-Spalte liegt links der Baumzelle — ihre Linie führt weiter,
3041
- damit Pfeil und Struktur zusammenhängen. */
3048
+ /* The expand column sits left of the tree cell — its line continues
3049
+ so arrow and structure connect. */
3042
3050
  .vi-dt-cell.is-lead.is-expand-cell {
3043
3051
  position: relative;
3044
3052
  }
@@ -3141,8 +3149,8 @@ watch(
3141
3149
  font-size: var(--wa-font-size-s);
3142
3150
  cursor: pointer;
3143
3151
  }
3144
- /* Nur für Screenreader: unsichtbar, aber vorhanden. Bezugsrahmen ist .vi-dt
3145
- (position: relative), damit die Region im Gitter geclippt bleibt. */
3152
+ /* Screen readers only: invisible but present. The containing block is .vi-dt
3153
+ (position: relative) so the region stays clipped within the grid. */
3146
3154
  .vi-dt-live {
3147
3155
  position: absolute;
3148
3156
  inset-block-start: 0;