viur-ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +79 -0
  2. package/index.d.ts +351 -0
  3. package/index.js +46 -0
  4. package/package.json +84 -0
  5. package/src/components/Browser/Browser.vue +496 -0
  6. package/src/components/Browser/TreeNav.vue +408 -0
  7. package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
  8. package/src/components/Browser/ViurBrowser.vue +271 -0
  9. package/src/components/Browser/dragSession.ts +134 -0
  10. package/src/components/Browser/index.ts +14 -0
  11. package/src/components/Browser/types.ts +30 -0
  12. package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
  13. package/src/components/DataTable/DataTable.vue +3155 -0
  14. package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
  15. package/src/components/DataTable/ViurDataTable.vue +331 -0
  16. package/src/components/DataTable/columns.ts +400 -0
  17. package/src/components/DataTable/index.ts +60 -0
  18. package/src/components/DataTable/rows.ts +446 -0
  19. package/src/components/DataTable/types.ts +267 -0
  20. package/src/components/DataTable/useColumnLayout.ts +294 -0
  21. package/src/components/DataTable/useGridNavigation.ts +193 -0
  22. package/src/components/DataTable/useVirtualRows.ts +126 -0
  23. package/src/components/DataTable/viurColumns.ts +330 -0
  24. package/src/forms/ViForm.d.vue.ts +106 -0
  25. package/src/forms/ViForm.vue +592 -0
  26. package/src/forms/ViFormCategory.vue +57 -0
  27. package/src/forms/bones/Bone.vue +1023 -0
  28. package/src/forms/bones/BoneActions.vue +127 -0
  29. package/src/forms/bones/BoneLabel.vue +122 -0
  30. package/src/forms/bones/BoneMultipleList.vue +91 -0
  31. package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
  32. package/src/forms/bones/BoneNestedWrapper.vue +155 -0
  33. package/src/forms/bones/BoneSingleEntry.vue +51 -0
  34. package/src/forms/bones/FilterBone.vue +694 -0
  35. package/src/forms/bones/actionbar/defaultBar.vue +98 -0
  36. package/src/forms/bones/actionbar/fileBar.vue +165 -0
  37. package/src/forms/bones/actionbar/relationalBar.vue +125 -0
  38. package/src/forms/bones/actions/ActionDialog.vue +70 -0
  39. package/src/forms/bones/actions/Debug.vue +106 -0
  40. package/src/forms/bones/actions/Delete.vue +13 -0
  41. package/src/forms/bones/actions/DescribeImage.vue +201 -0
  42. package/src/forms/bones/actions/Download.vue +27 -0
  43. package/src/forms/bones/actions/Placeholder.vue +14 -0
  44. package/src/forms/bones/actions/Translation.vue +238 -0
  45. package/src/forms/bones/actions/index.js +280 -0
  46. package/src/forms/bones/bone.d.ts +45 -0
  47. package/src/forms/bones/country_information.json +779 -0
  48. package/src/forms/bones/index.js +225 -0
  49. package/src/forms/bones/uploadAdapter.js +46 -0
  50. package/src/forms/bones/utils.js +284 -0
  51. package/src/forms/bones/view/base_item.vue +35 -0
  52. package/src/forms/bones/view/boneLogic.js +202 -0
  53. package/src/forms/bones/view/index.js +5 -0
  54. package/src/forms/bones/widgets/booleanBone.vue +83 -0
  55. package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
  56. package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
  57. package/src/forms/bones/widgets/codeBone.vue +214 -0
  58. package/src/forms/bones/widgets/colorBone.vue +75 -0
  59. package/src/forms/bones/widgets/dateBone.vue +114 -0
  60. package/src/forms/bones/widgets/emailBone.vue +100 -0
  61. package/src/forms/bones/widgets/fileBone.vue +273 -0
  62. package/src/forms/bones/widgets/jsonBone.vue +63 -0
  63. package/src/forms/bones/widgets/keyBone.vue +71 -0
  64. package/src/forms/bones/widgets/numericBone.vue +125 -0
  65. package/src/forms/bones/widgets/passwordBone.vue +215 -0
  66. package/src/forms/bones/widgets/phoneBone.vue +173 -0
  67. package/src/forms/bones/widgets/rawBone.vue +94 -0
  68. package/src/forms/bones/widgets/recordBone.vue +64 -0
  69. package/src/forms/bones/widgets/relationalBone.vue +164 -0
  70. package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
  71. package/src/forms/bones/widgets/selectBone.vue +122 -0
  72. package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
  73. package/src/forms/bones/widgets/spatialBone.vue +111 -0
  74. package/src/forms/bones/widgets/stringBone.vue +139 -0
  75. package/src/forms/bones/widgets/textBone.vue +641 -0
  76. package/src/forms/install.d.ts +4 -0
  77. package/src/forms/install.js +43 -0
  78. package/src/forms/layouts/LayloutList2Columns.vue +57 -0
  79. package/src/forms/layouts/LayoutCategory.vue +38 -0
  80. package/src/forms/layouts/LayoutList.vue +31 -0
  81. package/src/forms/utils.js +601 -0
  82. package/src/public/assets/icons/fontawesome-manifest.json +8 -0
  83. package/src/public/assets/icons/regular/file.svg +1 -0
  84. package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
  85. package/src/public/assets/icons/solid/bug.svg +1 -0
  86. package/src/public/assets/icons/solid/check.svg +1 -0
  87. package/src/public/assets/icons/solid/circle-info.svg +1 -0
  88. package/src/public/assets/icons/solid/compress.svg +1 -0
  89. package/src/public/assets/icons/solid/download.svg +1 -0
  90. package/src/public/assets/icons/solid/expand.svg +1 -0
  91. package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
  92. package/src/public/assets/icons/solid/plus.svg +1 -0
  93. package/src/public/assets/icons/solid/question.svg +1 -0
  94. package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
  95. package/src/public/assets/icons/solid/upload.svg +1 -0
  96. package/src/public/assets/icons/solid/xmark.svg +1 -0
  97. package/src/request/cache.js +183 -0
  98. package/src/request/configStore.js +516 -0
  99. package/src/request/envelope.js +211 -0
  100. package/src/request/index.js +35 -0
  101. package/src/request/messageStore.js +201 -0
  102. package/src/request/request.js +752 -0
  103. package/src/request/skel.js +90 -0
  104. package/src/theme/colorStore.js +168 -0
  105. package/src/translations/de.js +178 -0
  106. package/src/translations/en.js +177 -0
  107. package/src/user/LoginScreen.d.vue.ts +54 -0
  108. package/src/user/LoginScreen.vue +627 -0
  109. package/src/user/google.js +75 -0
  110. package/src/user/index.js +3 -0
  111. package/src/user/store.d.ts +59 -0
  112. package/src/user/store.js +183 -0
  113. package/src/webawesomeCompat.css +72 -0
  114. package/src/webawesomeConfig.js +61 -0
