@dnax/ui 0.0.42 → 0.0.44

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.
@@ -393,10 +393,13 @@ const onDocMousedown = (e: MouseEvent) => {
393
393
  }
394
394
 
395
395
  onMounted(() => {
396
- if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
396
+ // Phase de CAPTURE (3e arg = true) : un `@mousedown.stop` plus haut dans l'arbre
397
+ // (contenu de QDialog / QDataGrid…) empêcherait sinon l'événement d'atteindre
398
+ // `document` et le popup ne se fermerait jamais au clic extérieur.
399
+ if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown, true)
397
400
  })
398
401
  onBeforeUnmount(() => {
399
- if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
402
+ if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown, true)
400
403
  window.clearTimeout(swipeCloseTimer)
401
404
  })
402
405
 
@@ -339,10 +339,12 @@ const menuRows = computed<MenuRow[]>(() => {
339
339
  })
340
340
 
341
341
  onMounted(() => {
342
- if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
342
+ // Phase de CAPTURE : sinon un `@mousedown.stop` parent (contenu de QDialog,
343
+ // QDataGrid…) bloque l'événement avant `document` → popup jamais fermé.
344
+ if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown, true)
343
345
  })
344
346
  onBeforeUnmount(() => {
345
- if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
347
+ if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown, true)
346
348
  disableTracking()
347
349
  })
348
350
  </script>
