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,271 @@
1
+ <template>
2
+ <Browser
3
+ ref="browserEl"
4
+ v-model:location="location"
5
+ v-model:selected-keys="selectedKeys"
6
+ :root-nodes="rootNodeList"
7
+ :rows="rows"
8
+ :columns="effectiveColumns"
9
+ row-key="key"
10
+ :row-kind="rowKind"
11
+ :load-node-children="loadNodeChildren"
12
+ :order-field="orderField"
13
+ :loading="tree.isFetching.value"
14
+ :uploads="uploads"
15
+ :label="module"
16
+ :has-more="tree.hasMore.value"
17
+ :table-props="mergedTableProps"
18
+ @open="$emit('open', $event)"
19
+ @navigate="onNavigate"
20
+ @move="onMove"
21
+ @reorder="onReorder"
22
+ @upload="onUpload"
23
+ @load-more="onLoadMore"
24
+ @page-change="onPageSizeChange"
25
+ >
26
+ <template
27
+ v-for="(_, slotName) in $slots"
28
+ #[slotName]="scope"
29
+ >
30
+ <slot
31
+ :name="slotName"
32
+ v-bind="scope"
33
+ ></slot>
34
+ </template>
35
+ </Browser>
36
+ </template>
37
+
38
+ <script setup>
39
+ import { ref, computed, watch } from "vue"
40
+ import Browser from "./Browser.vue"
41
+ import { useTreeFetch, useListFetch, useListRootNodesFetch, useMoveFetch, uploadFile } from "../../request/index.js"
42
+ import { columnsFromStructure } from "../DataTable/viurColumns"
43
+
44
+ // Dünner ViUR-Wrapper um den generischen Browser: `module` rein, fertig.
45
+ // Rootnodes über /listRootNodes, Inhalt des Ortes über useTreeFetch (Nodes +
46
+ // Leaves), Baum-Ebenen über list(skelType=node), Verschieben über /move,
47
+ // Uploads über uploadFile. Gespeichert wird pro Zeile; die Ansicht verschiebt
48
+ // der Browser lokal — nachgeladen wird nur der Ort selbst nach Uploads.
49
+
50
+ const props = defineProps({
51
+ module: { type: String, required: true },
52
+ renderer: { type: String, default: null },
53
+ /** Fester Rootnode; ohne ihn wird /listRootNodes befragt. */
54
+ rootNode: { type: [String, Object], default: null },
55
+ /** Spalten; ohne Angabe aus der viewLeafSkel-Struktur abgeleitet. */
56
+ columns: { type: Array, default: null },
57
+ /** Nur diese Bones als Spalten (bei abgeleiteten Spalten). */
58
+ bones: { type: Array, default: null },
59
+ /**
60
+ * Anzeigesprache mehrsprachiger Bones ("de", "en" oder "*" für alle mit der
61
+ * primären vorn) — bestimmt Anzeige UND lokale Sortierung der Spalten.
62
+ */
63
+ language: { type: String, default: null },
64
+ primaryLanguage: { type: String, default: null },
65
+ params: { type: Object, default: () => ({}) },
66
+ orderField: { type: String, default: "sortindex" },
67
+ uploads: { type: Boolean, default: false },
68
+ tableProps: { type: Object, default: () => ({}) },
69
+ })
70
+ const emit = defineEmits(["open", "moved", "move-failed", "uploaded", "upload-failed"])
71
+
72
+ const browserEl = ref(null)
73
+ const location = defineModel("location", { type: [String, Number, null], default: null })
74
+ const selectedKeys = defineModel("selectedKeys", { type: Array, default: () => [] })
75
+
76
+ const ctx = computed(() => (props.renderer ? { renderer: props.renderer } : {}))
77
+
78
+ // Seitengröße der Cursor-Leiste = ViUR-Parameter `limit` (core ≥ 3.9; das
79
+ // frühere `amount` gibt es dort nicht mehr). Sie gilt vom ERSTEN Fetch an:
80
+ // eine per tableProps vorgegebene pageSize (oder ein `limit` in den params)
81
+ // bestimmt schon den Initial-Request; sonst 30 (ViUR-Standard). Angezeigt
82
+ // wird immer die tatsächliche Fetch-Größe.
83
+ const limit = ref(Math.min(99, Number(props.params?.limit ?? props.tableProps?.pageSize ?? 30) || 30))
84
+ const baseParams = () => ({ ...props.params, orderby: props.orderField, limit: limit.value })
85
+ // Für jeden Fetch nach dem Setup gilt: IMMER mit dem aktuellen Ort (Rootnode
86
+ // oder navigierter Ordner) als parententry — sonst listet der Server quer
87
+ // über den ganzen Baum.
88
+ const currentParams = () => ({
89
+ ...baseParams(),
90
+ ...(location.value != null ? { parententry: location.value } : {}),
91
+ })
92
+ const mergedTableProps = computed(() => ({
93
+ // Keine Suche im Browser: navigiert wird über Baum und Breadcrumb, gesucht
94
+ // wird in der Listen-Ansicht des Moduls. Wer sie doch will, setzt
95
+ // `tableProps.withSearch` (lokal sinnvoll nur, wenn alles geladen ist:
96
+ // `allLoaded`).
97
+ ...props.tableProps,
98
+ pageSize: limit.value,
99
+ }))
100
+
101
+ // ── Rootnodes ────────────────────────────────────────────────────────────────
102
+ const rootNodesFetch = props.rootNode ? null : useListRootNodesFetch(props.module, "/", {}, ctx.value)
103
+ const rootNodeList = computed(() => {
104
+ if (props.rootNode) {
105
+ return typeof props.rootNode === "string"
106
+ ? [{ key: props.rootNode, name: props.module }]
107
+ : [{ name: props.module, ...props.rootNode }]
108
+ }
109
+ const list = rootNodesFetch?.data?.value
110
+ return Array.isArray(list) ? list.map((entry) => ({ key: entry.key, name: entry.name ?? entry.key })) : []
111
+ })
112
+
113
+ // ── Inhalt des aktuellen Ortes (Nodes + Leaves) ───────────────────────────────
114
+ const tree = useTreeFetch(props.module, {
115
+ params: baseParams(),
116
+ parententry: location.value ?? undefined,
117
+ ...(props.renderer ? { renderer: props.renderer } : {}),
118
+ immediate: false,
119
+ })
120
+ const rows = computed(() => tree.data.value)
121
+ const rowKind = (row) => (row?.skelType === "node" ? "node" : "leaf")
122
+
123
+ // Ohne expliziten Ort ist die ROOTNODE der Ort: sobald sie bekannt ist, wird
124
+ // sie zum Standard-`parententry` für alles — Listen-Fetch, Drops auf die
125
+ // freie Fläche, Reorder und Uploads. Gefetcht wird nie ohne parententry.
126
+ watch(
127
+ rootNodeList,
128
+ (roots) => {
129
+ if (location.value == null && roots.length) location.value = roots[0].key
130
+ },
131
+ { immediate: true }
132
+ )
133
+
134
+ watch(
135
+ location,
136
+ (parent) => {
137
+ if (parent != null) void tree.open(parent).catch(() => null)
138
+ },
139
+ { immediate: true }
140
+ )
141
+
142
+ // Alles geladen (tree.complete)? Dann laufen Sortierung, Suche und
143
+ // Spaltenfilter nur noch LOKAL auf dem vollständigen Datensatz.
144
+ const allLoaded = computed(() => tree.complete.value)
145
+
146
+ const effectiveColumns = computed(() => {
147
+ if (props.columns) return props.columns
148
+ const structure = tree.structure.value?.leaf ?? tree.structure.value?.node ?? {}
149
+ const derived = columnsFromStructure(structure, {
150
+ ...(props.bones ? { bones: props.bones } : {}),
151
+ ...(props.language ? { language: props.language } : {}),
152
+ ...(props.primaryLanguage ? { primaryLanguage: props.primaryLanguage } : {}),
153
+ local: allLoaded.value,
154
+ })
155
+ return derived.length ? derived : [{ id: "name", label: "Name" }]
156
+ })
157
+
158
+ function onNavigate() {
159
+ selectedKeys.value = []
160
+ }
161
+
162
+ // ── Cursor-Nachladen: 1 / 5 / 10 Seiten oder alles ───────────────────────────
163
+ async function onLoadMore({ pages }) {
164
+ if (pages === "all") {
165
+ await tree.nextAll().catch(() => null)
166
+ return
167
+ }
168
+ let remaining = pages
169
+ while (remaining-- > 0 && tree.hasMore.value) {
170
+ await tree.next().catch(() => null)
171
+ }
172
+ }
173
+
174
+ /** Neue Seitengröße: aktuellen Ort mit geändertem `limit` neu holen. */
175
+ function onPageSizeChange({ pageSize }) {
176
+ if (!pageSize || pageSize === limit.value) return
177
+ limit.value = Math.min(99, pageSize)
178
+ void tree.filter(currentParams()).catch(() => null)
179
+ }
180
+
181
+ // ── Verschieben (Drop auf Baum/Ordner) & Umsortieren ─────────────────────────
182
+ async function saveMove(row, parentNode, sortindex, kind = rowKind(row)) {
183
+ const data = {
184
+ skelType: kind,
185
+ key: row.key,
186
+ parentNode,
187
+ ...(sortindex !== undefined ? { sortindex } : {}),
188
+ }
189
+ const result = useMoveFetch(props.module, "/", data, { ...ctx.value, immediate: false })
190
+ try {
191
+ await result.execute()
192
+ return result.data.value
193
+ } catch (error) {
194
+ // Fehler trägt der Aufrufer aus (onMove/onReorder fangen und melden).
195
+ throw error
196
+ }
197
+ }
198
+
199
+ async function onMove({ rows: movedRows, target, keys, source }) {
200
+ try {
201
+ for (const row of movedRows) {
202
+ // Aus dem BAUM kommen BrowserNodes ({ key, name, data }) — das Skel
203
+ // steckt in `data`, und ein Baum-Eintrag ist immer ein Node. Aus der
204
+ // LISTE kommen Skels mit skelType.
205
+ const raw = row?.data ?? row
206
+ await saveMove(raw, target, undefined, source === "tree" ? "node" : rowKind(raw))
207
+ }
208
+ emit("moved", { keys, target })
209
+ // Ansicht (Liste UND Baum) ist bereits lokal verschoben — kein Reload.
210
+ } catch (error) {
211
+ emit("move-failed", { keys, target, error })
212
+ // Der lokale Stand (Liste UND Baum) ist dann falsch — einmal frisch holen.
213
+ void refresh().catch(() => null)
214
+ }
215
+ }
216
+
217
+ async function onReorder({ row, order, parent, parentChanged, key }) {
218
+ try {
219
+ await saveMove(row, parent ?? location.value, order)
220
+ emit("moved", { keys: [key], target: parent ?? location.value, parentChanged })
221
+ } catch (error) {
222
+ emit("move-failed", { keys: [key], target: parent, error })
223
+ void refresh().catch(() => null)
224
+ }
225
+ }
226
+
227
+ // ── Uploads ───────────────────────────────────────────────────────────────────
228
+ async function onUpload({ files, target }) {
229
+ const results = []
230
+ for (const file of files) {
231
+ try {
232
+ results.push(await uploadFile(file, { target: target ?? undefined, renderer: props.renderer ?? undefined }))
233
+ } catch (error) {
234
+ emit("upload-failed", { file, error })
235
+ }
236
+ }
237
+ if (results.length) {
238
+ emit("uploaded", { files: results, target })
239
+ // Neue Einträge gibt es nur vom Server — den aktuellen Ort einmal holen.
240
+ void tree.execute().catch(() => null)
241
+ }
242
+ }
243
+
244
+ // ── Baum-Ebenen (nur Nodes) ───────────────────────────────────────────────────
245
+ async function loadNodeChildren(node) {
246
+ const list = useListFetch(props.module, {
247
+ // Baum-Ebenen sind Navigation: möglichst vollständig (ViUR-Maximum 99).
248
+ params: { skelType: "node", parententry: node.key, orderby: props.orderField, limit: 99 },
249
+ ...(props.renderer ? { renderer: props.renderer } : {}),
250
+ immediate: false,
251
+ })
252
+ await list.execute().catch(() => null)
253
+ return list.data.value.map((skel) => ({
254
+ key: skel.key,
255
+ name: typeof skel.name === "string" ? skel.name : (skel.name?.name ?? skel.key),
256
+ data: skel,
257
+ }))
258
+ }
259
+
260
+ /**
261
+ * Alles neu laden: die Liste des aktuellen Ortes UND den Navigations-Baum.
262
+ * Der Baum verwirft seine geladenen Ebenen und klappt die Rootnodes wieder
263
+ * auf (frisch vom Server); offene Unterebenen laden beim Aufklappen neu.
264
+ */
265
+ function refresh() {
266
+ browserEl.value?.tree?.reset?.()
267
+ return tree.execute()
268
+ }
269
+
270
+ defineExpose({ browser: browserEl, tree, refresh })
271
+ </script>
@@ -0,0 +1,134 @@
1
+ import { reactive } from "vue"
2
+ import type { InjectionKey } from "vue"
3
+ import type { RowKey } from "../DataTable/types"
4
+
5
+ // --------------------------------------------------------------------------- //
6
+ // Drag-Session — gemeinsames Ziehen zwischen Panels (Liste ↔ Baum) //
7
+ // --------------------------------------------------------------------------- //
8
+ //
9
+ // Die DataTable zieht Zeilen mit Zeiger-Ereignissen (kein HTML5-D&D). Damit
10
+ // eine Zeile auch AUSSERHALB der Tabelle fallen gelassen werden kann (auf einen
11
+ // Ordner im Navigations-Baum oder eine Ordnerzeile der Liste), stellt der
12
+ // Browser diese Session per provide() bereit:
13
+ //
14
+ // • Die QUELLE (DataTable) meldet Beginn/Bewegung/Ende des Zugs.
15
+ // • Jedes PANEL registriert sich als Container mit einer `targetFor`-Funktion,
16
+ // die ein DOM-Element unter dem Zeiger in ein Drop-Ziel übersetzt (oder
17
+ // null, wenn dort nichts fallen gelassen werden kann).
18
+ // • Beim Loslassen über einem Ziel liefert `drop()` Nutzlast + Ziel an den
19
+ // Halter der Session (Browser) — die Quelle überspringt dann ihre interne
20
+ // Verschiebung.
21
+ //
22
+ // Die Session ist bewusst DOM-basiert (elementFromPoint statt Ziel-Rechtecke
23
+ // vorab zu vermessen): Panels scrollen und virtualisieren unabhängig.
24
+
25
+ export interface DragPayload<R = Record<string, unknown>> {
26
+ keys: RowKey[]
27
+ rows: R[]
28
+ /** Container-Id der Quelle (z. B. "list"). */
29
+ source: string
30
+ }
31
+
32
+ export interface DropTarget {
33
+ /** Container-Id des Panels, in dem das Ziel liegt. */
34
+ container: string
35
+ /** Schlüssel des Zielknotens; null = Wurzelebene des Containers. */
36
+ key: RowKey | null
37
+ mode: "inside" | "before" | "after"
38
+ }
39
+
40
+ export interface DragContainer {
41
+ id: string
42
+ /** Wurzelelement des Panels — nur Treffer darin zählen. */
43
+ el: () => HTMLElement | null
44
+ /**
45
+ * Element unter dem Zeiger → Drop-Ziel (oder null). `y`/`x` erlauben
46
+ * Zonen-Entscheidungen (before/inside/after) anhand der Trefferposition.
47
+ */
48
+ targetFor: (hit: Element, x: number, y: number) => DropTarget | null
49
+ }
50
+
51
+ export interface DragSession<R = Record<string, unknown>> {
52
+ state: {
53
+ active: boolean
54
+ payload: DragPayload<R> | null
55
+ over: DropTarget | null
56
+ }
57
+ register(container: DragContainer): () => void
58
+ start(payload: DragPayload<R>): void
59
+ /** Hit-Test unter (x, y); `hint` übergeht elementFromPoint (Tests). */
60
+ update(x: number, y: number, hint?: Element | null): DropTarget | null
61
+ /** Ziel eingelöst: liefert Nutzlast+Ziel und beendet die Session. */
62
+ drop(): { payload: DragPayload<R>; target: DropTarget } | null
63
+ cancel(): void
64
+ }
65
+
66
+ export const DRAG_SESSION: InjectionKey<DragSession> = Symbol("viur-browser-drag-session")
67
+
68
+ export function createDragSession<R = Record<string, unknown>>(
69
+ onDrop?: (payload: DragPayload<R>, target: DropTarget) => void
70
+ ): DragSession<R> {
71
+ const containers = new Map<string, DragContainer>()
72
+ // `reactive` entpackt generische Typen (R wird zu UnwrapRefSimple<R>), womit
73
+ // der Zustand nicht mehr auf DragSession<R> passt. Die Nutzlast ist reine
74
+ // Datenweitergabe — deshalb hier die Form festschreiben.
75
+ const state = reactive({
76
+ active: false,
77
+ payload: null as DragPayload<R> | null,
78
+ over: null as DropTarget | null,
79
+ }) as DragSession<R>["state"]
80
+
81
+ function register(container: DragContainer): () => void {
82
+ containers.set(container.id, container)
83
+ return () => containers.delete(container.id)
84
+ }
85
+
86
+ function start(payload: DragPayload<R>): void {
87
+ state.active = true
88
+ state.payload = payload as typeof state.payload
89
+ state.over = null
90
+ }
91
+
92
+ function update(x: number, y: number, hint?: Element | null): DropTarget | null {
93
+ if (!state.active) return null
94
+ const hit = hint !== undefined ? hint : (document.elementFromPoint(x, y) ?? null)
95
+ if (!hit) {
96
+ state.over = null
97
+ return null
98
+ }
99
+ for (const container of containers.values()) {
100
+ const root = container.el()
101
+ if (!root || !root.contains(hit)) continue
102
+ state.over = container.targetFor(hit, x, y)
103
+ return state.over
104
+ }
105
+ state.over = null
106
+ return null
107
+ }
108
+
109
+ function drop(): { payload: DragPayload<R>; target: DropTarget } | null {
110
+ const payload = state.payload
111
+ const target = state.over
112
+ cancel()
113
+ if (!payload || !target) return null
114
+ onDrop?.(payload, target)
115
+ return { payload, target }
116
+ }
117
+
118
+ function cancel(): void {
119
+ state.active = false
120
+ state.payload = null
121
+ state.over = null
122
+ }
123
+
124
+ return { state, register, start, update, drop, cancel }
125
+ }
126
+
127
+ /** Zonen-Helfer: Position im Element → before / inside / after. */
128
+ export function dropModeFor(hitRect: DOMRect, y: number, insideOnly = false): "inside" | "before" | "after" {
129
+ if (insideOnly) return "inside"
130
+ const offset = (y - hitRect.top) / Math.max(1, hitRect.height)
131
+ if (offset < 0.25) return "before"
132
+ if (offset > 0.75) return "after"
133
+ return "inside"
134
+ }
@@ -0,0 +1,14 @@
1
+ // Öffentliche Oberfläche des Browser-Widgets.
2
+ export { default as Browser } from "./Browser.vue"
3
+ export { default as ViurBrowser } from "./ViurBrowser.vue"
4
+ export { default as TreeNav } from "./TreeNav.vue"
5
+ export {
6
+ createDragSession,
7
+ dropModeFor,
8
+ DRAG_SESSION,
9
+ type DragSession,
10
+ type DragPayload,
11
+ type DragContainer,
12
+ type DropTarget,
13
+ } from "./dragSession"
14
+ export type { BrowserNode, BrowserMovePayload, BrowserUploadPayload } from "./types"
@@ -0,0 +1,30 @@
1
+ import type { RowKey } from "../DataTable/types"
2
+
3
+ /** Knoten des Navigations-Baums (und Ordner in der Liste). */
4
+ export interface BrowserNode {
5
+ key: RowKey
6
+ name: string
7
+ icon?: string | null
8
+ /** false = sicher keine Kinder → Pfeil entfällt. */
9
+ hasChildren?: boolean
10
+ /** Beliebige Nutzdaten (z. B. das Skel) — der Browser liest sie nicht. */
11
+ data?: Record<string, unknown>
12
+ }
13
+
14
+ export interface BrowserMovePayload<R = Record<string, unknown>> {
15
+ /** Zeilenschlüssel in Anzeige-Reihenfolge. */
16
+ keys: RowKey[]
17
+ rows: R[]
18
+ /** Zielknoten (null = Wurzel des aktuellen Rootnodes). */
19
+ target: RowKey | null
20
+ /** "inside" (in einen Ordner) oder before/after (Reihenfolge in der Liste). */
21
+ mode: "inside" | "before" | "after"
22
+ /** Quelle des Zugs: Liste oder Baum. */
23
+ source: "list" | "tree"
24
+ }
25
+
26
+ export interface BrowserUploadPayload {
27
+ files: File[]
28
+ /** Zielknoten (null = aktuelle Ebene ist die Wurzel). */
29
+ target: RowKey | null
30
+ }
@@ -0,0 +1,225 @@
1
+ <template>
2
+ <!-- Inhalt des Filter-Popovers einer Spalte. Welche Steuerelemente erscheinen,
3
+ entscheidet der filterType; die Werte für set-Filter kommen aus den
4
+ Facetten (oder aus column.def.filterOptions, z. B. im Server-Modus). -->
5
+ <div class="vi-dtf wa-stack wa-gap-s">
6
+ <span class="vi-dtf-title">{{ column.label }}</span>
7
+
8
+ <template v-if="state.kind === 'range'">
9
+ <div class="wa-cluster wa-gap-2xs wa-flex-nowrap">
10
+ <wa-input
11
+ class="vi-dtf-input"
12
+ size="small"
13
+ appearance="outlined"
14
+ :type="state.inputType"
15
+ :placeholder="t('dataTable.filterFrom')"
16
+ :value="state.from"
17
+ @input="setRange(0, ($event.target as HTMLInputElement).value)"
18
+ ></wa-input>
19
+ <wa-input
20
+ class="vi-dtf-input"
21
+ size="small"
22
+ appearance="outlined"
23
+ :type="state.inputType"
24
+ :placeholder="t('dataTable.filterTo')"
25
+ :value="state.to"
26
+ @input="setRange(1, ($event.target as HTMLInputElement).value)"
27
+ ></wa-input>
28
+ </div>
29
+ </template>
30
+
31
+ <template v-else-if="state.kind === 'set'">
32
+ <wa-input
33
+ v-if="state.options.length > 10"
34
+ class="vi-dtf-search"
35
+ size="small"
36
+ appearance="outlined"
37
+ :placeholder="t('dataTable.searchValues')"
38
+ :value="state.optionQuery"
39
+ @input="state.optionQuery = ($event.target as HTMLInputElement).value"
40
+ >
41
+ <wa-icon
42
+ slot="start"
43
+ name="magnifying-glass"
44
+ ></wa-icon>
45
+ </wa-input>
46
+ <div class="vi-dtf-options wa-stack wa-gap-2xs">
47
+ <label
48
+ v-for="option in state.filteredOptions"
49
+ :key="option.value"
50
+ class="vi-dtf-option wa-flank wa-flex-nowrap wa-gap-xs"
51
+ >
52
+ <input
53
+ class="vi-dtf-check"
54
+ type="checkbox"
55
+ :value="option.value"
56
+ :checked="state.selected.includes(option.value)"
57
+ @change="toggleOption(option.value)"
58
+ />
59
+ <span class="vi-dtf-option-label wa-split wa-flex-nowrap wa-gap-xs">
60
+ <span class="wa-ellipsis">{{ option.label ?? option.value }}</span>
61
+ <span
62
+ v-if="option.count !== undefined"
63
+ class="vi-dtf-count"
64
+ >
65
+ {{ option.count }}
66
+ </span>
67
+ </span>
68
+ </label>
69
+ <span
70
+ v-if="!state.filteredOptions.length"
71
+ class="vi-dtf-empty"
72
+ >
73
+ Keine Werte
74
+ </span>
75
+ </div>
76
+ </template>
77
+
78
+ <template v-else>
79
+ <wa-input
80
+ class="vi-dtf-input"
81
+ size="small"
82
+ appearance="outlined"
83
+ :placeholder="state.kind === 'equals' ? 'genau …' : 'enthält …'"
84
+ :value="state.text"
85
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value || undefined)"
86
+ ></wa-input>
87
+ </template>
88
+
89
+ <div class="wa-cluster wa-gap-2xs">
90
+ <wa-button
91
+ class="vi-dtf-clear"
92
+ size="small"
93
+ appearance="outlined"
94
+ @click="clear"
95
+ >
96
+ Leeren
97
+ </wa-button>
98
+ </div>
99
+ </div>
100
+ </template>
101
+
102
+ <script setup lang="ts">
103
+ import { useI18n } from "vue-i18n"
104
+ import { computed, reactive } from "vue"
105
+ import type { ColumnFacets, FilterOption, ResolvedColumn } from "./types"
106
+
107
+ const { t } = useI18n()
108
+
109
+ const props = defineProps<{
110
+ column: ResolvedColumn
111
+ facets?: ColumnFacets
112
+ modelValue?: unknown
113
+ }>()
114
+
115
+ const emit = defineEmits<{ "update:modelValue": [value: unknown] }>()
116
+
117
+ const state = reactive({
118
+ optionQuery: "",
119
+
120
+ kind: computed((): "text" | "equals" | "range" | "set" => {
121
+ switch (props.column.filterType) {
122
+ case "number-range":
123
+ case "date-range":
124
+ return "range"
125
+ case "set":
126
+ case "includes-any":
127
+ case "includes-all":
128
+ return "set"
129
+ case "equals":
130
+ return "equals"
131
+ default:
132
+ return "text"
133
+ }
134
+ }),
135
+ inputType: computed((): string => (props.column.filterType === "date-range" ? "date" : "number")),
136
+ text: computed((): string => (props.modelValue === undefined ? "" : String(props.modelValue))),
137
+ range: computed((): [unknown, unknown] =>
138
+ Array.isArray(props.modelValue) ? (props.modelValue as [unknown, unknown]) : [undefined, undefined]
139
+ ),
140
+ from: computed((): string => valueToText(state.range[0])),
141
+ to: computed((): string => valueToText(state.range[1])),
142
+ selected: computed((): string[] =>
143
+ Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map((item) => String(item)) : []
144
+ ),
145
+ options: computed((): FilterOption[] => {
146
+ if (props.column.def.filterOptions) return props.column.def.filterOptions
147
+ return (props.facets?.values ?? []).map((facet) => ({
148
+ value: facet.value,
149
+ count: facet.count,
150
+ }))
151
+ }),
152
+ filteredOptions: computed((): FilterOption[] => {
153
+ const query = state.optionQuery.trim().toLowerCase()
154
+ if (!query) return state.options
155
+ return state.options.filter((option) =>
156
+ String(option.label ?? option.value)
157
+ .toLowerCase()
158
+ .includes(query)
159
+ )
160
+ }),
161
+ })
162
+
163
+ function valueToText(value: unknown): string {
164
+ return value === undefined || value === null ? "" : String(value)
165
+ }
166
+
167
+ function setRange(index: 0 | 1, raw: string): void {
168
+ const next: [unknown, unknown] = [...state.range] as [unknown, unknown]
169
+ next[index] = raw === "" ? undefined : raw
170
+ emit("update:modelValue", next[0] === undefined && next[1] === undefined ? undefined : next)
171
+ }
172
+
173
+ function toggleOption(value: string): void {
174
+ const selected = new Set(state.selected)
175
+ if (selected.has(value)) selected.delete(value)
176
+ else selected.add(value)
177
+ const next = [...selected]
178
+ emit("update:modelValue", next.length ? next : undefined)
179
+ }
180
+
181
+ function clear(): void {
182
+ state.optionQuery = ""
183
+ emit("update:modelValue", undefined)
184
+ }
185
+ </script>
186
+
187
+ <style scoped>
188
+ .vi-dtf {
189
+ min-inline-size: 15rem;
190
+ max-inline-size: 20rem;
191
+ }
192
+ .vi-dtf-title {
193
+ font-size: var(--wa-font-size-s);
194
+ font-weight: 700;
195
+ }
196
+ .vi-dtf-input,
197
+ .vi-dtf-search {
198
+ flex: 1 1 auto;
199
+ min-inline-size: 0;
200
+ width: 100%;
201
+ }
202
+ .vi-dtf-options {
203
+ max-block-size: 14rem;
204
+ overflow: auto;
205
+ }
206
+ .vi-dtf-option {
207
+ font-size: var(--wa-font-size-s);
208
+ cursor: pointer;
209
+ }
210
+ .vi-dtf-option-label {
211
+ min-inline-size: 0;
212
+ flex: 1 1 auto;
213
+ }
214
+ .vi-dtf-count {
215
+ color: var(--wa-color-text-quiet);
216
+ font-variant-numeric: tabular-nums;
217
+ }
218
+ .vi-dtf-empty,
219
+ .vi-dtf-count {
220
+ font-size: var(--wa-font-size-xs);
221
+ }
222
+ .vi-dtf-empty {
223
+ color: var(--wa-color-text-quiet);
224
+ }
225
+ </style>