@@ -0,0 +1,193 @@
1
+ // Tastaturnavigation nach dem ARIA-Grid-Muster: Das ganze Gitter ist EIN
2
+ // Tab-Stop, innerhalb wandert ein Roving-Tabindex über die Zellen.
3
+ //
4
+ // Die Composable kennt nur Koordinaten (Zeile -1 = Kopfzeile, 0..n-1 = Body)
5
+ // und ruft für alles Fachliche Callbacks der Komponente.
6
+
7
+ import { nextTick, reactive, type Ref } from "vue"
8
+
9
+ export interface GridNavigationOptions {
10
+ rowCount: () => number
11
+ colCount: () => number
12
+ /** Zeilen pro Seite für Bild auf/ab. */
13
+ pageRows?: () => number
14
+ /** Enter auf einer Kopfzelle. */
15
+ onHeaderActivate?: (col: number, shift: boolean) => void
16
+ /** Enter auf einer Datenzelle. */
17
+ onCellActivate?: (row: number, col: number) => void
18
+ /** Leertaste auf einer Zeile. */
19
+ onToggleSelect?: (row: number) => void
20
+ /** Shift + ↑/↓ */
21
+ onExtendSelection?: (row: number) => void
22
+ /** Strg + A */
23
+ onSelectAll?: () => void
24
+ /** Strg + C */
25
+ onCopy?: () => void
26
+ /** Shift + F10 / Kontextmenü-Taste */
27
+ onContextMenu?: (row: number, col: number) => void
28
+ /** Alt + ↑/↓ auf einer Datenzeile (feste Reihenfolge). */
29
+ onMoveRow?: (row: number, delta: number) => void
30
+ /** Shift + ←/→ auf einer Kopfzelle. */
31
+ onMoveColumn?: (col: number, delta: number) => void
32
+ /** Alt + ←/→ auf einer Kopfzelle. */
33
+ onResizeColumn?: (col: number, delta: number) => void
34
+ }
35
+
36
+ const RESIZE_STEP = 24
37
+
38
+ export function useGridNavigation(scroller: Ref<HTMLElement | undefined>, options: GridNavigationOptions) {
39
+ const state = reactive({
40
+ /** -1 = Kopfzeile. */
41
+ row: -1,
42
+ col: 0,
43
+ })
44
+
45
+ function isActive(row: number, col: number): boolean {
46
+ return state.row === row && state.col === col
47
+ }
48
+
49
+ function clamp(): void {
50
+ const rows = options.rowCount()
51
+ const cols = options.colCount()
52
+ state.row = Math.max(-1, Math.min(rows - 1, state.row))
53
+ if (rows === 0) state.row = -1
54
+ state.col = Math.max(0, Math.min(Math.max(0, cols - 1), state.col))
55
+ }
56
+
57
+ async function focusActive(): Promise<void> {
58
+ clamp()
59
+ await nextTick()
60
+ const el = scroller.value?.querySelector<HTMLElement>(`[data-row="${state.row}"][data-col="${state.col}"]`)
61
+ el?.focus({ preventScroll: false })
62
+ }
63
+
64
+ function setActive(row: number, col: number, focus = true): void {
65
+ state.row = row
66
+ state.col = col
67
+ clamp()
68
+ if (focus) void focusActive()
69
+ }
70
+
71
+ function move(deltaRow: number, deltaCol: number): void {
72
+ setActive(state.row + deltaRow, state.col + deltaCol)
73
+ }
74
+
75
+ function handleKeydown(event: KeyboardEvent): void {
76
+ const rows = options.rowCount()
77
+ const cols = options.colCount()
78
+ const inHeader = state.row === -1
79
+ const meta = event.ctrlKey || event.metaKey
80
+
81
+ switch (event.key) {
82
+ case "ArrowDown":
83
+ if (event.altKey && !inHeader) {
84
+ event.preventDefault()
85
+ options.onMoveRow?.(state.row, 1)
86
+ return
87
+ }
88
+ if (event.shiftKey && !inHeader) {
89
+ event.preventDefault()
90
+ move(1, 0)
91
+ options.onExtendSelection?.(state.row)
92
+ return
93
+ }
94
+ event.preventDefault()
95
+ move(1, 0)
96
+ return
97
+ case "ArrowUp":
98
+ if (event.altKey && !inHeader) {
99
+ event.preventDefault()
100
+ options.onMoveRow?.(state.row, -1)
101
+ return
102
+ }
103
+ if (event.shiftKey && !inHeader) {
104
+ event.preventDefault()
105
+ move(-1, 0)
106
+ options.onExtendSelection?.(state.row)
107
+ return
108
+ }
109
+ event.preventDefault()
110
+ move(-1, 0)
111
+ return
112
+ case "ArrowLeft":
113
+ event.preventDefault()
114
+ if (inHeader && event.shiftKey) {
115
+ options.onMoveColumn?.(state.col, -1)
116
+ void focusActive()
117
+ return
118
+ }
119
+ if (inHeader && event.altKey) {
120
+ options.onResizeColumn?.(state.col, -RESIZE_STEP)
121
+ return
122
+ }
123
+ move(0, -1)
124
+ return
125
+ case "ArrowRight":
126
+ event.preventDefault()
127
+ if (inHeader && event.shiftKey) {
128
+ options.onMoveColumn?.(state.col, 1)
129
+ void focusActive()
130
+ return
131
+ }
132
+ if (inHeader && event.altKey) {
133
+ options.onResizeColumn?.(state.col, RESIZE_STEP)
134
+ return
135
+ }
136
+ move(0, 1)
137
+ return
138
+ case "Home":
139
+ event.preventDefault()
140
+ if (meta) setActive(rows ? 0 : -1, 0)
141
+ else setActive(state.row, 0)
142
+ return
143
+ case "End":
144
+ event.preventDefault()
145
+ if (meta) setActive(rows - 1, cols - 1)
146
+ else setActive(state.row, cols - 1)
147
+ return
148
+ case "PageDown":
149
+ event.preventDefault()
150
+ move(options.pageRows?.() ?? 10, 0)
151
+ return
152
+ case "PageUp":
153
+ event.preventDefault()
154
+ move(-(options.pageRows?.() ?? 10), 0)
155
+ return
156
+ case "Enter":
157
+ event.preventDefault()
158
+ if (inHeader) options.onHeaderActivate?.(state.col, event.shiftKey)
159
+ else options.onCellActivate?.(state.row, state.col)
160
+ return
161
+ case " ":
162
+ case "Spacebar":
163
+ if (inHeader) return
164
+ event.preventDefault()
165
+ options.onToggleSelect?.(state.row)
166
+ return
167
+ case "a":
168
+ case "A":
169
+ if (!meta) return
170
+ event.preventDefault()
171
+ options.onSelectAll?.()
172
+ return
173
+ case "c":
174
+ case "C":
175
+ if (!meta) return
176
+ options.onCopy?.()
177
+ return
178
+ case "F10":
179
+ if (!event.shiftKey) return
180
+ event.preventDefault()
181
+ options.onContextMenu?.(state.row, state.col)
182
+ return
183
+ case "ContextMenu":
184
+ event.preventDefault()
185
+ options.onContextMenu?.(state.row, state.col)
186
+ return
187
+ default:
188
+ return
189
+ }
190
+ }
191
+
192
+ return { state, isActive, setActive, move, focusActive, handleKeydown, clamp }
193
+ }
@@ -0,0 +1,126 @@
1
+ // Virtualisierung: nur das sichtbare Fenster (plus Overscan) rendert.
2
+ //
3
+ // Höhen sind nicht überall gleich — Detail-Panels dürfen beliebig hoch sein.
4
+ // Deshalb wird ein Präfixsummen-Array über die Zeilenhöhen geführt (gemessene
5
+ // Höhen überschreiben die Standardhöhe) und darin binär gesucht. Das Fenster
6
+ // wird über Padding oben/unten positioniert, nicht über absolute Positionen —
7
+ // so bleiben Grid-Spalten und Sticky-Pinning unangetastet.
8
+
9
+ import { computed, reactive, type Ref } from "vue"
10
+ import type { DisplayRow, Row } from "./types"
11
+
12
+ export interface VirtualRowsOptions<R extends Row = Row> {
13
+ rows: () => DisplayRow<R>[]
14
+ rowHeight: () => number
15
+ enabled: () => boolean
16
+ overscan?: number
17
+ }
18
+
19
+ export function useVirtualRows<R extends Row = Row>(
20
+ scroller: Ref<HTMLElement | undefined>,
21
+ options: VirtualRowsOptions<R>
22
+ ) {
23
+ const overscan = options.overscan ?? 6
24
+
25
+ const state = reactive({
26
+ scrollTop: 0,
27
+ viewportHeight: 0,
28
+ /** Gemessene Höhen (nur für Zeilen, die von der Standardhöhe abweichen). */
29
+ heights: {} as Record<string, number>,
30
+
31
+ offsets: computed((): number[] => {
32
+ const rows = options.rows()
33
+ const base = options.rowHeight()
34
+ const offsets = new Array<number>(rows.length + 1)
35
+ offsets[0] = 0
36
+ for (let index = 0; index < rows.length; index++) {
37
+ offsets[index + 1] = offsets[index] + (state.heights[rows[index].key] ?? base)
38
+ }
39
+ return offsets
40
+ }),
41
+
42
+ totalHeight: computed((): number => {
43
+ const offsets = state.offsets as number[]
44
+ return offsets[offsets.length - 1] ?? 0
45
+ }),
46
+
47
+ range: computed((): { start: number; end: number } => {
48
+ const rows = options.rows()
49
+ if (!options.enabled() || !rows.length) return { start: 0, end: rows.length }
50
+ const offsets = state.offsets as number[]
51
+ const viewport = state.viewportHeight || options.rowHeight() * 12
52
+ const first = findIndex(offsets, state.scrollTop)
53
+ const last = findIndex(offsets, state.scrollTop + viewport) + 1
54
+ return {
55
+ start: Math.max(0, first - overscan),
56
+ end: Math.min(rows.length, last + overscan),
57
+ }
58
+ }),
59
+
60
+ windowRows: computed((): DisplayRow<R>[] => {
61
+ const { start, end } = state.range as { start: number; end: number }
62
+ return options.rows().slice(start, end)
63
+ }),
64
+
65
+ padStart: computed((): number => {
66
+ const offsets = state.offsets as number[]
67
+ return offsets[(state.range as { start: number }).start] ?? 0
68
+ }),
69
+
70
+ padEnd: computed((): number => {
71
+ const offsets = state.offsets as number[]
72
+ const end = (state.range as { end: number }).end
73
+ return Math.max(0, (state.totalHeight as number) - (offsets[end] ?? 0))
74
+ }),
75
+ })
76
+
77
+ /** Größter Index, dessen Offset <= position ist (binäre Suche). */
78
+ function findIndex(offsets: number[], position: number): number {
79
+ let low = 0
80
+ let high = offsets.length - 1
81
+ while (low < high) {
82
+ const middle = (low + high) >> 1
83
+ if (offsets[middle + 1] <= position) low = middle + 1
84
+ else high = middle
85
+ }
86
+ return low
87
+ }
88
+
89
+ function syncViewport(): void {
90
+ const el = scroller.value
91
+ if (!el) return
92
+ state.scrollTop = el.scrollTop
93
+ state.viewportHeight = el.clientHeight
94
+ }
95
+
96
+ function onScroll(): void {
97
+ syncViewport()
98
+ }
99
+
100
+ /** Höhe einer abweichenden Zeile (Detail-Panel) hinterlegen. */
101
+ function measureRow(key: string, height: number): void {
102
+ if (height <= 0) return
103
+ if (Math.abs((state.heights[key] ?? 0) - height) < 1) return
104
+ state.heights[key] = height
105
+ }
106
+
107
+ function forgetRow(key: string): void {
108
+ delete state.heights[key]
109
+ }
110
+
111
+ function scrollToIndex(index: number, align: "start" | "center" | "end" = "start"): void {
112
+ const el = scroller.value
113
+ if (!el) return
114
+ const offsets = state.offsets as number[]
115
+ const clamped = Math.max(0, Math.min(offsets.length - 2, index))
116
+ const top = offsets[clamped] ?? 0
117
+ const height = (offsets[clamped + 1] ?? top) - top
118
+ const viewport = el.clientHeight
119
+ const target =
120
+ align === "center" ? top - viewport / 2 + height / 2 : align === "end" ? top - viewport + height : top
121
+ el.scrollTop = Math.max(0, target)
122
+ syncViewport()
123
+ }
124
+
125
+ return { state, onScroll, syncViewport, measureRow, forgetRow, scrollToIndex }
126
+ }
@@ -0,0 +1,330 @@
1
+ import type { CellBadge, DataTableColumn, Row } from "./types"
2
+ import { compareValues, firstValueOf } from "./columns"
3
+ // @ts-expect-error – JS-Modul ohne Typen
4
+ import Utils from "../../forms/bones/utils.js"
5
+
6
+ // --------------------------------------------------------------------------- //
7
+ // ViUR-Struktur → Spalten der DataTable //
8
+ // --------------------------------------------------------------------------- //
9
+ //
10
+ // Eine ViUR-Liste liefert neben den Datensätzen ihre `structure`: je Bone ein
11
+ // Objekt mit `descr` (Beschriftung), `type` ("str", "str.email", "numeric",
12
+ // "select", "relational.…"), `multiple`, `languages`, `indexed`, `visible` und
13
+ // typabhängigen Zusätzen (`values` bei select, `precision` bei numeric,
14
+ // `format` bei relationalen Bones).
15
+ //
16
+ // Hier entstehen daraus Spalten: Beschriftung, Ausrichtung, Sortierbarkeit und
17
+ // ein `formatter`. Die Übersetzung Wert → Text macht `Utils.boneToString`
18
+ // (src/forms/bones/utils.js) — die EINE Stelle dafür, die auch die Formulare
19
+ // und Formatstrings der Bones benutzen.
20
+
21
+ /** Ein Bone der Struktur, wie der Renderer ihn liefert. */
22
+ export interface BoneDescription {
23
+ descr?: string
24
+ type?: string
25
+ multiple?: boolean
26
+ languages?: string[] | null
27
+ /** Nur indizierte Bones lassen sich serverseitig sortieren. */
28
+ indexed?: boolean
29
+ visible?: boolean
30
+ readonly?: boolean
31
+ /** select: Wert → Beschriftung. */
32
+ values?: Record<string, string> | [string, string][]
33
+ /** numeric: Nachkommastellen. */
34
+ precision?: number
35
+ /** date: was der Bone überhaupt führt. */
36
+ date?: boolean
37
+ time?: boolean
38
+ params?: Record<string, unknown> | null
39
+ [key: string]: unknown
40
+ }
41
+
42
+ export type BoneStructure = Record<string, BoneDescription>
43
+
44
+ export interface ColumnsFromStructureOptions<R extends Row = Row> {
45
+ /**
46
+ * Sprache mehrsprachiger Bones: ein Code zeigt diese Sprache (leer →
47
+ * erste befüllte, mit ihrer Flagge gekennzeichnet), `"*"` zeigt ALLE
48
+ * befüllten Sprachen mit Flaggen-Präfix; ohne Angabe die erste des Bones.
49
+ */
50
+ language?: string | null
51
+ /** Steht im `"*"`-Modus vorn — üblicherweise die UI-Sprache. */
52
+ primaryLanguage?: string | null
53
+ /** Gebietseinstellung für Zahlen und Datumsangaben. */
54
+ locale?: string
55
+ /** Nur diese Bones, in dieser Reihenfolge (Standard: alle sichtbaren). */
56
+ bones?: string[]
57
+ /** Diese Bones nie zeigen (zusätzlich zu den ohnehin verborgenen). */
58
+ exclude?: string[]
59
+ /** Beschriftung für `bool`-Werte. */
60
+ boolTexts?: [string, string]
61
+ /** Ergänzt/überschreibt die erzeugte Spalte je Bone. */
62
+ overrides?: Record<string, Partial<DataTableColumn<R>>>
63
+ /**
64
+ * Der VOLLSTÄNDIGE Datensatz liegt im Client (alles geladen): Sortieren,
65
+ * Suchen und Spaltenfilter laufen dann lokal auf allen Spalten — ohne den
66
+ * Server. Ohne das Flag bleibt beides aus, denn auf einem ANGELADENEN
67
+ * Ausschnitt wären lokale Sortierung/Filter eine Lüge; dort sortiert der
68
+ * Server (nur indizierte Bones, über die Tabellen-Ereignisse).
69
+ */
70
+ local?: boolean
71
+ }
72
+
73
+ /** Bones, die in einer Liste nichts zu suchen haben. */
74
+ const HIDDEN_TYPES = ["password", "credential"]
75
+
76
+ // --------------------------------------------------------------------------- //
77
+ // Plaketten für reine select- und bool-Bones //
78
+ // --------------------------------------------------------------------------- //
79
+ //
80
+ // Reine Auswahlwerte ("select" ohne Untertyp — country & Co. bleiben Text) und
81
+ // bool-Werte werden als farbige Plakette gerendert: POSITIVE Werte grün,
82
+ // NEGATIVE rot, alle übrigen Optionen bekommen regenbogen-abgestufte Farbtöne
83
+ // (gleichmäßig über den Farbkreis, stabil je Optionsreihenfolge). Ob ein Wert
84
+ // positiv/negativ ist, entscheidet die Schlüsselwortliste unten — geprüft
85
+ // werden Options-SCHLÜSSEL und -BESCHRIFTUNG, unabhängig von Groß-/
86
+ // Kleinschreibung ("Ja", "ja", "yes", "Aktiv", "visible", …).
87
+
88
+ /** Positive Werte → grün. Erweiterbar (z. B. `POSITIVE_KEYWORDS.add("live")`). */
89
+ export const POSITIVE_KEYWORDS = new Set([
90
+ "yes",
91
+ "ja",
92
+ "true",
93
+ "1",
94
+ "active",
95
+ "aktiv",
96
+ "enabled",
97
+ "aktiviert",
98
+ "visible",
99
+ "sichtbar",
100
+ "on",
101
+ "an",
102
+ "ok",
103
+ "public",
104
+ "öffentlich",
105
+ "released",
106
+ "freigegeben",
107
+ "erlaubt",
108
+ "allowed",
109
+ "online",
110
+ ])
111
+
112
+ /** Negative Werte → rot. */
113
+ export const NEGATIVE_KEYWORDS = new Set([
114
+ "no",
115
+ "nein",
116
+ "false",
117
+ "0",
118
+ "inactive",
119
+ "inaktiv",
120
+ "disabled",
121
+ "deaktiviert",
122
+ "hidden",
123
+ "versteckt",
124
+ "unsichtbar",
125
+ "invisible",
126
+ "off",
127
+ "aus",
128
+ "blocked",
129
+ "gesperrt",
130
+ "denied",
131
+ "verboten",
132
+ "private",
133
+ "privat",
134
+ "deleted",
135
+ "gelöscht",
136
+ "offline",
137
+ ])
138
+
139
+ const POSITIVE_COLOR = "var(--wa-color-success-50, #16a34a)"
140
+ const NEGATIVE_COLOR = "var(--wa-color-danger-50, #dc2626)"
141
+
142
+ function keywordTone(...candidates: unknown[]): string | null {
143
+ for (const candidate of candidates) {
144
+ const word = String(candidate ?? "")
145
+ .trim()
146
+ .toLowerCase()
147
+ if (!word) continue
148
+ if (POSITIVE_KEYWORDS.has(word)) return POSITIVE_COLOR
149
+ if (NEGATIVE_KEYWORDS.has(word)) return NEGATIVE_COLOR
150
+ }
151
+ return null
152
+ }
153
+
154
+ /** select-`values` vereinheitlichen: [ [schlüssel, beschriftung], … ]. */
155
+ function selectOptions(bone: BoneDescription): [string, string][] {
156
+ const values = bone.values
157
+ if (Array.isArray(values)) return values.map(([key, label]) => [String(key), String(label)])
158
+ if (values && typeof values === "object") return Object.entries(values).map(([key, label]) => [key, String(label)])
159
+ return []
160
+ }
161
+
162
+ /**
163
+ * Farbzuordnung der Optionen eines reinen select-Bones: Schlüsselwörter
164
+ * entscheiden grün/rot, die übrigen Optionen teilen sich den Farbkreis
165
+ * (Regenbogen), in der Reihenfolge ihrer Definition.
166
+ */
167
+ export function selectToneMap(bone: BoneDescription): Map<string, { label: string; color: string }> {
168
+ const options = selectOptions(bone)
169
+ const map = new Map<string, { label: string; color: string }>()
170
+ const neutrals: [string, string][] = []
171
+ for (const [key, label] of options) {
172
+ const tone = keywordTone(key, label)
173
+ if (tone) map.set(key, { label, color: tone })
174
+ else neutrals.push([key, label])
175
+ }
176
+ neutrals.forEach(([key, label], index) => {
177
+ // Regenbogen: gleichmäßig verteilte Farbtöne, Sättigung/Helligkeit fest.
178
+ const hue = Math.round((360 / Math.max(1, neutrals.length)) * index)
179
+ map.set(key, { label, color: `oklch(0.62 0.16 ${hue})` })
180
+ })
181
+ return map
182
+ }
183
+
184
+ /**
185
+ * Plaketten-Funktion für einen Bone — oder null, wenn der Bone keine bekommt.
186
+ * Nur REINE selects (`type === "select"`, kein select.country o. Ä.) und bools.
187
+ */
188
+ export function badgesForBone(
189
+ bone: BoneDescription,
190
+ options: ColumnsFromStructureOptions = {}
191
+ ): ((value: unknown) => CellBadge[] | null) | null {
192
+ if (bone.type === "bool") {
193
+ const [yes, no] = options.boolTexts ?? ["Ja", "Nein"]
194
+ return (value) => {
195
+ if (value === null || value === undefined || value === "") return null
196
+ const truthy = value === true || value === "true" || value === 1 || value === "1"
197
+ return [{ label: truthy ? yes : no, color: truthy ? POSITIVE_COLOR : NEGATIVE_COLOR }]
198
+ }
199
+ }
200
+ if (bone.type !== "select") return null
201
+ const tones = selectToneMap(bone)
202
+ return (value) => {
203
+ if (value === null || value === undefined || value === "") return null
204
+ const keys = Array.isArray(value) ? value : [value]
205
+ const badges: CellBadge[] = []
206
+ for (const key of keys) {
207
+ const entry = tones.get(String(key))
208
+ badges.push(entry ?? { label: String(key), color: keywordTone(key) ?? "var(--wa-color-neutral-60, #71717a)" })
209
+ }
210
+ return badges.length ? badges : null
211
+ }
212
+ }
213
+
214
+ /** Typfamilie eines Bones: "str.email" → "str". */
215
+ export function boneFamily(type: string | undefined | null): string {
216
+ return Utils.boneFamily(type) as string
217
+ }
218
+
219
+ /**
220
+ * Bone-Wert eines Datensatzes als Text — mit Sprachen, `multiple` und dem
221
+ * Formatstring relationaler Bones. Dünne Hülle um `Utils.boneToString`, damit
222
+ * Tabellen-Code bei den DataTable-Typen bleiben kann.
223
+ */
224
+ export function boneText(value: unknown, bone: BoneDescription, options: ColumnsFromStructureOptions = {}): string {
225
+ return Utils.boneToString(value, bone, options) as string
226
+ }
227
+
228
+ /** Lokaler Spaltenfilter passend zum Bone-Typ. */
229
+ function localFilterFor(bone: BoneDescription, options: ColumnsFromStructureOptions): Partial<DataTableColumn> {
230
+ const family = boneFamily(bone.type)
231
+ if (bone.type === "bool") {
232
+ const [yes, no] = options.boolTexts ?? ["Ja", "Nein"]
233
+ return {
234
+ filterable: true,
235
+ filterType: "set",
236
+ filterOptions: [
237
+ { value: "true", label: yes },
238
+ { value: "false", label: no },
239
+ ],
240
+ }
241
+ }
242
+ if (family === "select") {
243
+ const options = selectOptions(bone).map(([value, label]) => ({ value, label }))
244
+ return { filterable: true, filterType: bone.multiple ? "includes-any" : "set", filterOptions: options }
245
+ }
246
+ if (family === "numeric") return { filterable: true, filterType: "number-range" }
247
+ if (family === "date") return { filterable: true, filterType: "date-range" }
248
+ return { filterable: true, filterType: "text" }
249
+ }
250
+
251
+ const isEmptyValue = (value: unknown): boolean => value === null || value === undefined || value === ""
252
+
253
+ /**
254
+ * Sortierwert eines MEHRSPRACHIGEN Bones: dieselbe Sprache, die die Anzeige
255
+ * nach vorn stellt — die gewünschte (bzw. bei `"*"` die primäre), und wenn
256
+ * die leer ist, die erste befüllte. So sortiert die Spalte nach dem Text, den
257
+ * die Zelle tatsächlich zeigt, nicht stur nach der ersten Bone-Sprache.
258
+ */
259
+ function languageSortValue(value: unknown, languages: string[], options: ColumnsFromStructureOptions): unknown {
260
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value
261
+ const record = value as Record<string, unknown>
262
+ const wanted = options.language === "*" ? (options.primaryLanguage ?? null) : (options.language ?? null)
263
+ let lang = wanted && languages.includes(wanted) ? wanted : languages[0]
264
+ if (isEmptyValue(record[lang])) {
265
+ lang = languages.find((other) => !isEmptyValue(record[other])) ?? lang
266
+ }
267
+ return record[lang]
268
+ }
269
+
270
+ /** Zahlen rechts, alles andere links — wie in Tabellen üblich. */
271
+ function alignOf(bone: BoneDescription): "start" | "end" {
272
+ return boneFamily(bone.type) === "numeric" && !bone.multiple ? "end" : "start"
273
+ }
274
+
275
+ /**
276
+ * Spalten aus einer ViUR-Struktur.
277
+ *
278
+ * Sortierbar wird eine Spalte nur, wenn der Bone `indexed` ist und weder
279
+ * `multiple` noch mehrsprachig — nach allem anderen kann der Server nicht
280
+ * ordnen. Suche und Spaltenfilter bleiben aus: die macht bei einer Liste der
281
+ * Server, der Aufrufer verdrahtet sie über die Ereignisse der Tabelle.
282
+ */
283
+ export function columnsFromStructure<R extends Row = Row>(
284
+ structure: BoneStructure | null | undefined,
285
+ options: ColumnsFromStructureOptions<R> = {}
286
+ ): DataTableColumn<R>[] {
287
+ const bones = structure ?? {}
288
+ const exclude = new Set(options.exclude ?? [])
289
+ const names = options.bones ?? Object.keys(bones)
290
+
291
+ const columns: DataTableColumn<R>[] = []
292
+ for (const name of names) {
293
+ const bone = bones[name]
294
+ if (!bone || exclude.has(name)) continue
295
+ if (bone.visible === false) continue
296
+ if (HIDDEN_TYPES.includes(boneFamily(bone.type))) continue
297
+
298
+ // Server-Modus: nur indizierte Bones sind sortierbar; lokal (alles
299
+ // geladen) darf nach jeder Spalte sortiert, gesucht und gefiltert werden.
300
+ const sortable = options.local === true || (bone.indexed === true && !bone.multiple && !bone.languages?.length)
301
+ const column: DataTableColumn<R> = {
302
+ field: name,
303
+ label: bone.descr ?? name,
304
+ align: alignOf(bone),
305
+ sortable,
306
+ searchable: options.local === true,
307
+ filterable: false,
308
+ formatter: (value) => boneText(value, bone, options as ColumnsFromStructureOptions),
309
+ minWidth: 120,
310
+ flex: 1,
311
+ }
312
+ // Mehrsprachig: nach der ANGEZEIGTEN Sprache sortieren (siehe oben).
313
+ if (bone.languages?.length) {
314
+ const languages = bone.languages
315
+ column.comparator = (a, b) =>
316
+ compareValues(
317
+ firstValueOf(languageSortValue(a, languages, options as ColumnsFromStructureOptions)),
318
+ firstValueOf(languageSortValue(b, languages, options as ColumnsFromStructureOptions)),
319
+ "auto"
320
+ )
321
+ }
322
+
323
+ // Reine selects und bools als farbige Plaketten (siehe badgesForBone).
324
+ const badge = badgesForBone(bone, options as ColumnsFromStructureOptions)
325
+ if (badge) column.badge = (value) => badge(value)
326
+ if (options.local === true) Object.assign(column, localFilterFor(bone, options as ColumnsFromStructureOptions))
327
+ columns.push({ ...column, ...(options.overrides?.[name] ?? {}) })
328
+ }
329
+ return columns
330
+ }