@@ -0,0 +1,533 @@
1
+ <script lang="ts">
2
+ // QChart — graphique ECharts piloté par des **marks** :
3
+ // <q-chart :marks="[{ type: 'bar', data: rows, x: 'mois', y: 'ventes', fill: 'primary' }]" />
4
+ // Une marque est un objet `{ type, data, x, y, fill, … }` ; les canaux acceptent
5
+ // le nom d'un champ, des valeurs explicites ou une fonction d'accès. Types :
6
+ // `line`, `area`, `bar`, `dot`, `text`, `rule`. Voir `lib/chart.ts`.
7
+ //
8
+ // ECharts dessine en canvas → le graphe est monté côté client (onMounted) ; le
9
+ // conteneur, sa hauteur et son `aria-label` sortent dès le prerender.
10
+ </script>
11
+
12
+ <script setup lang="ts">
13
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
14
+ import { chartToECharts, COLOR_TOKENS } from "../lib/chart"
15
+ import { normalizeCssColor } from "../lib/color"
16
+ import { legendPickOf, nextLegendPick, pickFromEvent, sameSelection, tableModel } from "../lib/chart"
17
+ import type { QChartAxis, QChartColumn, QChartLegend, QChartMark, QChartPick } from "../lib/chart"
18
+
19
+ interface Props {
20
+ /** Marques à rendre : `{ type: 'line', data, x, y }`, `{ type: 'bar', … }`… */
21
+ marks?: QChartMark[]
22
+ /** Axe X (type, libellé, bornes, grille) */
23
+ x?: QChartAxis
24
+ /** Axe Y (type, libellé, bornes, grille) */
25
+ y?: QChartAxis
26
+ /** Hauteur du conteneur (nombre = px, sinon valeur CSS) */
27
+ height?: number | string
28
+ /** Titre du graphique */
29
+ title?: string
30
+ /** Palette (tokens `primary`, `secondary`… ou couleurs CSS) des séries non colorées */
31
+ colors?: string[]
32
+ /** Légende : `false` pour la masquer, `true` pour la forcer (défaut : dès qu'une série est
33
+ * nommée), ou `{ position: 'top' | 'bottom' | 'left' | 'right', offset, align }` */
34
+ legend?: boolean | QChartLegend
35
+ /** Info-bulle au survol (défaut : true) */
36
+ tooltip?: boolean
37
+ /** Réaction des marques liées à la sélection : `filter` (défaut) **retire** les données non
38
+ * liées, `dim` les **estompe** (`dimOpacity`) en gardant tout le contexte visible. */
39
+ linkMode?: "filter" | "dim"
40
+ /** Opacité des éléments non sélectionnés en mode `dim` (défaut : 0.25) */
41
+ dimOpacity?: number
42
+ /** **Groupe de liaison** : les `<q-chart>` d'un même groupe se synchronisent
43
+ * (`echarts.connect`) — survol/curseur d'axe, info-bulle, légende, zoom et mise en
44
+ * évidence d'un élément. */
45
+ group?: string
46
+ /** Élément **sélectionné** (`QChartPick`, typiquement le `@pick` d'un autre graphique) :
47
+ * les éléments qui portent ce `name` sont mis en évidence (`highlight`) dans toutes les
48
+ * séries. `null` efface la sélection. */
49
+ selected?: QChartPick | null
50
+ /** Options ECharts brutes, fusionnées en dernier (échappatoire) */
51
+ options?: Record<string, any>
52
+ }
53
+
54
+ const props = withDefaults(defineProps<Props>(), {
55
+ marks: () => [],
56
+ height: 280,
57
+ legend: undefined,
58
+ tooltip: true,
59
+ // mêmes défauts que le traducteur : un composant qui n'est pas renseigné (la marque `table`,
60
+ // rendue en HTML, lit ses props directement) se comporte comme les graphiques.
61
+ linkMode: "filter",
62
+ dimOpacity: 0.25,
63
+ })
64
+
65
+ const emit = defineEmits<{
66
+ /** Émis une fois, avec l'instance ECharts */
67
+ ready: [chart: any]
68
+ /** Clic sur un élément du graphique (point, barre, part, cellule…) ou sur un élément de
69
+ * légende (`legend.action: 'select'`) — payload normalisé. `null` = sélection effacée
70
+ * (re-clic sur l'élément déjà sélectionné). */
71
+ pick: [pick: QChartPick | null]
72
+ /** La sélection vient d'être **effacée par l'utilisateur** — payload : l'élément lâché
73
+ * (`{ name, origin: 'legend', data }`). Émis juste avant le `@pick` à `null`, pour qui a
74
+ * besoin de savoir *ce qui* a été désélectionné (journal, requête, analytics). Jamais émis
75
+ * quand c'est l'application qui remet `selected` à `null` : ce serait un écho. */
76
+ unpick: [pick: QChartPick]
77
+ }>()
78
+
79
+ const el = ref<HTMLElement | null>(null)
80
+ const chart = shallowRef<any>(null)
81
+ let modulePromise: Promise<any> | null = null
82
+ let resizeObserver: ResizeObserver | undefined
83
+ let themeObserver: MutationObserver | undefined
84
+
85
+ /** Charge ECharts (modules enregistrés une seule fois, arbre secoué). */
86
+ const loadECharts = () => {
87
+ modulePromise ??= (async () => {
88
+ const [core, charts, components, renderers] = await Promise.all([
89
+ import("echarts/core"),
90
+ import("echarts/charts"),
91
+ import("echarts/components"),
92
+ import("echarts/renderers"),
93
+ ])
94
+ core.use([
95
+ charts.BarChart,
96
+ charts.LineChart,
97
+ charts.ScatterChart,
98
+ charts.PieChart, // marque `pie` (camembert / anneau)
99
+ charts.HeatmapChart, // marque `heatmap` (carte de chaleur)
100
+ components.GridComponent,
101
+ components.TooltipComponent,
102
+ components.LegendComponent,
103
+ components.MarkLineComponent,
104
+ components.VisualMapComponent, // échelle de couleurs des cartes de chaleur
105
+ renderers.CanvasRenderer,
106
+ ])
107
+ return core
108
+ })()
109
+ return modulePromise
110
+ }
111
+
112
+ /** Canvas 2D de 1×1 px, mémoïsé : sert à **normaliser les couleurs CSS** (cf. `lib/color.ts`). */
113
+ let probeCtx: CanvasRenderingContext2D | null | undefined
114
+ const colorContext = () => {
115
+ if (probeCtx !== undefined) return probeCtx
116
+ if (typeof document === "undefined") return (probeCtx = null)
117
+ const canvas = document.createElement("canvas")
118
+ canvas.width = canvas.height = 1
119
+ probeCtx = canvas.getContext("2d")
120
+ return probeCtx
121
+ }
122
+
123
+ /** Couleurs déjà normalisées (les tokens ne changent pas d'un rendu à l'autre) */
124
+ const colorCache = new Map<string, string>()
125
+
126
+ /** Normalise n'importe quelle couleur CSS (`oklch()`, `color-mix()`, `rgb(0 0 0 / .5)`) en
127
+ * `#rrggbb` / `rgba()`, la seule forme que le parseur de zrender (moteur d'ECharts) sait
128
+ * relire. Sans ça, `liftColor()` — appelé sur l'état de survol — renvoie `undefined` et
129
+ * l'élément survolé devient invisible (cf. `lib/color.ts`).
130
+ * Renvoie `undefined` si la couleur n'est pas reconnue (ou hors navigateur). */
131
+ const normalizeColor = (value: string): string | undefined => {
132
+ if (typeof value !== "string") return undefined
133
+ const cached = colorCache.get(value)
134
+ if (cached !== undefined) return cached
135
+ const out = normalizeCssColor(value, colorContext())
136
+ if (out !== undefined) colorCache.set(value, out)
137
+ return out
138
+ }
139
+
140
+ /** Couleurs lues sur les tokens CSS du conteneur → suit clair/sombre et le thème hôte */
141
+ const themeOf = () => {
142
+ const node = el.value
143
+ if (!node || typeof getComputedStyle !== "function") return { theme: {}, tokens: {} }
144
+ const cs = getComputedStyle(node)
145
+ const read = (name: string) => cs.getPropertyValue(name).trim()
146
+ /** Valeur normalisée d'un token, ou `raw` (déjà lisible) */
147
+ const token = (name: string, fallback = "") => {
148
+ const raw = read(name) || fallback
149
+ return normalizeColor(raw) ?? raw
150
+ }
151
+ const tokens: Record<string, string> = {}
152
+ for (const name of COLOR_TOKENS) {
153
+ const raw = read(`--${name}`)
154
+ if (raw) tokens[name] = normalizeColor(raw) ?? raw
155
+ }
156
+ return {
157
+ theme: {
158
+ text: token("--foreground", "#1d1d1d"),
159
+ muted: token("--muted-foreground", "rgba(0, 0, 0, 0.55)"),
160
+ grid: token("--border", "rgba(0, 0, 0, 0.1)"),
161
+ },
162
+ tokens,
163
+ }
164
+ }
165
+
166
+ const build = () => {
167
+ const { theme, tokens } = themeOf()
168
+ return chartToECharts({
169
+ marks: props.marks,
170
+ x: props.x,
171
+ y: props.y,
172
+ colors: props.colors,
173
+ title: props.title,
174
+ legend: props.legend,
175
+ tooltip: props.tooltip,
176
+ // Sélection partagée : nourrit la jointure des marques qui déclarent `link`
177
+ selection: props.selected ?? null,
178
+ // Ce que deviennent les lignes non liées : retirées (`filter`) ou estompées (`dim`)
179
+ linkMode: props.linkMode,
180
+ dimOpacity: props.dimOpacity,
181
+ options: props.options,
182
+ theme,
183
+ tokens,
184
+ normalizeColor,
185
+ })
186
+ }
187
+
188
+ /** Relie un graphique à son groupe. ECharts veut le groupe **sur l'instance**
189
+ * (`chart.group`) ET le groupe déclaré comme connecté (`echarts.connect`) : l'un sans
190
+ * l'autre ne synchronise rien (vérifié au navigateur). Une fois les deux posés, les actions
191
+ * « partageables » déclenchées dans un graphique sont rejouées sur les autres charts du
192
+ * même groupe. */
193
+ const linkChart = (group: string, instance: any, api: any) => {
194
+ instance.group = group
195
+ api.connect(group)
196
+ }
197
+
198
+ const tableMark = computed(() => (props.marks ?? []).find((mark) => mark.type === "table"))
199
+ const table = computed(() =>
200
+ tableMark.value
201
+ ? tableModel(tableMark.value, props.selected ?? null, (props.linkMode as "filter" | "dim") ?? "filter")
202
+ : null,
203
+ )
204
+
205
+ /** Champ qui porte la clé partagée d'une ligne : le `localField` de `link`, sinon le premier
206
+ * champ de la première colonne. C'est ce qui alimente `@pick`, la sélection et la mise en
207
+ * évidence — une ligne se désigne par la même clé que les autres marques. */
208
+ const rowKey = (row: any) => {
209
+ const link = typeof tableMark.value?.link === "string" ? tableMark.value.link : tableMark.value?.link?.localField
210
+ const field = link ?? table.value?.columns[0]?.field
211
+ return field !== undefined && row ? row[field] : undefined
212
+ }
213
+
214
+ /** Ligne **hors** sélection, en mode `dim` sur une table liée : elle reste affichée, estompée.
215
+ * Même règle que les séries : sans `link` la marque ne réagit pas, et sans sélection rien
216
+ * n'est estompé. */
217
+ const isDimmed = (row: any) =>
218
+ props.linkMode === "dim" && !!tableMark.value?.link && !!props.selected && !isSelected(row)
219
+
220
+ /** La ligne est-elle celle qui est sélectionnée ? (même clé, d'où qu'elle vienne) */
221
+ const isSelected = (row: any) => {
222
+ const key = rowKey(row)
223
+ const selected = props.selected
224
+ return key !== undefined && !!selected && typeof selected === "object" && String(selected.name) === String(key)
225
+ }
226
+
227
+ /** Valeur affichée d'une cellule : `format` de la colonne sinon la valeur telle quelle */
228
+ const cellValue = (column: QChartColumn, row: any) => {
229
+ const value = row?.[column.field]
230
+ const formatted = column.format?.(value, row)
231
+ if (formatted !== undefined) return formatted
232
+ return value === null || value === undefined ? "" : String(value)
233
+ }
234
+
235
+ /** Sélection émise pour un élément — avec la même règle de re-clic que le reste */
236
+ const emitPick = (pick: QChartPick) => {
237
+ if (sameSelection(props.selected, pick)) {
238
+ emit("unpick", pick)
239
+ emit("pick", null)
240
+ return
241
+ }
242
+ emit("pick", pick)
243
+ }
244
+
245
+ /** Clic sur une ligne de table : elle se désigne par sa clé partagée */
246
+ const pickRow = (row: any, dataIndex: number) => {
247
+ const markIndex = (props.marks ?? []).findIndex((mark) => mark.type === "table")
248
+ const name = rowKey(row)
249
+ emitPick({
250
+ ...(name !== undefined ? { name } : {}),
251
+ markIndex,
252
+ markType: "table",
253
+ dataIndex,
254
+ origin: "mark",
255
+ data: row,
256
+ })
257
+ }
258
+
259
+ const render = async () => {
260
+ // Une marque `table` remplace le tracé : on libère l'instance si elle existait
261
+ if (tableMark.value) {
262
+ chart.value?.dispose()
263
+ chart.value = undefined
264
+ return
265
+ }
266
+ if (!el.value || typeof window === "undefined") return
267
+ const echarts = await loadECharts()
268
+ if (!el.value) return
269
+ if (!chart.value) {
270
+ chart.value = echarts.init(el.value)
271
+ if (props.group) linkChart(props.group, chart.value, echarts)
272
+ // Clic sur un élément → `@pick` (payload normalisé, cf. `pickFromEvent`). Re-clic sur
273
+ // l'élément **déjà sélectionné** → désélection, comme pour une entrée de légende : les
274
+ // filtres croisés se relâchent (`@unpick` porte l'élément lâché, `@pick` porte `null`).
275
+ chart.value.on("click", (params: any) => {
276
+ // Le moteur ne transmet que sa forme interne (`data: { value: [catégorie, valeur] }`) :
277
+ // on **restaure la ligne d'origine** depuis `marks`, comme pour un clic de légende — c'est
278
+ // elle qui alimente les jointures `link.foreignField`, et c'est la doc qui l'annonce.
279
+ const engine = pickFromEvent(params)
280
+ if (engine.name === undefined && engine.dataIndex === undefined) return
281
+ const identity = legendPickOf(props.marks, String(engine.name), engine.markIndex)
282
+ const pick: QChartPick = {
283
+ ...engine,
284
+ // `origin` reste celui du moteur (`mark`) : `legendPickOf` ne fait que compléter
285
+ // l'identité — la ligne d'origine, la position dans `marks`, la valeur (pas la paire
286
+ // interne `[catégorie, valeur]`).
287
+ origin: engine.origin ?? identity.origin,
288
+ markIndex: identity.markIndex ?? engine.markIndex,
289
+ markName: identity.markName ?? engine.markName,
290
+ markType: identity.markType ?? engine.markType,
291
+ dataIndex: identity.dataIndex ?? engine.dataIndex,
292
+ data: identity.data ?? engine.data,
293
+ value: identity.value ?? engine.value,
294
+ }
295
+ emitPick(pick)
296
+ })
297
+ // Clic sur la **légende** : avec `legend.action: 'select'`, la légende sert de sélecteur —
298
+ // ECharts a basculé l'élément (il masquerait la part/la série), on le rétablit puis on
299
+ // émet le pick ; un second clic sur le même nom efface la sélection.
300
+ chart.value.on("legendselectchanged", (event: any) => {
301
+ const legend = props.legend && typeof props.legend === "object" ? props.legend : undefined
302
+ if (legend?.action !== "select") return
303
+ const name = event?.name
304
+ if (name === undefined || name === null) return
305
+ // Le moteur a basculé l'entrée : on la rétablit, la sélection est notre affaire
306
+ if (event?.selected?.[name] === false) chart.value?.dispatchAction({ type: "legendSelect", name })
307
+ // Même identité qu'un clic sur un élément : marque (index, nom, type) + donnée (index,
308
+ // ligne, valeur) — le clic de légende ne porte que le nom.
309
+ const entry = legendPickOf(props.marks, name)
310
+ // Re-clic sur le nom courant → `null` (désélection) ; sinon on bascule sur ce nom.
311
+ // La désélection est signalée deux fois : `@unpick` porte l'élément lâché, `@pick`
312
+ // porte l'état (`null`).
313
+ const next = nextLegendPick(props.selected, name, entry.data)
314
+ if (next === null) emit("unpick", entry)
315
+ emit("pick", next === null ? null : entry)
316
+ })
317
+ emit("ready", chart.value)
318
+ }
319
+ chart.value.setOption(build(), { notMerge: true })
320
+ chart.value.resize()
321
+ applySelection()
322
+ }
323
+
324
+ onMounted(async () => {
325
+ await render()
326
+ if (el.value && typeof ResizeObserver !== "undefined") {
327
+ resizeObserver = new ResizeObserver(() => chart.value?.resize())
328
+ resizeObserver.observe(el.value)
329
+ }
330
+ // Le basculement clair/sombre change une classe sur <html> : on relit les tokens
331
+ if (typeof MutationObserver !== "undefined" && typeof document !== "undefined") {
332
+ themeObserver = new MutationObserver(() => {
333
+ chart.value?.setOption(build(), { notMerge: true })
334
+ })
335
+ themeObserver.observe(document.documentElement, {
336
+ attributes: true,
337
+ attributeFilter: ["class", "data-theme"],
338
+ })
339
+ }
340
+ })
341
+
342
+ watch(
343
+ // `selected` en fait partie : la sélection change **l'option** (lignes jointes filtrées ou
344
+ // estompées) — sans lui, les autres graphiques liés ne suivaient pas.
345
+ () => [
346
+ props.marks,
347
+ props.selected,
348
+ props.x,
349
+ props.y,
350
+ props.title,
351
+ props.colors,
352
+ props.legend,
353
+ props.tooltip,
354
+ props.options,
355
+ ],
356
+ async () => {
357
+ // `nextTick` : le template a pu passer d'une table à un tracé (ou l'inverse)
358
+ await nextTick()
359
+ void render()
360
+ },
361
+ { deep: true },
362
+ )
363
+
364
+ // Le groupe peut arriver après le montage (`:group="groupe"` calculé) : on relie alors les
365
+ // graphiques déjà montés du même groupe.
366
+ watch(
367
+ () => props.group,
368
+ async (group) => {
369
+ if (!group) return
370
+ const echarts = await loadECharts()
371
+ if (props.group === group && chart.value) linkChart(group, chart.value, echarts)
372
+ },
373
+ )
374
+
375
+ /** Sélection venue de l'extérieur (souvent le `@pick` d'un autre graphique) → mise en
376
+ * évidence des éléments qui portent ce nom, dans toutes les séries. */
377
+ const applySelection = () => {
378
+ // ⚠️ ne pas nommer la locale `chart` : elle masquerait la ref du même nom (TDZ).
379
+ const instance = chart.value
380
+ if (!instance) return
381
+ // En mode `dim`, c'est l'état **`select`** qui ramène la sélection au plein contraste (les
382
+ // autres gardent l'opacité réduite) ; sinon c'est `highlight`. Les deux locales sont
383
+ // déclarées **avant** tout usage (piège TDZ).
384
+ const dim = props.linkMode === "dim"
385
+ const selection = props.selected
386
+ instance.dispatchAction({ type: dim ? "unselect" : "downplay" })
387
+ if (!selection || typeof selection !== "object") return
388
+ // Le `name` est la clé partagée entre graphiques : visé dans toutes les séries.
389
+ // Sans nom (sélection réduite à une identité), on cible l'élément précis.
390
+ if (selection.name !== undefined) {
391
+ instance.dispatchAction(dim ? { type: "select", name: selection.name } : { type: "highlight", name: selection.name })
392
+ } else if (selection.markIndex !== undefined && selection.dataIndex !== undefined) {
393
+ const target = { seriesIndex: selection.markIndex, dataIndex: selection.dataIndex }
394
+ instance.dispatchAction(dim ? { type: "select", ...target } : { type: "highlight", ...target })
395
+ }
396
+ }
397
+
398
+ watch(() => props.selected, applySelection)
399
+
400
+ onBeforeUnmount(() => {
401
+ resizeObserver?.disconnect()
402
+ themeObserver?.disconnect()
403
+ chart.value?.dispose()
404
+ chart.value = null
405
+ })
406
+
407
+ defineExpose({
408
+ /** Instance ECharts (échappatoire : `chart.value?.dispatchAction(...)`) */
409
+ chart,
410
+ /** Force un nouveau rendu */
411
+ refresh: () => void render(),
412
+ })
413
+ </script>
414
+
415
+ <template>
416
+ <!-- Marque `table` : les données en HTML (sélection, jointure et `@pick` comme le reste) -->
417
+ <div
418
+ v-if="table"
419
+ class="q-chart q-chart--table"
420
+ :style="{ maxHeight: typeof height === 'number' ? `${height}px` : height }"
421
+ role="table"
422
+ :aria-label="title || 'Table'"
423
+ >
424
+ <table :class="`is-${table.separator}`">
425
+ <thead>
426
+ <tr>
427
+ <th v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
428
+ {{ column.label }}
429
+ </th>
430
+ </tr>
431
+ </thead>
432
+ <tbody>
433
+ <tr
434
+ v-for="(row, index) in table.rows"
435
+ :key="index"
436
+ :class="{ 'is-selected': isSelected(row), 'is-dimmed': isDimmed(row) }"
437
+ :style="isDimmed(row) ? { opacity: dimOpacity } : undefined"
438
+ @click="pickRow(row, index)"
439
+ >
440
+ <td v-for="column in table.columns" :key="column.field" :style="{ textAlign: column.align }">
441
+ {{ cellValue(column, row) }}
442
+ </td>
443
+ </tr>
444
+ </tbody>
445
+ </table>
446
+ <p v-if="table.rows.length === 0" class="q-chart__empty">No rows</p>
447
+ </div>
448
+
449
+ <div
450
+ v-else
451
+ ref="el"
452
+ class="q-chart"
453
+ :style="{ height: typeof height === 'number' ? `${height}px` : height }"
454
+ role="img"
455
+ :aria-label="title || 'Chart'"
456
+ ></div>
457
+ </template>
458
+
459
+ <style scoped>
460
+ /* Marque `table` : mêmes jetons que le reste, en-tête collant, chiffres alignés.
461
+ Les **séparateurs** se règlent par marque (`separator`) : `horizontal` par défaut, puis
462
+ `vertical`, `grid` (les deux) ou `none`. */
463
+ .q-chart--table {
464
+ overflow: auto;
465
+ font-size: 13px;
466
+ }
467
+
468
+ .q-chart--table table {
469
+ width: 100%;
470
+ border-collapse: collapse;
471
+ }
472
+
473
+ .q-chart--table th {
474
+ position: sticky;
475
+ top: 0;
476
+ z-index: 1;
477
+ padding: 6px 10px;
478
+ font-weight: 600;
479
+ color: var(--muted-foreground, #71717a);
480
+ text-align: start;
481
+ border-bottom: 1px solid var(--border, #e4e4e7);
482
+ background: var(--card, #fff);
483
+ }
484
+
485
+ .q-chart--table td {
486
+ padding: 6px 10px;
487
+ font-variant-numeric: tabular-nums;
488
+ }
489
+
490
+ /* `horizontal` (défaut) — un trait sous chaque rangée (l'en-tête garde le sien, plus marqué) */
491
+ .q-chart--table table.is-horizontal td {
492
+ border-bottom: 1px solid var(--border, #f1f1f3);
493
+ }
494
+
495
+ /* `vertical` — un trait entre les colonnes */
496
+ .q-chart--table table.is-vertical th + th,
497
+ .q-chart--table table.is-vertical td + td {
498
+ border-left: 1px solid var(--border, #f1f1f3);
499
+ }
500
+
501
+ /* `grid` — les deux, en gardant l'en-tête plus marqué */
502
+ .q-chart--table table.is-grid th,
503
+ .q-chart--table table.is-grid td {
504
+ border: 1px solid var(--border, #f1f1f3);
505
+ }
506
+
507
+ /* `none` — plus aucun trait de rangée (l'en-tête garde le sien) */
508
+ .q-chart--table table.is-none td {
509
+ border-bottom: 0;
510
+ }
511
+
512
+ .q-chart--table tbody tr {
513
+ cursor: pointer;
514
+ }
515
+
516
+ .q-chart--table tbody tr:hover {
517
+ background: var(--accent, #f4f4f5);
518
+ }
519
+
520
+ .q-chart--table tbody tr.is-selected {
521
+ box-shadow: inset 2px 0 0 var(--primary, #2563eb);
522
+ background: var(--accent, #f4f4f5);
523
+ }
524
+
525
+ .q-chart__empty {
526
+ margin: 8px 10px;
527
+ font-size: 12px;
528
+ color: var(--muted-foreground, #71717a);
529
+ }
530
+ .q-chart {
531
+ width: 100%;
532
+ }
533
+ </style>
@@ -210,13 +210,15 @@ watch(open, (v) => {
210
210
 
211
211
  onMounted(() => {
212
212
  if (typeof document !== "undefined") {
213
- document.addEventListener("mousedown", onDocMousedown)
213
+ // Phase de CAPTURE : un `@mousedown.stop` parent (contenu de QDialog…) bloque
214
+ // sinon l'événement avant `document` → panneau jamais fermé au clic extérieur.
215
+ document.addEventListener("mousedown", onDocMousedown, true)
214
216
  document.addEventListener("keydown", onDocKeydown)
215
217
  }
216
218
  })
217
219
  onBeforeUnmount(() => {
218
220
  if (typeof document !== "undefined") {
219
- document.removeEventListener("mousedown", onDocMousedown)
221
+ document.removeEventListener("mousedown", onDocMousedown, true)
220
222
  document.removeEventListener("keydown", onDocKeydown)
221
223
  document.body.style.overflow = ""
222
224
  }