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