viur-ui 0.0.1 → 0.0.2

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 (83) 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 +3 -5
  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 +346 -346
  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 +15 -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 +22 -22
  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
@@ -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"
@@ -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,14 +1771,14 @@ 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) {
@@ -1801,7 +1801,7 @@ function toggleExpand(displayRow: DisplayRow<R>): void {
1801
1801
  }
1802
1802
  }
1803
1803
 
1804
- /** Zeile aufklappen und melden. */
1804
+ /** Expand a row and emit the event. */
1805
1805
  function expand(key: RowKey, row?: R): void {
1806
1806
  if (expandedKeys.value.includes(key)) return
1807
1807
  expandedKeys.value = [...expandedKeys.value, key]
@@ -1816,19 +1816,19 @@ function allExpandableKeys(nodes: RowNode<R>[] = state.built.nodes as RowNode<R>
1816
1816
  return into
1817
1817
  }
1818
1818
 
1819
- // ── Zellklicks ───────────────────────────────────────────────────────
1819
+ // ── Cell clicks ──────────────────────────────────────────────────────
1820
1820
 
1821
1821
  /**
1822
- * Doppelklick auf einer Datenzeile. Bedienelemente in der Zeile (Auswahl,
1823
- * Aufklappen, Aktionen) lösen ihn nicht aus — dort doppelt zu klicken meint
1824
- * nicht „Datensatz öffnen".
1822
+ * Double click on a data row. Controls in the row (selection,
1823
+ * expand, actions) don't trigger it — double-clicking there doesn't mean
1824
+ * "open record".
1825
1825
  */
1826
1826
  function onRowDoubleClick(displayRow: DisplayRow<R>, event: MouseEvent): void {
1827
1827
  if (displayRow.kind !== "data" || !displayRow.row) return
1828
1828
  const target = event.target as HTMLElement
1829
1829
  if (target.closest("wa-checkbox, wa-button, a, input, select, .vi-dt-expand")) return
1830
- // Ein Doppelklick löst zwei Klicks aus, die die Auswahl per Klick wieder
1831
- // aufheben würden — die Zeile bleibt/wird ausgewählt.
1830
+ // A double click fires two clicks, which would undo the click selection
1831
+ // again — the row stays/becomes selected.
1832
1832
  if (props.selectable !== false && props.selectOnRowClick && canSelect(displayRow.row)) {
1833
1833
  const key = displayRow.nodeKey
1834
1834
  if (!state.selectedSet.has(key)) {
@@ -1852,8 +1852,8 @@ function onCellClick(displayRow: DisplayRow<R>, cell: CellColumn, event: MouseEv
1852
1852
  if (cell.kind !== "data" || !displayRow.row) return
1853
1853
  const target = event.target as HTMLElement
1854
1854
  if (target.closest(".vi-dt-expand, .vi-dt-checkbox")) return
1855
- // Zeile per Klick auswählen — außer der Klick trifft ein interaktives Element
1856
- // in der Zelle (Link, Button, Eingabefeld), das sich selbst um den Klick kümmert.
1855
+ // Select the row on click — unless the click hits an interactive element
1856
+ // in the cell (link, button, input field) that handles the click itself.
1857
1857
  if (
1858
1858
  props.selectable !== false &&
1859
1859
  props.selectOnRowClick &&
@@ -1881,14 +1881,14 @@ function onCellContextMenu(displayRow: DisplayRow<R>, cell: CellColumn, event: M
1881
1881
  })
1882
1882
  }
1883
1883
 
1884
- // ── Feste Reihenfolge: Zeilen umsortieren ────────────────────────────
1884
+ // ── Fixed order: reordering rows ─────────────────────────────────────
1885
1885
 
1886
1886
  /**
1887
- * Zeilen werden mit ZEIGER-Ereignissen gezogen, nicht mit HTML5-Drag-and-Drop.
1888
- * Grund: `dragover` verlangt ein akzeptierendes Ziel und feuert je nach
1889
- * Umgebung unzuverlässig (bei Touch gar nicht) — die Einfügemarke bliebe dann
1890
- * aus und die Zielposition wäre Zufall. Pointer-Events verhalten sich überall
1891
- * gleich, funktionieren mit Touch und sind testbar.
1887
+ * Rows are dragged with POINTER events, not with HTML5 drag and drop.
1888
+ * Reason: `dragover` requires an accepting target and fires unreliably
1889
+ * depending on the environment (not at all on touch) — the insertion marker would then
1890
+ * be missing and the target position would be random. Pointer events behave the same
1891
+ * everywhere, work with touch and are testable.
1892
1892
  */
1893
1893
  function beginRowDrag(displayRow: DisplayRow<R>, event: PointerEvent): void {
1894
1894
  if (!state.orderActive || displayRow.kind !== "data") return
@@ -1916,15 +1916,15 @@ function beginRowDrag(displayRow: DisplayRow<R>, event: PointerEvent): void {
1916
1916
  }
1917
1917
 
1918
1918
  /**
1919
- * Zeile unter dem Zeiger bestimmen und die Zone ableiten. Wie im admin4 gibt es
1920
- * drei: oberes Viertel = davor, unteres Viertel = dahinter, die Mitte = HINEIN
1921
- * (die Zeile wird Kind des Ziels). Ohne Baum bleibt nur davor/dahinter.
1919
+ * Determine the row under the pointer and derive the zone. As in admin4 there are
1920
+ * three: top quarter = before, bottom quarter = after, the middle = INSIDE
1921
+ * (the row becomes a child of the target). Without a tree only before/after remain.
1922
1922
  */
1923
1923
  function updateRowDrag(clientX: number, clientY: number): void {
1924
1924
  const drag = state.rowDrag
1925
1925
  if (!drag) return
1926
- // Erst die umgebende Session fragen: liefert sie ein Ziel (Baumknoten oder
1927
- // Ordnerzeile), übernimmt sie Anzeige und Drop — der interne Indikator ruht.
1926
+ // Ask the enclosing session first: if it provides a target (tree node or
1927
+ // folder row), it takes over display and drop — the internal indicator rests.
1928
1928
  if (dragSession?.state.active) {
1929
1929
  const external = dragSession.update(clientX, clientY)
1930
1930
  if (external) {
@@ -1937,7 +1937,7 @@ function updateRowDrag(clientX: number, clientY: number): void {
1937
1937
  const target = displayed().find((row) => row.key === element.dataset.key)
1938
1938
  if (!target || target.nodeKey === drag.key) return
1939
1939
 
1940
- // In den eigenen Teilbaum ziehen ist nicht möglich — er hinge an sich selbst.
1940
+ // Dragging into its own subtree is impossible — it would hang from itself.
1941
1941
  if (isDescendant(state.built.nodes as RowNode<R>[], drag.key, target.nodeKey)) {
1942
1942
  drag.overKey = null
1943
1943
  return
@@ -1952,7 +1952,7 @@ function updateRowDrag(clientX: number, clientY: number): void {
1952
1952
  else drag.mode = "inside"
1953
1953
  }
1954
1954
 
1955
- /** Nachlade-Modus: Zeile aus ihrem alten Bucket nehmen und am Ziel einsetzen. */
1955
+ /** Lazy-load mode: take the row out of its old bucket and insert it at the target. */
1956
1956
  function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, insertAt: number, order: number): void {
1957
1957
  const row = source.node.row
1958
1958
  if (!row) return
@@ -1964,9 +1964,9 @@ function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, ins
1964
1964
  const hidden = new Set(lazyMoves.value.hidden)
1965
1965
  let injected = lazyMoves.value.injected.filter((candidate) => (candidate[props.rowKey!] as RowKey) !== key)
1966
1966
 
1967
- // Aus der Quelle entfernen …
1967
+ // Remove from the source …
1968
1968
  if (source.parentKey === null) {
1969
- hidden.add(key) // oberste Ebene: props.data bleibt unangetastet, nur ausblenden
1969
+ hidden.add(key) // top level: props.data stays untouched, just hide
1970
1970
  } else {
1971
1971
  const bucket = state.loadedChildren[String(source.parentKey)] as R[] | undefined
1972
1972
  if (bucket) {
@@ -1976,10 +1976,10 @@ function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, ins
1976
1976
  }
1977
1977
  }
1978
1978
 
1979
- // … und am Ziel einsetzen.
1979
+ // … and insert at the target.
1980
1980
  if (targetParent === null) {
1981
- // Original (falls es in props.data steht) bleibt ausgeblendet; sichtbar
1982
- // ist die Kopie mit neuem Ordnungswert.
1981
+ // The original (if it is in props.data) stays hidden; the copy with the
1982
+ // new order value is visible.
1983
1983
  hidden.add(key)
1984
1984
  injected = [...injected, moved]
1985
1985
  } else {
@@ -1989,20 +1989,20 @@ function applyLazyMove(source: NodeLocation<R>, targetParent: RowKey | null, ins
1989
1989
  next.splice(Math.max(0, Math.min(insertAt, next.length)), 0, moved)
1990
1990
  state.loadedChildren[String(targetParent)] = next as (typeof state.loadedChildren)[string]
1991
1991
  }
1992
- // Bucket noch nie geladen: beim Aufklappen holt fetchChildren den Stand
1993
- // vom Server — der enthält die Zeile nach dem Speichern ohnehin.
1992
+ // Bucket never loaded: on expand fetchChildren gets the current state
1993
+ // from the server — which contains the row after saving anyway.
1994
1994
  }
1995
1995
 
1996
1996
  lazyMoves.value = { hidden, injected }
1997
1997
  }
1998
1998
 
1999
1999
  /**
2000
- * Nachlade-Modus zurücksetzen: Kinder-Cache verwerfen und alle Zweige
2001
- * zuklappen — die Pfeile stehen danach wieder auf „zu", und das nächste
2002
- * Aufklappen holt den frischen Stand vom Server. Läuft bei `reload()` und
2003
- * wenn der Aufrufer neue Daten liefert; ohne `loadChildren` (parentField-
2004
- * Bäume) bleibt die Aufklapp-Situation erhalten, dort sind die Kinder ohnehin
2005
- * immer aus den aktuellen Daten abgeleitet.
2000
+ * Reset lazy-load mode: discard the children cache and collapse all
2001
+ * branches — the arrows are back to "collapsed" afterwards, and the next
2002
+ * expand fetches the fresh state from the server. Runs on `reload()` and
2003
+ * when the caller delivers new data; without `loadChildren` (parentField
2004
+ * trees) the expand state is kept, since there the children are always
2005
+ * derived from the current data anyway.
2006
2006
  */
2007
2007
  function resetLazyTree(): void {
2008
2008
  if (!props.loadChildren) return
@@ -2011,15 +2011,15 @@ function resetLazyTree(): void {
2011
2011
  if (expandedKeys.value.length) expandedKeys.value = []
2012
2012
  }
2013
2013
 
2014
- // Drag & Drop ist rein CLIENTSEITIG: die Zeile wird lokal verschoben (Buckets/
2015
- // Überlagerungen oben), `row-reorder` meldet den Zug zum Speichern im
2016
- // Hintergrund — die Tabelle lädt danach NICHTS neu, und die App muss es auch
2017
- // nicht. Kommen von außen neue Daten herein, sind sie maßgeblich: die
2018
- // optimistischen Überlagerungen werden verworfen (frische Daten enthalten
2019
- // einen gespeicherten Zug bereits). Der Nachlade-Baum bleibt dabei stehen —
2020
- // zugeklappt und geleert wird er nur über `reload()`/`resetTree()` oder, per
2021
- // Opt-in `resetTreeOnDataChange`, bei jedem Datenwechsel ohne vorangegangene
2022
- // Verschiebung.
2014
+ // Drag & drop is purely CLIENT-SIDE: the row is moved locally (buckets/
2015
+ // overrides above), `row-reorder` reports the move for saving in the
2016
+ // background — the table reloads NOTHING afterwards, and the app doesn't
2017
+ // have to either. When new data comes in from outside, it is authoritative: the
2018
+ // optimistic overrides are discarded (fresh data already contains
2019
+ // a saved move). The lazy-load tree stays as it is —
2020
+ // it is only collapsed and cleared via `reload()`/`resetTree()` or, via the
2021
+ // opt-in `resetTreeOnDataChange`, on every data change not preceded by a
2022
+ // move.
2023
2023
  watch(
2024
2024
  () => (props.dataSource ? (state.serverRows as R[]) : props.data),
2025
2025
  () => {
@@ -2036,8 +2036,8 @@ watch(
2036
2036
  function commitRowDrag(): void {
2037
2037
  const drag = state.rowDrag
2038
2038
  state.rowDrag = null
2039
- // Ein Session-Ziel (Baum/Ordner außerhalb der internen Logik) hat Vorrang:
2040
- // der Browser übernimmt den Zug, hier passiert dann nichts mehr.
2039
+ // A session target (tree/folder outside the internal logic) takes precedence:
2040
+ // the Browser takes over the move, nothing else happens here.
2041
2041
  if (dragSession?.state.active) {
2042
2042
  if (dragSession.state.over) {
2043
2043
  dragSession.drop()
@@ -2051,11 +2051,11 @@ function commitRowDrag(): void {
2051
2051
  }
2052
2052
 
2053
2053
  /**
2054
- * Zeile an eine neue Stelle setzen und den neuen Ordnungswert melden.
2054
+ * Move a row to a new position and report the new order value.
2055
2055
  *
2056
- * Gerechnet wird immer im Kreis der künftigen GESCHWISTER — bei „hinein" also
2057
- * unter den Kindern des Ziels, sonst unter den Geschwistern des Ziels. Nur so
2058
- * ergibt der Wert im Baum Sinn: `sortindex` gilt je Elternteil.
2056
+ * The computation is always among the future SIBLINGS — for "inside" that is
2057
+ * among the target's children, otherwise among the target's siblings. Only then
2058
+ * does the value make sense in the tree: `sortindex` applies per parent.
2059
2059
  */
2060
2060
  function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2061
2061
  if (!props.orderField || key === targetKey) return
@@ -2065,9 +2065,9 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2065
2065
  const target = locateNode(nodes, targetKey)
2066
2066
  if (!target) return
2067
2067
 
2068
- // Mehrfachauswahl: gehört die gezogene Zeile zur Auswahl, wandert die ganze
2069
- // Auswahl mit — in Anzeige-Reihenfolge. Zeilen, die nicht ans Ziel dürfen
2070
- // (das Ziel selbst, Vorfahren des Ziels), bleiben außen vor.
2068
+ // Multi-selection: if the dragged row is part of the selection, the whole
2069
+ // selection moves along — in display order. Rows that may not go to the target
2070
+ // (the target itself, ancestors of the target) are left out.
2071
2071
  const moving = movingKeysFor(key).filter(
2072
2072
  (candidate) => candidate !== targetKey && !isDescendant(nodes, candidate, targetKey)
2073
2073
  )
@@ -2081,7 +2081,7 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2081
2081
  let values: number[]
2082
2082
 
2083
2083
  if (mode === "inside") {
2084
- // Neue Kinder: hinter die vorhandenen Kinder des Ziels.
2084
+ // New children: after the target's existing children.
2085
2085
  parentKey = targetKey
2086
2086
  const children = target.node.children.filter((child) => !movingSet.has(child.key))
2087
2087
  values = children.map(orderOf)
@@ -2103,12 +2103,12 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2103
2103
  if (!source?.node.row) continue
2104
2104
 
2105
2105
  const { order, needsReindex } = orderValueFor(values, insertAt, props.orderStep)
2106
- // Den vergebenen Wert in die Nachbarliste übernehmen, damit die nächste
2107
- // Zeile der Auswahl DAHINTER einsortiert wird (Reihenfolge bleibt erhalten).
2106
+ // Add the assigned value to the neighbour list so the next
2107
+ // row of the selection is sorted in AFTER it (order is preserved).
2108
2108
  values.splice(Math.max(0, Math.min(values.length, insertAt)), 0, order)
2109
2109
 
2110
- // Optimistisch übernehmen: die Zeile verlässt ihre alte Stelle sofort und
2111
- // erscheint am Ziel — beim Drop IN einen Ordner also unter dessen Kindern.
2110
+ // Apply optimistically: the row leaves its old position immediately and
2111
+ // appears at the target — on a drop INTO a folder, among its children.
2112
2112
  if (props.rowKey) {
2113
2113
  if (props.loadChildren) {
2114
2114
  applyLazyMove(source, parentKey, insertAt, order)
@@ -2146,10 +2146,10 @@ function moveRowTo(key: RowKey, targetKey: RowKey, mode: DropMode): void {
2146
2146
  }
2147
2147
 
2148
2148
  /**
2149
- * Welche Zeilen ein Zug bewegt: nur die gezogene — oder, wenn sie Teil der
2150
- * Auswahl ist, alle ausgewählten Zeilen in Anzeige-Reihenfolge. Ausgewählte
2151
- * Nachfahren einer ebenfalls ausgewählten Zeile wandern implizit mit ihrem
2152
- * Teilbaum und werden nicht doppelt bewegt.
2149
+ * Which rows a move affects: only the dragged one — or, if it is part of the
2150
+ * selection, all selected rows in display order. Selected
2151
+ * descendants of an also-selected row move implicitly with their
2152
+ * subtree and are not moved twice.
2153
2153
  */
2154
2154
  function movingKeysFor(key: RowKey): RowKey[] {
2155
2155
  if (props.selectable === false || !state.selectedSet.has(key) || state.selectedSet.size < 2) return [key]
@@ -2159,8 +2159,8 @@ function movingKeysFor(key: RowKey): RowKey[] {
2159
2159
  for (const row of state.dataDisplayRows as DisplayRow<R>[]) {
2160
2160
  if (selected.has(row.nodeKey)) ordered.push(row.nodeKey)
2161
2161
  }
2162
- // dataDisplayRows enthält nur SICHTBARE Zeilen — ausgewählte Zeilen in
2163
- // zugeklappten Zweigen hängen wir hinten an, damit sie nicht liegenbleiben.
2162
+ // dataDisplayRows only contains VISIBLE rows — selected rows in
2163
+ // collapsed branches are appended so they aren't left behind.
2164
2164
  const seen = new Set(ordered)
2165
2165
  for (const candidate of selected) {
2166
2166
  if (!seen.has(candidate)) ordered.push(candidate)
@@ -2172,15 +2172,15 @@ function movingKeysFor(key: RowKey): RowKey[] {
2172
2172
  return result.length ? result : [key]
2173
2173
  }
2174
2174
 
2175
- /** Zeilenschlüssel wie in der Pipeline (rowKey oder Indexpfad). */
2175
+ /** Row key as in the pipeline (rowKey or index path). */
2176
2176
  function keyOfRow(row: R, index: number): RowKey {
2177
2177
  return props.rowKey ? (row[props.rowKey] as RowKey) : String(index)
2178
2178
  }
2179
2179
 
2180
2180
  /**
2181
- * Tastatur: Zeile um eine Position verschieben (Alt + ↑/↓) — innerhalb ihrer
2182
- * GESCHWISTER. Ein Elternwechsel bleibt der Maus vorbehalten; per Tastatur
2183
- * wäre nicht erkennbar, in welche Ebene man zielt.
2181
+ * Keyboard: move a row by one position (Alt + ↑/↓) — within its
2182
+ * SIBLINGS. Changing the parent is reserved for the mouse; via keyboard
2183
+ * it wouldn't be clear which level is being targeted.
2184
2184
  */
2185
2185
  function moveRowByKeyboard(rowIndex: number, delta: number): void {
2186
2186
  if (!state.orderActive) return
@@ -2194,7 +2194,7 @@ function moveRowByKeyboard(rowIndex: number, delta: number): void {
2194
2194
  nav.setActive(rowIndex + delta, nav.state.col)
2195
2195
  }
2196
2196
 
2197
- // ── Spalten: Resize, Reorder, Pin ────────────────────────────────────
2197
+ // ── Columns: resize, reorder, pin ────────────────────────────────────
2198
2198
 
2199
2199
  function onResizeStart(columnId: string, event: PointerEvent): void {
2200
2200
  layout.startResize(columnId, event.clientX)
@@ -2225,13 +2225,13 @@ function pinColumn(columnId: string, side: PinSide | false): void {
2225
2225
  }
2226
2226
 
2227
2227
  /**
2228
- * Spalte auf die Breite ihres breitesten Inhalts bringen.
2228
+ * Size a column to the width of its widest content.
2229
2229
  *
2230
- * Gemessen wird der TEXT, nicht die Box: der Inhalt sitzt in einem
2231
- * Block-Element, das die Zelle ausfüllt — dessen scrollWidth wäre also immer
2232
- * die aktuelle Spaltenbreite, und Autosize könnte nie verkleinern. Ein Range
2233
- * über die Knoteninhalte liefert die echte Textbreite; für Komponenten-Inhalte
2234
- * (Badges, Tags) fällt die Messung auf scrollWidth zurück.
2230
+ * The TEXT is measured, not the box: the content sits in a
2231
+ * block element that fills the cell — its scrollWidth would therefore always be
2232
+ * the current column width, and autosize could never shrink. A Range
2233
+ * over the node contents yields the real text width; for component content
2234
+ * (badges, tags) the measurement falls back to scrollWidth.
2235
2235
  */
2236
2236
  function autoSizeColumn(columnId: string): void {
2237
2237
  const cells = scrollerEl.value?.querySelectorAll<HTMLElement>(`[data-column="${columnId}"]`)
@@ -2247,7 +2247,7 @@ function autoSizeColumn(columnId: string): void {
2247
2247
  }
2248
2248
  widest = Math.max(widest, Math.max(measured, content.scrollWidth === content.clientWidth ? 0 : content.scrollWidth))
2249
2249
  }
2250
- const width = Math.ceil(widest) + 40 // Polsterung + Werkzeuge im Kopf
2250
+ const width = Math.ceil(widest) + 40 // padding + header tools
2251
2251
  layout.setWidth(columnId, width)
2252
2252
  emit("column-resize", { column: columnId, width, finished: true })
2253
2253
  }
@@ -2272,7 +2272,7 @@ function resetColumns(): void {
2272
2272
  layout.resetColumns()
2273
2273
  }
2274
2274
 
2275
- // ── Paginierung ──────────────────────────────────────────────────────
2275
+ // ── Pagination ───────────────────────────────────────────────────────
2276
2276
 
2277
2277
  function setPage(next: number, notify = true): void {
2278
2278
  const clamped = Math.max(0, Math.min(state.pageCount - 1, next))
@@ -2283,7 +2283,7 @@ function setPage(next: number, notify = true): void {
2283
2283
  if (state.isServer) scheduleFetch(false, { page: clamped })
2284
2284
  }
2285
2285
 
2286
- /** Cursor-Leiste: neue Seitengröße — der Aufrufer lädt damit neu (page-change). */
2286
+ /** Cursor bar: new page size — the caller reloads with it (page-change). */
2287
2287
  function onCursorPageSize(next: number): void {
2288
2288
  pageSize.value = next
2289
2289
  emit("page-change", { page: 0, pageSize: next })
@@ -2296,16 +2296,16 @@ function setPageSize(next: number): void {
2296
2296
  if (state.isServer) scheduleFetch(false, { page: 0, pageSize: next })
2297
2297
  }
2298
2298
 
2299
- // ── Server-Modus ─────────────────────────────────────────────────────
2299
+ // ── Server mode ──────────────────────────────────────────────────────
2300
2300
 
2301
2301
  /**
2302
- * Was der nächste Server-Abruf gegenüber den Modellen abweichend verwenden soll.
2302
+ * What the next server fetch should use instead of the model values.
2303
2303
  *
2304
- * Nötig, weil Vue bei GEBUNDENEM `v-model` den lokalen Wert eines
2305
- * `defineModel` nicht sofort setzt: die Aktualisierung kommt erst über den Prop
2306
- * der Eltern. Ein Lesen direkt nach dem Schreiben liefert also den alten Wert —
2307
- * unbemerkt, solange niemand `v-model:sort` & Co. bindet, und beim ersten
2308
- * Sortierklick sichtbar falsch, sobald doch.
2304
+ * Needed because with a BOUND `v-model`, Vue doesn't set the local value of a
2305
+ * `defineModel` immediately: the update only arrives via the parent's
2306
+ * prop. Reading right after writing therefore yields the old value —
2307
+ * unnoticed as long as nobody binds `v-model:sort` & co., and visibly wrong
2308
+ * on the first sort click as soon as someone does.
2309
2309
  */
2310
2310
  type RequestOverrides = Partial<Pick<DataTableRequest, "sort" | "filters" | "search" | "page" | "pageSize">>
2311
2311
  let pendingRequest: RequestOverrides = {}
@@ -2364,12 +2364,12 @@ async function fetchRows(): Promise<void> {
2364
2364
 
2365
2365
  function reload(): void {
2366
2366
  scheduleFetch(false)
2367
- // Ohne dataSource ersetzt der Aufrufer props.data selbst (der Watch oben
2368
- // greift dann); den Nachlade-Baum setzen wir in jedem Fall zurück.
2367
+ // Without dataSource the caller replaces props.data itself (the watch above
2368
+ // kicks in then); we reset the lazy-load tree in any case.
2369
2369
  if (!props.dataSource) resetLazyTree()
2370
2370
  }
2371
2371
 
2372
- // ── Kopieren & CSV ───────────────────────────────────────────────────
2372
+ // ── Copy & CSV ───────────────────────────────────────────────────────
2373
2373
 
2374
2374
  interface ExportOptions {
2375
2375
  columnIds?: string[]
@@ -2437,13 +2437,13 @@ async function copySelectedRows(options: ExportOptions & { format?: "tsv" | "csv
2437
2437
  try {
2438
2438
  await navigator.clipboard?.writeText(text)
2439
2439
  } catch {
2440
- // Clipboard kann verweigert werden (Berechtigung, unsicherer Kontext).
2440
+ // Clipboard may be denied (permission, insecure context).
2441
2441
  }
2442
2442
  state.announcement = t("dataTable.rowsCopied", { count: rows.length })
2443
2443
  return rows.length
2444
2444
  }
2445
2445
 
2446
- // ── Zustand sichern / laden ──────────────────────────────────────────
2446
+ // ── Save / load state ────────────────────────────────────────────────
2447
2447
 
2448
2448
  function getState(): DataTableState {
2449
2449
  return {
@@ -2498,7 +2498,7 @@ function resetState(): void {
2498
2498
  }
2499
2499
 
2500
2500
  defineExpose({
2501
- // Zeilen
2501
+ // Rows
2502
2502
  getVisibleRows: (): R[] =>
2503
2503
  displayed()
2504
2504
  .filter((row) => row.kind === "data")
@@ -2506,7 +2506,7 @@ defineExpose({
2506
2506
  getProcessedRows: (): R[] => dataRowsOf(),
2507
2507
  getSelectedRows: (): R[] => resolveSelectedRows(selectedKeys.value),
2508
2508
  scrollToIndex: virtual.scrollToIndex,
2509
- // Aufklappen
2509
+ // Expanding
2510
2510
  expandRow: (key: RowKey) => {
2511
2511
  if (!expandedKeys.value.includes(key)) expandedKeys.value = [...expandedKeys.value, key]
2512
2512
  },
@@ -2519,7 +2519,7 @@ defineExpose({
2519
2519
  collapseAllRows: () => {
2520
2520
  expandedKeys.value = []
2521
2521
  },
2522
- // Spalten
2522
+ // Columns
2523
2523
  toggleColumn: (id: string, visible: boolean) => layout.toggleColumn(id, visible, true),
2524
2524
  pinColumn: (id: string, side: PinSide | false) => layout.pinColumn(id, side, true),
2525
2525
  getColumnPin: layout.getColumnPin,
@@ -2528,32 +2528,32 @@ defineExpose({
2528
2528
  sizeColumnsToFit,
2529
2529
  resetColumns,
2530
2530
  getColumnFacets: (id: string): ColumnFacets => (state.facets as Record<string, ColumnFacets>)[id] ?? { values: [] },
2531
- // Daten
2531
+ // Data
2532
2532
  reload,
2533
- /** Nachlade-Baum zurücksetzen: Pfeile zu, Kinder-Cache leer (frisch beim Aufklappen). */
2533
+ /** Reset the lazy-load tree: arrows collapsed, children cache empty (fresh on expand). */
2534
2534
  resetTree: resetLazyTree,
2535
- /** Nachgeladene Kinder einer Zeile verwerfen und erneut holen. */
2535
+ /** Discard a row's lazy-loaded children and fetch them again. */
2536
2536
  reloadChildren: async (key: RowKey): Promise<void> => {
2537
2537
  const row = findRowByKey(key)
2538
2538
  if (!row) return
2539
2539
  delete state.loadedChildren[String(key)]
2540
2540
  await fetchChildren(row, true)
2541
2541
  },
2542
- /** Nachgeladene Kinder einer Zeile (undefined = noch nicht geladen). */
2542
+ /** A row's lazy-loaded children (undefined = not loaded yet). */
2543
2543
  getLoadedChildren: (key: RowKey): R[] | undefined => state.loadedChildren[String(key)] as R[] | undefined,
2544
2544
  getDataAsCsv,
2545
2545
  exportDataAsCsv,
2546
2546
  copySelectedRows,
2547
- // Zustand
2547
+ // State
2548
2548
  getState,
2549
2549
  setState,
2550
2550
  resetState,
2551
2551
  focus: () => void nav.focusActive(),
2552
2552
  })
2553
2553
 
2554
- // ── Messen, Beobachten, Reagieren ────────────────────────────────────
2554
+ // ── Measure, observe, react ──────────────────────────────────────────
2555
2555
 
2556
- /** Kopfzellen messen — die Breiten sind die Grundlage der Sticky-Abstände. */
2556
+ /** Measure header cells — the widths are the basis for the sticky offsets. */
2557
2557
  function observeHeaderCells(): void {
2558
2558
  const scroller = scrollerEl.value
2559
2559
  if (!scroller || !headerObserver) return
@@ -2563,7 +2563,7 @@ function observeHeaderCells(): void {
2563
2563
  }
2564
2564
  }
2565
2565
 
2566
- /** Detail-Panels haben freie Höhe → für die Virtualisierung messen. */
2566
+ /** Detail panels have free height → measure them for virtualization. */
2567
2567
  function measureDetailRows(): void {
2568
2568
  const body = bodyEl.value
2569
2569
  if (!body || !detailObserver) return
@@ -2628,7 +2628,7 @@ watch(
2628
2628
  () => void nextTick(measureDetailRows)
2629
2629
  )
2630
2630
 
2631
- // Datenwechsel: Seite in den gültigen Bereich ziehen.
2631
+ // Data change: clamp the page into the valid range.
2632
2632
  watch(
2633
2633
  () => state.filteredCount,
2634
2634
  () => {
@@ -2638,7 +2638,7 @@ watch(
2638
2638
  </script>
2639
2639
 
2640
2640
  <style scoped>
2641
- /* Größen und Farben spiegeln die CSS-Variablen von <wa-data-grid>. */
2641
+ /* Sizes and colors mirror the CSS variables of <wa-data-grid>. */
2642
2642
  .vi-dt {
2643
2643
  --dt-border-color: var(--wa-color-surface-border);
2644
2644
  --dt-border-width: var(--wa-border-width-s);
@@ -2653,11 +2653,11 @@ watch(
2653
2653
  display: flex;
2654
2654
  flex-direction: column;
2655
2655
  min-inline-size: 0;
2656
- /* Bezugsrahmen für absolut positionierte Kinder — vor allem .vi-dt-live.
2657
- Ohne das hängt die Live-Region am initialen Container (body) und entkommt
2658
- JEDEM `overflow: hidden` der Vorfahren: sie liegt dann auf der
2659
- Dokument-Position ihres Gitters und spannt den Scrollbereich der ganzen
2660
- Seite auf (bei acht Gittern untereinander mehrere Tausend Pixel). */
2656
+ /* Containing block for absolutely positioned children — above all .vi-dt-live.
2657
+ Without it the live region hangs off the initial container (body) and escapes
2658
+ EVERY `overflow: hidden` of its ancestors: it then sits at the
2659
+ document position of its grid and stretches the scroll area of the whole
2660
+ page (several thousand pixels with eight grids stacked). */
2661
2661
  position: relative;
2662
2662
  color: var(--wa-color-text-normal);
2663
2663
  font-size: var(--wa-font-size-s);
@@ -2669,7 +2669,7 @@ watch(
2669
2669
  overflow: hidden;
2670
2670
  }
2671
2671
 
2672
- /* Größen: Zeilenhöhe, Polsterung und Textskala zusammen. */
2672
+ /* Sizes: row height, padding and text scale together. */
2673
2673
  .vi-dt.is-size-xs {
2674
2674
  --dt-row-height: 2rem;
2675
2675
  --dt-cell-padding: var(--wa-space-xs);
@@ -2699,15 +2699,15 @@ watch(
2699
2699
  min-inline-size: 14rem;
2700
2700
  }
2701
2701
 
2702
- /* Ein Scroll-Bereich für Kopf, Körper und Fuß — nur so bleiben gepinnte
2703
- Spalten und die Kopfzeile beim Scrollen in beiden Richtungen bündig. */
2702
+ /* One scroll area for header, body and footer — only this way do pinned
2703
+ columns and the header row stay aligned when scrolling in both directions. */
2704
2704
  .vi-dt-scroller {
2705
2705
  position: relative;
2706
2706
  overflow: auto;
2707
- /* Streckt sich, wenn die Tabelle eine feste Höhe hat (z. B. in einer
2708
- füllenden Ansicht) — sonst bleibt sie inhaltshoch. Der Deckel gilt
2709
- weiterhin für inhaltshohe Tabellen; wer füllen will, setzt von aussen
2710
- `--dt-max-height: none`. */
2707
+ /* Stretches when the table has a fixed height (e.g. in a
2708
+ filling view) — otherwise it stays content-sized. The cap still
2709
+ applies to content-sized tables; to fill, set
2710
+ `--dt-max-height: none` from outside. */
2711
2711
  flex: 1 1 auto;
2712
2712
  min-block-size: 0;
2713
2713
  max-block-size: var(--dt-max-height, 30rem);
@@ -2717,13 +2717,13 @@ watch(
2717
2717
  inset-block-start: 0;
2718
2718
  z-index: 3;
2719
2719
  }
2720
- /* Die Zeile gibt nur den Farbton als Variable vor und ordnet die Zellen an —
2721
- gemalt wird in den ZELLEN.
2722
- Grund: Die Zeilenbox ist nur so breit wie der sichtbare Bereich, ihre
2723
- Spalten-Tracks können aber darüber hinausragen (waagerechtes Scrollen).
2724
- Ein Hintergrund oder Rahmen auf der Zeile bricht deshalb mitten im Gitter
2725
- ab — sichtbar als fehlende Trennlinie unter gepinnten Spalten, sobald man
2726
- scrollt. Die Zellen decken dagegen die gesamte Breite ab. */
2720
+ /* The row only provides the tint as a variable and lays out the cells —
2721
+ painting happens in the CELLS.
2722
+ Reason: the row box is only as wide as the visible area, but its
2723
+ column tracks can extend beyond it (horizontal scrolling).
2724
+ A background or border on the row therefore breaks off in the middle of the grid
2725
+ — visible as a missing divider under pinned columns as soon as you
2726
+ scroll. The cells, in contrast, cover the full width. */
2727
2727
  .vi-dt-row {
2728
2728
  --dt-row-bg: var(--wa-color-surface-default);
2729
2729
  display: grid;
@@ -2739,14 +2739,14 @@ watch(
2739
2739
  .vi-dt.is-striped .vi-dt-row.is-odd {
2740
2740
  --dt-row-bg: var(--dt-stripe);
2741
2741
  }
2742
- /* Rangfolge der Zeilenfarbe: Auswahl > Hover > Streifen > Grundfläche.
2743
- Die Streifenregel braucht `.is-striped` am Wurzelelement und wiegt damit
2744
- schwerer als ein schlichtes `.vi-dt-row.is-data:hover` — ohne die folgenden
2745
- Präfixe bliebe auf ungeraden Zeilen weder Hover noch Auswahl sichtbar. */
2742
+ /* Precedence of the row color: selection > hover > stripe > base surface.
2743
+ The stripe rule needs `.is-striped` on the root element and thus weighs
2744
+ more than a plain `.vi-dt-row.is-data:hover` — without the following
2745
+ prefixes neither hover nor selection would be visible on odd rows. */
2746
2746
  .vi-dt .vi-dt-row.is-data:hover {
2747
2747
  --dt-row-bg: var(--dt-row-hover);
2748
2748
  }
2749
- /* Auswahl per Zeilenklick: die Zeile signalisiert Klickbarkeit. */
2749
+ /* Selection via row click: the row signals that it is clickable. */
2750
2750
  .vi-dt.is-selectable .vi-dt-row.is-data {
2751
2751
  cursor: pointer;
2752
2752
  }
@@ -2789,7 +2789,7 @@ watch(
2789
2789
  gap: var(--wa-space-2xs);
2790
2790
  min-inline-size: 0;
2791
2791
  padding-inline: var(--dt-cell-padding);
2792
- /* Farbton und Trennlinie trägt jede Zelle selbst (siehe .vi-dt-row). */
2792
+ /* Each cell carries the tint and divider itself (see .vi-dt-row). */
2793
2793
  background-color: var(--dt-row-bg);
2794
2794
  border-block-end: var(--dt-border-width) solid var(--dt-border-color);
2795
2795
  overflow: hidden;
@@ -2808,12 +2808,12 @@ watch(
2808
2808
  outline: var(--wa-focus-ring);
2809
2809
  outline-offset: -2px;
2810
2810
  }
2811
- /* Gepinnte Zellen liegen ÜBER dem waagerecht scrollenden Inhalt und müssen ihn
2812
- deckend verbergen. Die Zeilentöne (Streifen, Hover, Auswahl) sind
2813
- teiltransparent (color-mix mit transparent), eine einfache
2814
- `background-color: var(--dt-row-bg)` ließe den Inhalt also durchscheinen.
2815
- Deshalb: deckende Grundfläche als Farbe, der Zeilenton als Auflage — das
2816
- Ergebnis ist derselbe Farbwert wie bei den frei scrollenden Zellen. */
2811
+ /* Pinned cells lie ABOVE the horizontally scrolling content and must hide it
2812
+ opaquely. The row tints (stripe, hover, selection) are
2813
+ semi-transparent (color-mix with transparent), so a plain
2814
+ `background-color: var(--dt-row-bg)` would let the content show through.
2815
+ Hence: an opaque base surface as the color, the row tint as an overlay — the
2816
+ result is the same color value as for the freely scrolling cells. */
2817
2817
  .vi-dt-cell.is-sticky {
2818
2818
  background-color: var(--wa-color-surface-default);
2819
2819
  background-image: linear-gradient(var(--dt-row-bg), var(--dt-row-bg));
@@ -2838,8 +2838,8 @@ watch(
2838
2838
  .vi-dt-hcell {
2839
2839
  padding-inline: var(--wa-space-s);
2840
2840
  }
2841
- /* Die Beschriftung füllt den freien Platz — sonst rutschen Filter- und
2842
- Menüknopf in die Mitte der Zelle und fangen den Sortier-Klick ab. */
2841
+ /* The label fills the free space — otherwise the filter and menu buttons
2842
+ slide into the middle of the cell and intercept the sort click. */
2843
2843
  .vi-dt-hcell .vi-dt-hlabel {
2844
2844
  flex: 1 1 auto;
2845
2845
  min-inline-size: 0;
@@ -2851,16 +2851,16 @@ watch(
2851
2851
  .vi-dt-hcell.is-align-center .vi-dt-hlabel {
2852
2852
  text-align: center;
2853
2853
  }
2854
- /* Spaltenmenü nur zeigen, wenn die Spalte im Fokus/Hover ist — sonst frisst es
2855
- die Beschriftung. (wa-dropdown selbst ist display:contents, also am Trigger.)
2856
- Aktive Filter bleiben immer sichtbar. */
2857
- /* visibility statt display: der Knopf ist unsichtbar und NICHT anklickbar
2858
- (fängt also den Sortier-Klick in der Zellmitte nicht ab), behält aber seinen
2859
- Platz im Layout. Beides ist wichtig:
2860
- - Mit display:none verlässt der Knopf das Layout. Ist sein Menü offen, hat
2861
- das Popup plötzlich keinen Anker mehr und springt in die linke obere Ecke.
2862
- - Außerdem würde die Kopfzeile bei jedem Hover-Wechsel umbrechen und offene
2863
- Filter-Popover um die Knopfbreite verschieben. */
2854
+ /* Only show the column menu when the column is focused/hovered — otherwise it eats
2855
+ the label. (wa-dropdown itself is display:contents, so style the trigger.)
2856
+ Active filters always stay visible. */
2857
+ /* visibility instead of display: the button is invisible and NOT clickable
2858
+ (so it doesn't intercept the sort click in the middle of the cell), but keeps its
2859
+ place in the layout. Both matter:
2860
+ - With display:none the button leaves the layout. If its menu is open, the
2861
+ popup suddenly has no anchor anymore and jumps to the top left corner.
2862
+ - Also, the header row would reflow on every hover change and shift open
2863
+ filter popovers by the button width. */
2864
2864
  .vi-dt-column-menu-button {
2865
2865
  visibility: hidden;
2866
2866
  }
@@ -2925,24 +2925,24 @@ watch(
2925
2925
  position: relative;
2926
2926
  }
2927
2927
 
2928
- /* ── Baumstruktur sichtbar machen ──────────────────────────────────── */
2929
- /* Einrückung allein liest sich schlecht. Deshalb pro Ebene eine senkrechte
2930
- Führungslinie (als Hintergrund-Muster über die eingerückte Fläche) und ein
2931
- kurzer Winkel zur Zeile selbst — wie in einem Dateibaum. */
2928
+ /* ── Making the tree structure visible ─────────────────────────────── */
2929
+ /* Indentation alone reads poorly. Hence one vertical guide line per level
2930
+ (as a background pattern over the indented area) and a
2931
+ short corner to the row itself — like in a file tree. */
2932
2932
  .vi-dt {
2933
2933
  --dt-guide: var(--wa-color-surface-border);
2934
2934
  }
2935
2935
  .vi-dt-cell.is-tree-cell {
2936
2936
  position: relative;
2937
2937
  }
2938
- /* Ein Feld je Ebene. Die senkrechte Linie sitzt auf der MITTE des Feldes, damit
2939
- sie durch die Aufklapp-Pfeile der jeweiligen Ebene läuft.
2940
- is-line → durchgehende Linie (Vorfahre hat noch Geschwister)
2941
- is-tee → „├" (diese Zeile hat noch Geschwister)
2942
- is-corner → „└" (letztes Kind: darunter bleibt es leer)
2943
- ohne alles → leeres Feld (der Teilbaum ist dort zu Ende)
2944
- Der Arm endet am Rand des Feldes — er läuft NICHT über den Pfeil oder den
2945
- Blatt-Platzhalter hinweg bis zum Text. */
2938
+ /* One slot per level. The vertical line sits in the MIDDLE of the slot so
2939
+ it runs through the expand arrows of the respective level.
2940
+ is-line → continuous line (ancestor still has siblings)
2941
+ is-tee → "├" (this row still has siblings)
2942
+ is-corner → "└" (last child: empty below)
2943
+ none → empty slot (the subtree ends there)
2944
+ The arm ends at the edge of the slot — it does NOT run past the arrow or the
2945
+ leaf placeholder up to the text. */
2946
2946
  .vi-dt-guide {
2947
2947
  position: relative;
2948
2948
  flex: 0 0 auto;
@@ -2959,11 +2959,11 @@ watch(
2959
2959
  inline-size: 1px;
2960
2960
  background-color: var(--dt-guide);
2961
2961
  }
2962
- /* Ecke: die senkrechte Linie endet auf halber Höhe. */
2962
+ /* Corner: the vertical line ends at half height. */
2963
2963
  .vi-dt-guide.is-corner::before {
2964
2964
  inset-block: 0 50%;
2965
2965
  }
2966
- /* Kurzer Arm von der Mitte zum Rand des Feldes. */
2966
+ /* Short arm from the middle to the edge of the slot. */
2967
2967
  .vi-dt-guide.is-tee::after,
2968
2968
  .vi-dt-guide.is-corner::after {
2969
2969
  content: "";
@@ -2974,15 +2974,15 @@ watch(
2974
2974
  background-color: var(--dt-guide);
2975
2975
  }
2976
2976
 
2977
- /* Pfeil und Blatt-Platzhalter MÜSSEN gleich breit sein — sonst stehen die
2978
- Beschriftungen einer Ebene nicht untereinander und die Einrückung wird
2979
- teilweise aufgefressen (der wa-button ist von sich aus breiter). */
2977
+ /* Arrow and leaf placeholder MUST be equally wide — otherwise the labels of a
2978
+ level don't line up and the indentation gets
2979
+ partially eaten up (the wa-button is wider by itself). */
2980
2980
  .vi-dt-expand {
2981
2981
  flex: 0 0 auto;
2982
2982
  inline-size: var(--dt-toggle-size);
2983
2983
  }
2984
- /* Nachgeladen und leer: der Pfeil bleibt an seinem Platz (kein Layoutsprung),
2985
- ist aber sichtbar außer Betrieb. */
2984
+ /* Loaded and empty: the arrow stays in place (no layout jump),
2985
+ but is visibly out of service. */
2986
2986
  .vi-dt-expand.is-empty {
2987
2987
  opacity: 0.3;
2988
2988
  cursor: default;
@@ -2991,13 +2991,13 @@ watch(
2991
2991
  font-size: 0.75em;
2992
2992
  --track-width: 2px;
2993
2993
  }
2994
- /* Blätter: gleicher Abstand wie zwischen Pfeil und Führungslinien. */
2994
+ /* Leaves: same spacing as between arrow and guide lines. */
2995
2995
  .vi-dt-leaf-gap {
2996
2996
  flex: 0 0 auto;
2997
2997
  inline-size: var(--wa-space-2xs);
2998
2998
  }
2999
2999
 
3000
- /* ── Feste Reihenfolge: Griff und Einfügemarke ─────────────────────── */
3000
+ /* ── Fixed order: handle and insertion marker ──────────────────────── */
3001
3001
  .vi-dt-grip {
3002
3002
  display: inline-flex;
3003
3003
  align-items: center;
@@ -3018,16 +3018,16 @@ watch(
3018
3018
  .vi-dt-row.is-dragging {
3019
3019
  opacity: 0.5;
3020
3020
  }
3021
- /* Marke, wo die Zeile landen würde. Als inset-Schatten, damit sie keine
3022
- Layout-Höhe kostet und die Zeilen beim Ziehen nicht springen. */
3021
+ /* Marker where the row would land. As an inset shadow so it costs no
3022
+ layout height and rows don't jump while dragging. */
3023
3023
  .vi-dt-row.is-drop-before .vi-dt-cell {
3024
3024
  box-shadow: inset 0 2px 0 var(--dt-accent);
3025
3025
  }
3026
3026
  .vi-dt-row.is-drop-after .vi-dt-cell {
3027
3027
  box-shadow: inset 0 -2px 0 var(--dt-accent);
3028
3028
  }
3029
- /* „Hinein": die Zeile wird Elternteil — deshalb ein Rahmen um die ganze Zeile
3030
- statt einer Linie an der Kante. */
3029
+ /* "Inside": the row becomes the parent — hence a frame around the whole row
3030
+ instead of a line at the edge. */
3031
3031
  .vi-dt-row.is-drop-inside .vi-dt-cell {
3032
3032
  box-shadow:
3033
3033
  inset 0 2px 0 var(--dt-accent),
@@ -3037,8 +3037,8 @@ watch(
3037
3037
  --dt-row-bg: color-mix(in oklab, var(--dt-selected) 60%, transparent);
3038
3038
  }
3039
3039
 
3040
- /* Die Aufklapp-Spalte liegt links der Baumzelle — ihre Linie führt weiter,
3041
- damit Pfeil und Struktur zusammenhängen. */
3040
+ /* The expand column sits left of the tree cell — its line continues
3041
+ so arrow and structure connect. */
3042
3042
  .vi-dt-cell.is-lead.is-expand-cell {
3043
3043
  position: relative;
3044
3044
  }
@@ -3141,8 +3141,8 @@ watch(
3141
3141
  font-size: var(--wa-font-size-s);
3142
3142
  cursor: pointer;
3143
3143
  }
3144
- /* Nur für Screenreader: unsichtbar, aber vorhanden. Bezugsrahmen ist .vi-dt
3145
- (position: relative), damit die Region im Gitter geclippt bleibt. */
3144
+ /* Screen readers only: invisible but present. The containing block is .vi-dt
3145
+ (position: relative) so the region stays clipped within the grid. */
3146
3146
  .vi-dt-live {
3147
3147
  position: absolute;
3148
3148
  inset-block-start: 0;