@dnax/ui 0.0.28 → 0.0.29

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.
@@ -0,0 +1,387 @@
1
+ <script lang="ts">
2
+ // QInteract — conteneur d'éléments positionnables (drag & resize), pour
3
+ // construire des dashboards / tableaux de bord type Power BI.
4
+ // Chaque élément : { id, x, y, w, h } en pixels, plus toutes les données
5
+ // métier que vous voulez (title, type de widget…). v-model:items reçoit les
6
+ // nouvelles positions après chaque interaction ; chaque drag / resize émet
7
+ // aussi ses événements dédiés.
8
+ export interface QInteractItem {
9
+ /** Identifiant unique de l'élément */
10
+ id: string | number
11
+ /** Position gauche (px) */
12
+ x: number
13
+ /** Position haut (px) */
14
+ y: number
15
+ /** Largeur (px) */
16
+ w: number
17
+ /** Hauteur (px) */
18
+ h: number
19
+ /** Données métier libres (title, type, …) */
20
+ [key: string]: any
21
+ }
22
+
23
+ export interface QInteractLayout {
24
+ /** Position en pixels */
25
+ x: number
26
+ y: number
27
+ /** Position exprimée en colonnes / rangées pour un damier de `columns` colonnes */
28
+ column: number
29
+ row: number
30
+ spanColumns: number
31
+ spanRows: number
32
+ }
33
+ </script>
34
+
35
+ <script setup lang="ts">
36
+ import { computed, ref, watch } from "vue"
37
+ import { Icon } from "@iconify/vue"
38
+ import { cn } from "../lib/utils"
39
+
40
+ interface Props {
41
+ /** Éléments (v-model:items) : [{ id, x, y, w, h, … }] */
42
+ items?: QInteractItem[]
43
+ /** Élément sélectionné (v-model:selected) : son id */
44
+ selected?: string | number | null
45
+ /** Hauteur du conteneur (px ou CSS) */
46
+ height?: string | number
47
+ /** Largeur du conteneur (px ou CSS) */
48
+ width?: string | number
49
+ /** Autorise drag & resize (défaut true) */
50
+ interactive?: boolean
51
+ /** Lecture seule : drag & resize désactivés, poignées masquées */
52
+ readonly?: boolean
53
+ /** Uniquement draggable depuis la barre d'en-tête (sinon toute la carte) */
54
+ handle?: boolean
55
+ /** Pas de grille (snap) en px — 0 désactive */
56
+ snap?: number
57
+ /** Marge intérieure minimale (px) */
58
+ padding?: number
59
+ /** Espacement entre les éléments (px) — évite qu'ils soient collés */
60
+ gap?: number
61
+ /** Largeur minimale d'un élément (px) */
62
+ minWidth?: number
63
+ /** Hauteur minimale d'un élément (px) */
64
+ minHeight?: number
65
+ /** Affiche les lignes de la grille */
66
+ showGrid?: boolean
67
+ /** Empêche le chevauchement des éléments (drag/resize bloqués sur une zone occupée) */
68
+ collision?: boolean
69
+ /** Hauteur réduite (curseurs + barre) */
70
+ dense?: boolean
71
+ dark?: boolean
72
+ /** Clic sur un élément le passe au premier plan */
73
+ bringToFront?: boolean
74
+ }
75
+
76
+ const props = withDefaults(defineProps<Props>(), {
77
+ items: () => [],
78
+ selected: null,
79
+ height: 420,
80
+ width: "100%",
81
+ interactive: true,
82
+ readonly: false,
83
+ handle: false,
84
+ snap: 8,
85
+ padding: 10,
86
+ gap: 0,
87
+ minWidth: 60,
88
+ minHeight: 40,
89
+ showGrid: false,
90
+ collision: false,
91
+ dense: false,
92
+ dark: false,
93
+ bringToFront: true,
94
+ })
95
+
96
+ const emit = defineEmits<{
97
+ "update:items": [value: QInteractItem[]]
98
+ "update:selected": [value: string | number | null]
99
+ select: [item: QInteractItem]
100
+ "drag-start": [item: QInteractItem]
101
+ "drag-move": [item: QInteractItem]
102
+ "drag-end": [item: QInteractItem]
103
+ "resize-start": [item: QInteractItem]
104
+ "resize-move": [item: QInteractItem]
105
+ "resize-end": [item: QInteractItem]
106
+ }>()
107
+
108
+ const local = ref<QInteractItem[]>([])
109
+ watch(
110
+ () => props.items,
111
+ (v) => {
112
+ if (v && v !== local.value) local.value = v.map((it) => ({ ...it }))
113
+ },
114
+ { immediate: true },
115
+ )
116
+ const push = (next: QInteractItem[]) => {
117
+ local.value = next
118
+ emit("update:items", next)
119
+ }
120
+
121
+ const sel = ref<string | number | null>(props.selected)
122
+ watch(
123
+ () => props.selected,
124
+ (v) => {
125
+ sel.value = v
126
+ },
127
+ )
128
+ const select = (item: QInteractItem) => {
129
+ sel.value = item.id
130
+ emit("update:selected", item.id)
131
+ emit("select", item)
132
+ }
133
+ const byId = (id: string | number) => local.value.find((it) => String(it.id) === String(id))
134
+ const indexOf = (id: string | number) => local.value.findIndex((it) => String(it.id) === String(id))
135
+
136
+ // Met à jour un élément (fusionne, ne remplace pas l'objet pendant le drag)
137
+ const patchItem = (id: string | number, patch: Partial<QInteractItem>, live = false) => {
138
+ const i = indexOf(id)
139
+ if (i < 0) return
140
+ const merged = { ...local.value[i], ...patch } as QInteractItem
141
+ const next = [...local.value]
142
+ next[i] = merged
143
+ if (live) {
144
+ local.value = next
145
+ } else {
146
+ push(next)
147
+ }
148
+ }
149
+
150
+ const rootEl = ref<HTMLElement | null>(null)
151
+ const sizeStyle = computed<Record<string, string>>(() => ({
152
+ height: typeof props.height === "number" ? props.height + "px" : props.height,
153
+ width: typeof props.width === "number" ? props.width + "px" : props.width,
154
+ }))
155
+ const gridStyle = computed<Record<string, string>>(() => {
156
+ const s = Math.max(1, props.snap || 1)
157
+ return { backgroundSize: s + "px " + s + "px" }
158
+ })
159
+
160
+ // ─── Drag ───
161
+ const drag = ref<{ id: string | number; dx: number; dy: number; sx: number; sy: number; item: QInteractItem } | null>(null)
162
+
163
+ const isHandle = (el: EventTarget | null): boolean => {
164
+ const node = el as HTMLElement | null
165
+ if (!props.handle) return false
166
+ return !!node?.closest?.(".q-interact__handle")
167
+ }
168
+
169
+ const canInteract = () => props.interactive && !props.readonly
170
+ const dragStart = (e: PointerEvent, item: QInteractItem) => {
171
+ if (!canInteract()) return
172
+ if (e.button !== 0) return
173
+ if (props.handle && !isHandle(e.target)) return
174
+ select(item)
175
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
176
+ const root = rootEl.value?.getBoundingClientRect()
177
+ if (!root) return
178
+ drag.value = {
179
+ id: item.id,
180
+ sx: item.x,
181
+ sy: item.y,
182
+ dx: e.clientX - rect.left,
183
+ dy: e.clientY - rect.top,
184
+ item,
185
+ }
186
+ emit("drag-start", item)
187
+ window.addEventListener("pointermove", onDragMove)
188
+ window.addEventListener("pointerup", onDragEnd)
189
+ }
190
+ const onDragMove = (e: PointerEvent) => {
191
+ const d = drag.value
192
+ if (!d || !rootEl.value) return
193
+ const root = rootEl.value.getBoundingClientRect()
194
+ const snap = Math.max(1, props.snap || 1)
195
+ const cur = byId(d.id)
196
+ if (!cur) return
197
+ const maxX = root.width - props.padding - cur.w + (props.gap || 0) / 2
198
+ const maxY = root.height - props.padding - cur.h + (props.gap || 0) / 2
199
+ const nx = Math.round(Math.max(props.padding, Math.min(maxX, e.clientX - root.left - d.dx)) / snap) * snap
200
+ const ny = Math.round(Math.max(props.padding, Math.min(maxY, e.clientY - root.top - d.dy)) / snap) * snap
201
+ // Collision : on ne pose pas l'élément sur une zone occupée
202
+ if (props.collision && collidesWithOthers(d.id, { x: nx, y: ny, w: cur.w, h: cur.h })) {
203
+ if (cur.x !== nx || cur.y !== ny) emit("drag-move", cur)
204
+ return
205
+ }
206
+ patchItem(d.id, { x: nx, y: ny }, true)
207
+ const updated = byId(d.id)
208
+ if (updated) emit("drag-move", updated)
209
+ }
210
+ const onDragEnd = () => {
211
+ window.removeEventListener("pointermove", onDragMove)
212
+ window.removeEventListener("pointerup", onDragEnd)
213
+ const d = drag.value
214
+ drag.value = null
215
+ if (!d) return
216
+ const cur = byId(d.id)
217
+ if (cur) emit("update:items", local.value.map((it) => ({ ...it })))
218
+ if (cur) emit("drag-end", cur)
219
+ }
220
+
221
+ // ─── Resize (poignée bas-droite) ───
222
+ const rs = ref<{ id: string | number; item: QInteractItem } | null>(null)
223
+ const resizeStart = (e: PointerEvent, item: QInteractItem) => {
224
+ if (!canInteract()) return
225
+ e.stopPropagation()
226
+ select(item)
227
+ rs.value = { id: item.id, item }
228
+ emit("resize-start", item)
229
+ window.addEventListener("pointermove", onResizeMove)
230
+ window.addEventListener("pointerup", onResizeEnd)
231
+ }
232
+ const intersectsRect = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) =>
233
+ a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
234
+ const collidesWithOthers = (id: string | number, rect: { x: number; y: number; w: number; h: number }) =>
235
+ props.collision &&
236
+ local.value.some((it) => String(it.id) !== String(id) && intersectsRect(rect, it))
237
+ const onResizeMove = (e: PointerEvent) => {
238
+ const r = rs.value
239
+ if (!r || !rootEl.value) return
240
+ const root = rootEl.value.getBoundingClientRect()
241
+ const base = byId(r.id)
242
+ if (!base) return
243
+ const snap = Math.max(1, props.snap || 1)
244
+ const cur = local.value[indexOf(r.id)]!
245
+ const g = props.gap || 0
246
+ const w = Math.round(Math.min(root.width - props.padding - cur.x + g / 2, Math.max(props.minWidth, e.clientX - root.left - cur.x)) / snap) * snap
247
+ const h = Math.round(Math.min(root.height - props.padding - cur.y + g / 2, Math.max(props.minHeight, e.clientY - root.top - cur.y)) / snap) * snap
248
+ if (collidesWithOthers(r.id, { x: cur.x, y: cur.y, w, h })) {
249
+ if (cur.w !== w || cur.h !== h) emit("resize-move", cur)
250
+ return
251
+ }
252
+ patchItem(r.id, { w, h }, true)
253
+ const updated = byId(r.id)
254
+ if (updated) emit("resize-move", updated)
255
+ }
256
+ const onResizeEnd = () => {
257
+ window.removeEventListener("pointermove", onResizeMove)
258
+ window.removeEventListener("pointerup", onResizeEnd)
259
+ const r = rs.value
260
+ rs.value = null
261
+ if (!r) return
262
+ const cur = byId(r.id)
263
+ if (cur) emit("update:items", local.value.map((it) => ({ ...it })))
264
+ if (cur) emit("resize-end", cur)
265
+ }
266
+
267
+ // ─── API ───
268
+ const itemStyle = (item: QInteractItem) => {
269
+ const g = props.gap || 0
270
+ return {
271
+ left: item.x + g / 2 + "px",
272
+ top: item.y + g / 2 + "px",
273
+ width: Math.max(0, item.w - g) + "px",
274
+ height: Math.max(0, item.h - g) + "px",
275
+ }
276
+ }
277
+ const isSelected = (item: QInteractItem) => String(sel.value) === String(item.id)
278
+
279
+ const bringToFront = (item: QInteractItem) => {
280
+ if (!props.bringToFront) return
281
+ const i = indexOf(item.id)
282
+ if (i < 0) return
283
+ const next = [...local.value]
284
+ const it = next.splice(i, 1)[0] as QInteractItem
285
+ next.push(it)
286
+ push(next)
287
+ }
288
+
289
+ const addItem = (item: QInteractItem) => {
290
+ push([...local.value, { ...item }])
291
+ select(item)
292
+ }
293
+ const removeItem = (id: string | number) => {
294
+ push(local.value.filter((it) => String(it.id) !== String(id)))
295
+ if (String(sel.value) === String(id)) sel.value = null
296
+ }
297
+ const clear = () => push([])
298
+
299
+ /** Positions réelles (racine du conteneur) en px */
300
+ const realPos = (item: QInteractItem) => {
301
+ const root = rootEl.value?.getBoundingClientRect()
302
+ if (!root) return { x: item.x, y: item.y }
303
+ return { x: root.left + item.x, y: root.top + item.y }
304
+ }
305
+
306
+ /**
307
+ * Exprime chaque élément en tuiles pour un damier de `columns` colonnes :
308
+ * { x, y, column, row, spanColumns, spanRows } — utile pour décrire le layout
309
+ * d'un dashboard à reconstruire sur d'autres écrans (Power BI-like).
310
+ */
311
+ const toGrid = (columns = 12): QInteractLayout[] => {
312
+ const root = rootEl.value
313
+ if (!root) return []
314
+ const colW = root.clientWidth / columns
315
+ const rowH = props.snap || 10
316
+ return local.value.map((it) => ({
317
+ x: it.x,
318
+ y: it.y,
319
+ column: Math.round(it.x / colW),
320
+ row: Math.round(it.y / rowH),
321
+ spanColumns: Math.max(1, Math.round(it.w / colW)),
322
+ spanRows: Math.max(1, Math.round(it.h / rowH)),
323
+ }))
324
+ }
325
+
326
+ const rootClasses = computed(() =>
327
+ cn(
328
+ "q-interact",
329
+ props.dense && "q-interact--dense",
330
+ props.dark && "q-interact--dark",
331
+ props.showGrid && "q-interact--grid",
332
+ !props.interactive && "q-interact--static",
333
+ props.readonly && "q-interact--static",
334
+ ),
335
+ )
336
+
337
+ defineExpose({
338
+ select,
339
+ addItem,
340
+ removeItem,
341
+ clear,
342
+ bringToFront,
343
+ getItem: byId,
344
+ getItems: () => local.value.map((it) => ({ ...it })),
345
+ realPos,
346
+ toGrid,
347
+ })
348
+ </script>
349
+
350
+ <template>
351
+ <div ref="rootEl" class="q-interact" :class="rootClasses" :style="[sizeStyle, props.showGrid ? gridStyle : undefined]">
352
+ <template v-for="item in local" :key="item.id">
353
+ <div
354
+ class="q-interact__item"
355
+ :class="{
356
+ 'q-interact__item--active': isSelected(item),
357
+ 'q-interact__item--dragging': drag?.id === item.id,
358
+ 'q-interact__item--resizing': rs?.id === item.id,
359
+ }"
360
+ :style="itemStyle(item)"
361
+ @pointerdown="dragStart($event, item)"
362
+ @click="select(item)"
363
+ @dblclick="bringToFront(item)"
364
+ >
365
+ <div v-if="handle" class="q-interact__handle">
366
+ <slot name="handle" :item="item">
367
+ <span class="q-interact__grip"><Icon icon="lucide:grip-vertical" /></span>
368
+ </slot>
369
+ </div>
370
+ <div class="q-interact__content">
371
+ <slot name="item" :item="item" :index="local.indexOf(item)">
372
+ <div class="q-interact__default">
373
+ {{ item.title ?? String(item.id) }}
374
+ </div>
375
+ </slot>
376
+ </div>
377
+ <span
378
+ v-if="interactive && !readonly"
379
+ class="q-interact__resize"
380
+ title="Resize"
381
+ @pointerdown="resizeStart($event, item)"
382
+ />
383
+ </div>
384
+ </template>
385
+ <slot v-if="!local.length" name="empty" />
386
+ </div>
387
+ </template>