@dnax/ui 0.0.73 → 0.0.76

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.
@@ -4,8 +4,10 @@
4
4
  // édition en place (Entrée / F2 / double-clic / frappe directe),
5
5
  // lignes & colonnes (ajouter / insérer / supprimer),
6
6
  // tri, copier/coller, undo/redo, et cellules typées
7
- // texte, nombre, entier, booléen, date, datetime, **time (heure)**, select / multiselect avec
8
- // badges colorés).
7
+ // texte, nombre, entier, booléen, date, datetime, **time (heure)**, **attachment (fichiers
8
+ // joints)**, select / multiselect avec badges colorés).
9
+ // Slots dynamiques : `#cell-<nom>` / `#cell` (contenu de cellule) et `#head-<nom>`
10
+ // (en-tête de colonne) — pour rendre n'importe quel composant dans la grille.
9
11
  // Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
10
12
  // ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
11
13
  // (`lib/spreadsheetChanges.ts`).
@@ -48,6 +50,25 @@ export type QSpreadsheetCellType =
48
50
  | "time"
49
51
  | "select"
50
52
  | "multiselect"
53
+ | "attachment"
54
+
55
+ /**
56
+ * Fichier joint stocké dans une cellule `attachment`. **Sans** l'objet `File` (qui n'est
57
+ * pas sérialisable) : le fichier binaire est livré à l'app par l'événement `files`, et
58
+ * `url` permet l'aperçu/le téléchargement (object URL local ou URL d'upload).
59
+ */
60
+ export interface QSpreadsheetAttachment {
61
+ /** Identifiant local stable (clé de rendu, cible de suppression) */
62
+ id: string
63
+ /** Nom du fichier */
64
+ name: string
65
+ /** Taille en octets */
66
+ size: number
67
+ /** Type MIME (`image/png`, `application/pdf`…) */
68
+ type: string
69
+ /** URL d'aperçu/téléchargement (object URL avant upload, URL serveur ensuite) */
70
+ url?: string
71
+ }
51
72
 
52
73
  // `QSpreadsheetCellOption` (option **normalisée**) et `QSpreadsheetOptionAccessor` vivent dans
53
74
  // `lib/spreadsheetOptions.ts` — source unique, testée hors navigateur — et sont ré-exportés en
@@ -86,6 +107,17 @@ export interface QSpreadsheetColumn {
86
107
  optionValue?: QSpreadsheetOptionAccessor
87
108
  /** Pour type "select" / "multiselect" : rend les options actives en badge coloré (sinon texte) */
88
109
  chip?: boolean
110
+ /**
111
+ * Pour type `attachment` : types acceptés — mêmes règles qu'un `<input type="file">`
112
+ * (`"image/*"`, `"application/pdf"`, `".pdf,.docx"`, liste séparée par des virgules).
113
+ */
114
+ accept?: string
115
+ /** Pour type `attachment` : autorise plusieurs fichiers dans une même cellule */
116
+ multiple?: boolean
117
+ /** Pour type `attachment` : nombre max de fichiers par cellule (0 = illimité) */
118
+ maxFiles?: number
119
+ /** Pour type `attachment` : taille max par fichier, en octets (0 = illimité) */
120
+ maxFileSize?: number
89
121
  /** Validation à la saisie : objet `{ min, max, … }` ou expression ArkType (`"number < 4"`) */
90
122
  validation?: QSpreadsheetValidationRule
91
123
  /** Formatteur de la valeur affichée */
@@ -174,6 +206,7 @@ export type {
174
206
  <script setup lang="ts">
175
207
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
176
208
  import { Icon } from "@iconify/vue"
209
+ import QInnerLoading from "./QInnerLoading.vue"
177
210
  import { cn } from "../lib/utils"
178
211
  import { icons } from "../lib/icons"
179
212
  import { colorValue, foregroundFor } from "../lib/colors"
@@ -200,6 +233,11 @@ import {
200
233
  parseCsv,
201
234
  } from "../lib/spreadsheet"
202
235
  import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
236
+ import { pageInfo } from "../lib/pagination"
237
+ import { cellFontFor, cellSizePx, cellWidthFor } from "../lib/cellSize"
238
+ import type { CellSizePreset } from "../lib/cellSize"
239
+ import { attachmentsOf as fileAttachmentsOf, fileIconKind, formatFileSize, isAttachment, selectFiles } from "../lib/fileList"
240
+ import type { FileIconKind } from "../lib/fileList"
203
241
  import { normalizeCellOptions } from "../lib/spreadsheetOptions"
204
242
  import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
205
243
  import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
@@ -213,6 +251,25 @@ import {
213
251
  interface Props {
214
252
  /** Lignes (v-model:rows) — un objet par ligne, clés = noms de colonnes */
215
253
  rows?: Record<string, any>[]
254
+ /**
255
+ * Pagination **distante** : lignes par page (0 = désactivée). `rows` doit contenir
256
+ * **uniquement la page courante** — l'app recharge sur `page-change`.
257
+ */
258
+ pageSize?: number
259
+ /** Page courante (1-based) — `v-model:page`. Sans binding, l'état est interne au composant */
260
+ page?: number
261
+ /**
262
+ * Total de lignes, toutes pages confondues. Active le compteur `page / N` et la plage
263
+ * « lignes 401–800 sur 12 000 ». À défaut : le nombre de lignes fournies.
264
+ */
265
+ totalRows?: number
266
+ /**
267
+ * Chargement **distant** en cours (pagination, rechargement…) : un voile recouvre la
268
+ * grille — la barre d'état et le pager restent visibles, flèches désactivées.
269
+ */
270
+ loading?: boolean
271
+ /** Libellé du chargement (défaut : i18n « Loading… ») */
272
+ loadingLabel?: string
216
273
  /** Colonnes ({ name, label, width, type, editable, options, chip, format, align, … }) */
217
274
  columns?: QSpreadsheetColumn[]
218
275
  /** Hauteur du conteneur scrollable (nombre px ou chaîne CSS, défaut "360px") */
@@ -221,6 +278,13 @@ interface Props {
221
278
  rowHeight?: number
222
279
  /** Largeur de colonne par défaut en px (défaut 160) */
223
280
  defaultColWidth?: number
281
+ /**
282
+ * Taille des cellules — preset `sm` (28px) · `md` (32px, défaut) · `lg` (40px), ou une
283
+ * hauteur en px (`:size-cell="48"`, `66`…). Pilote la hauteur de ligne, la largeur de
284
+ * colonne par défaut (ratio 5:1) et la police des cellules. `rowHeight` /
285
+ * `defaultColWidth` restent prioritaires s'ils sont fournis.
286
+ */
287
+ sizeCell?: CellSizePreset | number
224
288
  /** Affiche la barre d'outils */
225
289
  showToolbar?: boolean
226
290
  /** Affiche la barre de formule (fx) au-dessus de la grille */
@@ -325,11 +389,15 @@ interface Props {
325
389
  }
326
390
 
327
391
  const props = withDefaults(defineProps<Props>(), {
392
+ pageSize: 0,
393
+ totalRows: 0,
394
+ loading: false,
328
395
  rows: () => [],
329
396
  columns: () => [],
330
397
  height: "360px",
331
- rowHeight: 32,
332
- defaultColWidth: 160,
398
+ rowHeight: undefined,
399
+ defaultColWidth: undefined,
400
+ sizeCell: undefined,
333
401
  showToolbar: true,
334
402
  showFormulaBar: true,
335
403
  frozenRows: 0,
@@ -435,6 +503,11 @@ const I18N = {
435
503
  sheetsDeleted: "{n} sheet(s) deleted",
436
504
  formatted: "formatting",
437
505
  empty: "…",
506
+ pagePrev: "Previous page",
507
+ pageNext: "Next page",
508
+ pageRange: "Rows {from}–{to} of {total}",
509
+ pageLabel: "{page} / {count}",
510
+ loading: "Loading…",
438
511
  },
439
512
  fr: {
440
513
  sheet: "Feuille",
@@ -516,6 +589,11 @@ const I18N = {
516
589
  sheetsDeleted: "{n} feuille(s) supprimée(s)",
517
590
  formatted: "mise en forme",
518
591
  empty: "…",
592
+ pagePrev: "Page précédente",
593
+ pageNext: "Page suivante",
594
+ pageRange: "Lignes {from}–{to} sur {total}",
595
+ pageLabel: "{page} / {count}",
596
+ loading: "Chargement…",
519
597
  },
520
598
  } as const
521
599
  type UiKey = keyof (typeof I18N)["en"]
@@ -535,13 +613,72 @@ const emit = defineEmits<{
535
613
  "cell-change": [payload: { row: number; column: string; oldValue: any; newValue: any; rows: Record<string, any>[] }]
536
614
  "cell-edit-start": [payload: { row: number; column: string }]
537
615
  "cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
616
+ /**
617
+ * Fichiers choisis dans une cellule `attachment`. `files` = fichiers **ajoutés**
618
+ * (à uploader), `attachments` = état complet de la cellule, `rejected` = refusés
619
+ * (type / taille / quota). Les `QSpreadsheetAttachment` ne portent **pas** le `File`.
620
+ */
621
+ "files": [payload: {
622
+ row: number
623
+ column: string
624
+ rowIndex: number
625
+ files: File[]
626
+ attachments: QSpreadsheetAttachment[]
627
+ rejected: { file: File; reason: "type" | "size" | "count" }[]
628
+ }]
629
+ /**
630
+ * Clic sur un fichier joint : à l'app d'ouvrir son aperçu (modal, visionneuse PDF…).
631
+ * Rien n'est ouvert par le composant — on fournit la cellule **et** la ligne complète.
632
+ */
633
+ "attachment-click": [payload: {
634
+ /** Index de la ligne */
635
+ row: number
636
+ /** Nom de la colonne */
637
+ column: string
638
+ /** Fichier cliqué */
639
+ attachment: QSpreadsheetAttachment
640
+ /** Tous les fichiers de la cellule */
641
+ attachments: QSpreadsheetAttachment[]
642
+ /** Valeur brute de la cellule */
643
+ value: any
644
+ /** Toute la ligne (toutes les colonnes) */
645
+ rowData: Record<string, any>
646
+ event: MouseEvent
647
+ }]
538
648
  "selection-change": [value: QSpreadsheetSelection | null]
539
649
  "structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
540
650
  "prefill": [payload: { count: number; mode: "auto" | "manual" }]
541
651
  "update:dirty": [value: boolean]
542
652
  "update:changes": [value: QSpreadsheetChanges]
653
+ "update:page": [value: number]
654
+ /**
655
+ * Changement de page — c'est ici que l'app **charge** les données de la page demandée.
656
+ * `offset` = index global de la première ligne de la nouvelle page (0-based).
657
+ */
658
+ "page-change": [payload: {
659
+ page: number
660
+ previousPage: number
661
+ pageSize: number
662
+ offset: number
663
+ totalRows: number
664
+ pageCount: number
665
+ }]
543
666
  }>()
544
667
 
668
+ // ─── Taille des cellules (`sizeCell`) ───
669
+ // Une seule échelle pilote tout : hauteur de ligne → largeur de colonne par défaut
670
+ // (ratio 5:1) → police des cellules. `rowHeight` / `defaultColWidth` restent prioritaires
671
+ // s'ils sont fournis (API historique).
672
+ const cellHeightPx = computed(() => props.rowHeight ?? cellSizePx(props.sizeCell))
673
+ const cellWidthPx = computed(() => props.defaultColWidth ?? cellWidthFor(cellHeightPx.value))
674
+ const cellFontPx = computed(() => cellFontFor(cellHeightPx.value))
675
+
676
+ /** Variables CSS de la grille (police des cellules + hauteur de repli) */
677
+ const cellSizeStyle = computed(() => ({
678
+ "--q-spreadsheet-font": `${cellFontPx.value}px`,
679
+ "--q-spreadsheet-row-height": `${cellHeightPx.value}px`,
680
+ }))
681
+
545
682
  // ─── Clés de ligne (`_key`) ───
546
683
  /**
547
684
  * Propriété injectée automatiquement sur chaque ligne : une clé stable (uuid) qui
@@ -605,6 +742,81 @@ const pushRows = (next: Record<string, any>[]) => {
605
742
  emit("update:rows", next)
606
743
  }
607
744
 
745
+ // ─── Pagination (distante) ───
746
+ // `rows` contient **la page courante** : le tableau ne découpe rien lui-même (les index
747
+ // internes — sélection, édition, historique — restent locaux à la page). La numérotation
748
+ // des lignes et le pager sont décalés de `rowOffset`.
749
+ const pageSizeN = computed(() => Math.max(0, Math.trunc(props.pageSize || 0)))
750
+ const paginationOn = computed(() => pageSizeN.value > 0)
751
+
752
+ /** Total de lignes, toutes pages : `totalRows` (distant) sinon les lignes fournies */
753
+ const totalRowsN = computed(() =>
754
+ props.totalRows && props.totalRows > 0 ? props.totalRows : state.value.length,
755
+ )
756
+
757
+ /** Page interne : repli quand `page` n'est pas lié (`v-model:page` absent) */
758
+ const localPage = ref(1)
759
+
760
+ const pageState = computed(() =>
761
+ pageInfo({
762
+ page: props.page ?? localPage.value,
763
+ pageSize: pageSizeN.value,
764
+ total: totalRowsN.value,
765
+ loaded: state.value.length,
766
+ }),
767
+ )
768
+
769
+ /** Page affichée (bornée au nombre de pages) */
770
+ const currentPage = computed(() => pageState.value.page)
771
+ const pageCount = computed(() => pageState.value.pageCount)
772
+ /** Décalage global de la première ligne affichée — sert à la numérotation */
773
+ const rowOffset = computed(() => pageState.value.offset)
774
+
775
+ const numberFormat = computed(() => new Intl.NumberFormat(lang.value))
776
+ const pageRangeLabel = computed(() => {
777
+ if (!paginationOn.value) return ""
778
+ const { from, to } = pageState.value
779
+ const nf = numberFormat.value
780
+ return fmt("pageRange", { from: nf.format(from), to: nf.format(to), total: nf.format(totalRowsN.value) })
781
+ })
782
+ const pageLabel = computed(() =>
783
+ fmt("pageLabel", { page: currentPage.value, count: pageCount.value }),
784
+ )
785
+
786
+ /** Navigation : met à jour l'état interne (repli), émet `update:page` + `page-change` */
787
+ const goPage = (next: number) => {
788
+ if (!paginationOn.value) return
789
+ const target = Math.min(Math.max(1, Math.trunc(next)), pageCount.value)
790
+ const previous = currentPage.value
791
+ localPage.value = target
792
+ if (target === previous) return
793
+ emit("update:page", target)
794
+ emit("page-change", {
795
+ page: target,
796
+ previousPage: previous,
797
+ pageSize: pageSizeN.value,
798
+ offset: (target - 1) * pageSizeN.value,
799
+ totalRows: totalRowsN.value,
800
+ pageCount: pageCount.value,
801
+ })
802
+ }
803
+
804
+ // La pagination est **distante** : des lignes au-delà de `pageSize` signifient que l'app
805
+ // n'a pas découpé. On le signale plutôt que de tronquer silencieusement.
806
+ if (typeof document !== "undefined") {
807
+ watch(
808
+ [paginationOn, pageSizeN, () => state.value.length],
809
+ () => {
810
+ if (!paginationOn.value || state.value.length <= pageSizeN.value) return
811
+ console.warn(
812
+ `[dnax/ui] QSpreadsheet : ${state.value.length} lignes pour pageSize=${pageSizeN.value}. ` +
813
+ "La pagination est distante : passez seulement la page courante (`rows`) et `total-rows`.",
814
+ )
815
+ },
816
+ { immediate: true },
817
+ )
818
+ }
819
+
608
820
  // ─── Colonnes internes ───
609
821
  const cols = ref<QSpreadsheetColumn[]>([])
610
822
  watch(
@@ -881,6 +1093,8 @@ const cellText = (row: number, column: string): string => {
881
1093
  const ev = evalAt(row, column)
882
1094
  if (isError(ev)) return ev.toString()
883
1095
  if (col?.type === "select" || col?.type === "multiselect") return "" // rendu via badge / label
1096
+ // Fichiers joints : le texte « utile » (recherche, copie, CSV, formule) = les noms
1097
+ if (col?.type === "attachment") return attachmentsOf(ev).map((att) => att.name).join(", ")
884
1098
  let out: any = ev === null || ev === undefined ? "" : ev
885
1099
  // Apostrophe littérale (paste values) : affichée sans le préfixe
886
1100
  if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
@@ -900,6 +1114,10 @@ const cellTitle = (row: number, column: string): string | undefined => {
900
1114
  const text = choiceText(col, v)
901
1115
  return text === "" ? undefined : text
902
1116
  }
1117
+ if (col?.type === "attachment") {
1118
+ const list = attachmentsOf(v)
1119
+ return list.length ? list.map((att) => attachmentTitle(att)).join("\n") : undefined
1120
+ }
903
1121
  return undefined
904
1122
  }
905
1123
 
@@ -1018,8 +1236,15 @@ const editorInputMode = computed(() => {
1018
1236
  })
1019
1237
 
1020
1238
  const startEdit = (row: number, column: string, initial?: string) => {
1239
+ if (props.loading) return
1021
1240
  const col = colOf(column)
1022
1241
  if (isCellLocked(row, column) || col?.type === "boolean") return
1242
+ // Fichier joint : pas d'éditeur texte — Entrée / double-clic ouvrent le sélecteur
1243
+ // (une simple frappe de caractère ne doit **pas** déclencher de boîte de dialogue)
1244
+ if (col?.type === "attachment") {
1245
+ if (initial === undefined) openAttachments(row, column)
1246
+ return
1247
+ }
1023
1248
  const raw = state.value[row]?.[column]
1024
1249
  if (col?.type === "select") {
1025
1250
  const opt = cellOptionsOf(col).find((o) => o.value === raw)
@@ -1093,6 +1318,8 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
1093
1318
  const raw = String(text ?? "")
1094
1319
  const t = raw.trim()
1095
1320
  if (t === "") return null
1321
+ // Fichier joint : jamais cohérent depuis du texte (saisie, collage, import) → on garde
1322
+ if (col?.type === "attachment") return old
1096
1323
  // Formule A1 : conservée brute dans la cellule (booléen/choix exclus) —
1097
1324
  // `t` (déjà trimé) évite qu'un `" =…"` ne soit traité comme une formule par
1098
1325
  // `isFormulaRaw` tout en échouant au parsing du moteur.
@@ -1181,6 +1408,155 @@ const toggleBoolean = (row: number, column: string) => {
1181
1408
  setCellValue(row, column, old, !old)
1182
1409
  }
1183
1410
 
1411
+ // ─── Fichiers joints (type `attachment`) ───
1412
+ /**
1413
+ * Valeur d'une cellule `attachment` → tableau typé. La **normalisation** (tableau,
1414
+ * fichier seul, entrées invalides ignorées) vit dans `lib/fileList.ts`, testée.
1415
+ */
1416
+ const attachmentsOf = (value: unknown): QSpreadsheetAttachment[] =>
1417
+ fileAttachmentsOf(value) as QSpreadsheetAttachment[]
1418
+
1419
+ const ATTACHMENT_ICONS: Record<FileIconKind, string> = {
1420
+ image: icons.fileImage,
1421
+ video: icons.fileVideo,
1422
+ audio: icons.fileAudio,
1423
+ archive: icons.fileArchive,
1424
+ spreadsheet: icons.fileSpreadsheet,
1425
+ code: icons.fileCode,
1426
+ file: icons.fileText,
1427
+ }
1428
+
1429
+ const attachmentIcon = (att: QSpreadsheetAttachment) => ATTACHMENT_ICONS[fileIconKind(att)]
1430
+
1431
+ const attachmentTitle = (att: QSpreadsheetAttachment) =>
1432
+ `${att.name} — ${formatFileSize(att.size)}`
1433
+
1434
+ /** Object URLs créés par le composant (jamais une URL fournie par l'app) */
1435
+ const localAttachmentUrls = new Set<string>()
1436
+
1437
+ const releaseAttachmentUrl = (url?: string) => {
1438
+ if (!url || !localAttachmentUrls.has(url)) return
1439
+ URL.revokeObjectURL(url)
1440
+ localAttachmentUrls.delete(url)
1441
+ }
1442
+
1443
+ const attachInputEl = ref<HTMLInputElement | null>(null)
1444
+ const attachTarget = ref<{ row: number; column: string } | null>(null)
1445
+
1446
+ /** Ouvre le sélecteur de fichiers d'une cellule (`accept` / `multiple` de la colonne) */
1447
+ const openAttachments = (row: number, column: string) => {
1448
+ const col = colOf(column)
1449
+ if (!col || col.type !== "attachment" || isCellLocked(row, column)) return
1450
+ const input = attachInputEl.value
1451
+ if (!input) return
1452
+ attachTarget.value = { row, column }
1453
+ input.accept = col.accept ?? ""
1454
+ // `multiple` : explicite, ou déduit du quota (`maxFiles > 1`)
1455
+ input.multiple = col.multiple === true || (col.maxFiles ?? 0) > 1
1456
+ input.value = "" // permet de re-sélectionner le même fichier
1457
+ input.click()
1458
+ }
1459
+
1460
+ const addAttachments = (row: number, column: string, incoming: File[]) => {
1461
+ const col = colOf(column)
1462
+ if (!col || col.type !== "attachment" || isCellLocked(row, column)) return
1463
+ const old = state.value[row]?.[column]
1464
+ const current = attachmentsOf(old)
1465
+ // Mono-fichier par défaut ; `multiple` (ou un quota > 1, ou un existant multiple)
1466
+ const multiple = col.multiple === true || current.length > 1 || (col.maxFiles ?? 0) > 1
1467
+ const maxFiles = col.maxFiles && col.maxFiles > 0 ? col.maxFiles : multiple ? 0 : 1
1468
+
1469
+ const { accepted, rejected } = selectFiles(incoming, {
1470
+ accept: col.accept,
1471
+ maxSize: col.maxFileSize,
1472
+ maxFiles,
1473
+ // En mono-fichier le nouveau remplace l'ancien : il ne consomme pas de quota
1474
+ current: multiple ? current.length : 0,
1475
+ })
1476
+
1477
+ if (accepted.length === 0) {
1478
+ if (rejected.length) {
1479
+ emit("files", { row, column, rowIndex: row, files: [], attachments: current, rejected })
1480
+ }
1481
+ return
1482
+ }
1483
+
1484
+ const added: QSpreadsheetAttachment[] = accepted.map((file) => {
1485
+ const url =
1486
+ typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
1487
+ ? URL.createObjectURL(file)
1488
+ : undefined
1489
+ if (url) localAttachmentUrls.add(url)
1490
+ return { id: newRowKey(), name: file.name, size: file.size, type: file.type, url }
1491
+ })
1492
+
1493
+ let next: QSpreadsheetAttachment[]
1494
+ if (multiple) {
1495
+ next = [...current, ...added]
1496
+ } else {
1497
+ // Remplacement : les object URLs de l'ancien fichier sont libérés
1498
+ for (const att of current) releaseAttachmentUrl(att.url)
1499
+ next = added.slice(0, 1)
1500
+ }
1501
+
1502
+ pushHistory()
1503
+ setCellValue(row, column, old, next)
1504
+ emit("files", { row, column, rowIndex: row, files: accepted, attachments: next, rejected })
1505
+ }
1506
+
1507
+ const removeAttachment = (row: number, column: string, id: string) => {
1508
+ if (isCellLocked(row, column)) return
1509
+ const old = state.value[row]?.[column]
1510
+ const current = attachmentsOf(old)
1511
+ const target = current.find((att) => att.id === id)
1512
+ if (!target) return
1513
+ releaseAttachmentUrl(target.url)
1514
+ const next = current.filter((att) => att.id !== id)
1515
+ pushHistory()
1516
+ setCellValue(row, column, old, next.length ? next : null)
1517
+ }
1518
+
1519
+ const onAttachmentsChange = (e: Event) => {
1520
+ const input = e.target as HTMLInputElement
1521
+ const files = Array.from(input.files ?? [])
1522
+ input.value = "" // permet de re-sélectionner le même fichier
1523
+ const target = attachTarget.value
1524
+ attachTarget.value = null
1525
+ if (!target || files.length === 0) return
1526
+ addAttachments(target.row, target.column, files)
1527
+ }
1528
+
1529
+ /**
1530
+ * Clic sur une puce de fichier : le composant n'ouvre **rien** lui-même (il ne connaît pas
1531
+ * la visionneuse de l'app) — il émet `attachment-click` avec la cellule et la ligne
1532
+ * complète. Le clic sur le `×` (retrait) est stoppé et ne passe pas ici.
1533
+ */
1534
+ const emitAttachmentClick = (
1535
+ row: number,
1536
+ column: string,
1537
+ attachment: QSpreadsheetAttachment,
1538
+ event: MouseEvent,
1539
+ ) => {
1540
+ const value = state.value[row]?.[column]
1541
+ emit("attachment-click", {
1542
+ row,
1543
+ column,
1544
+ attachment,
1545
+ attachments: attachmentsOf(value),
1546
+ value,
1547
+ rowData: state.value[row] ?? {},
1548
+ event,
1549
+ })
1550
+ }
1551
+
1552
+ /** Démontage : libère les object URLs encore en vie (l'app garde les siens) */
1553
+ onBeforeUnmount(() => {
1554
+ for (const url of [...localAttachmentUrls]) {
1555
+ URL.revokeObjectURL(url)
1556
+ localAttachmentUrls.delete(url)
1557
+ }
1558
+ })
1559
+
1184
1560
  const clearSelection = () => {
1185
1561
  const rect = selRect.value
1186
1562
  if (!rect) return
@@ -1190,8 +1566,13 @@ const clearSelection = () => {
1190
1566
  const col = cols.value[c]
1191
1567
  if (!col || isCellLocked(r, col.name)) continue
1192
1568
  const v = state.value[r]?.[col.name]
1193
- if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
1569
+ if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0)) {
1570
+ // Effacer une cellule `attachment` libère ses object URLs locales
1571
+ if (col.type === "attachment") {
1572
+ for (const att of attachmentsOf(v)) releaseAttachmentUrl(att.url)
1573
+ }
1194
1574
  changed.push({ row: r, column: col.name })
1575
+ }
1195
1576
  }
1196
1577
  }
1197
1578
  if (!changed.length) return
@@ -1512,6 +1893,10 @@ const sortByColumn = (column?: string, desc = false) => {
1512
1893
  // `label` peut être numérique → toString avant comparaison ; liste → libellés joints
1513
1894
  return choiceText(col, raw).toLowerCase()
1514
1895
  }
1896
+ // Fichier joint : tri sur les noms (jamais `[object Object]`)
1897
+ if (col?.type === "attachment") {
1898
+ return attachmentsOf(raw).map((att) => att.name).join(", ").toLowerCase()
1899
+ }
1515
1900
  return String(ev).toLowerCase()
1516
1901
  }
1517
1902
  const sortedIdx = state.value
@@ -1606,7 +1991,7 @@ const jumpPage = (dir: 1 | -1, extend = false) => {
1606
1991
  if (!sel.value || !visibleRows.value.length) return
1607
1992
  let idx = visibleRows.value.indexOf(sel.value.row)
1608
1993
  if (idx < 0) idx = 0
1609
- const page = Math.max(1, Math.floor(viewH.value / props.rowHeight))
1994
+ const page = Math.max(1, Math.floor(viewH.value / cellHeightPx.value))
1610
1995
  let target = idx
1611
1996
  for (let k = 0; k < page; k++) {
1612
1997
  const n = nextVisibleIndex(target, dir)
@@ -1712,6 +2097,8 @@ const fillRightKey = () => {
1712
2097
 
1713
2098
  // ─── Clavier global (navigation / édition) ───
1714
2099
  const onKeydown = (e: KeyboardEvent) => {
2100
+ // Données en cours de chargement : on ne navigue ni n'édite une page périmée
2101
+ if (props.loading) return
1715
2102
  if (editing.value) return
1716
2103
  if (!sel.value || !cols.value.length) return
1717
2104
  const mod = e.ctrlKey || e.metaKey
@@ -2294,7 +2681,7 @@ const acceptFxAt = (i: number) => {
2294
2681
 
2295
2682
  // ════════ Hauteurs de lignes (resize au drag sur le numéro) ════════
2296
2683
  const rowHeights = ref<Record<number, number>>({})
2297
- const rowH = (r: number) => rowHeights.value[r] ?? props.rowHeight
2684
+ const rowH = (r: number) => rowHeights.value[r] ?? cellHeightPx.value
2298
2685
 
2299
2686
  const rowResizing = ref<{ r: number; y: number; h: number } | null>(null)
2300
2687
  const startRowResize = (e: PointerEvent, r: number) => {
@@ -2450,9 +2837,9 @@ const gutterW = computed(() => (props.showRowNumbers ? 34 : 0))
2450
2837
  const headH = computed(() => (props.showColumnHeaders ? 28 : 0))
2451
2838
  const colWNum = (ci: number) => {
2452
2839
  const c = cols.value[ci]
2453
- if (!c) return props.defaultColWidth
2454
- const w = colWidths.value[c.name] ?? c.width ?? props.defaultColWidth
2455
- return typeof w === "number" ? w : parseFloat(w) || props.defaultColWidth
2840
+ if (!c) return cellWidthPx.value
2841
+ const w = colWidths.value[c.name] ?? c.width ?? cellWidthPx.value
2842
+ return typeof w === "number" ? w : parseFloat(w) || cellWidthPx.value
2456
2843
  }
2457
2844
  const frozenLeftOf = (ci: number) => {
2458
2845
  let l = gutterW.value
@@ -2505,7 +2892,9 @@ const filters = ref<Record<string, string[] | null>>({})
2505
2892
  const filterValuesOf = (raw: any): any[] => {
2506
2893
  if (Array.isArray(raw)) {
2507
2894
  const values = raw.filter((v) => !isBlankValue(v))
2508
- return values.length ? values : [null]
2895
+ // Fichier joint : la valeur filtrante est le **nom** du fichier, pas l'objet
2896
+ const mapped = values.map((v) => (isAttachment(v) ? v.name : v))
2897
+ return mapped.length ? mapped : [null]
2509
2898
  }
2510
2899
  return isBlankValue(raw) ? [null] : [raw]
2511
2900
  }
@@ -2668,7 +3057,7 @@ const updateRange = () => {
2668
3057
  }
2669
3058
  const start = Math.max(0, lo - VIRTUAL_OVERSCAN)
2670
3059
  let end = start
2671
- while (end < n && info.rows[end]!.top < bottom + VIRTUAL_OVERSCAN * props.rowHeight) end++
3060
+ while (end < n && info.rows[end]!.top < bottom + VIRTUAL_OVERSCAN * cellHeightPx.value) end++
2672
3061
  end = Math.min(n - 1, end + VIRTUAL_OVERSCAN)
2673
3062
  renderRange.value = { start, end }
2674
3063
  topPad.value = Math.max(0, info.rows[start]!.top - headH.value)
@@ -2692,7 +3081,7 @@ const ensureRowVisible = (realRow: number) => {
2692
3081
  // scroll-behavior: smooth (CSS) anime la remontée / descente ; le focus est
2693
3082
  // différé jusqu'à ce que la ligne soit effectivement rendue par la fenêtre
2694
3083
  pendingFocus.value = { row: realRow, col: cols.value[sel.value?.column ? colIndex(sel.value.column) : 0]?.name ?? "" }
2695
- el.scrollTop = Math.max(0, info.top - headH.value - VIRTUAL_OVERSCAN * props.rowHeight)
3084
+ el.scrollTop = Math.max(0, info.top - headH.value - VIRTUAL_OVERSCAN * cellHeightPx.value)
2696
3085
  updateRange()
2697
3086
  }
2698
3087
 
@@ -2902,7 +3291,7 @@ const insertColumnAt = (pos: "left" | "right") => {
2902
3291
  const nextCols = [...cols.value]
2903
3292
  nextCols.splice(idx, 0, col)
2904
3293
  cols.value = nextCols
2905
- colWidths.value = { ...colWidths.value, [name]: props.defaultColWidth }
3294
+ colWidths.value = { ...colWidths.value, [name]: cellWidthPx.value }
2906
3295
  const nextRows = state.value.map((r) => ({ ...r, [name]: "" }))
2907
3296
  state.value = nextRows
2908
3297
  emit("update:columns", nextCols)
@@ -3173,13 +3562,13 @@ const isActiveRow = (r: number) => {
3173
3562
  const colWidths = ref<Record<string, string | number>>({})
3174
3563
  const syncColWidths = () => {
3175
3564
  const next: Record<string, string | number> = {}
3176
- for (const c of cols.value) next[c.name] = c.width ?? props.defaultColWidth
3565
+ for (const c of cols.value) next[c.name] = c.width ?? cellWidthPx.value
3177
3566
  colWidths.value = next
3178
3567
  }
3179
3568
  watch(cols, syncColWidths, { immediate: true, deep: true })
3180
3569
 
3181
3570
  const widthStyle = (col: QSpreadsheetColumn) => {
3182
- const w = colWidths.value[col.name] ?? col.width ?? props.defaultColWidth
3571
+ const w = colWidths.value[col.name] ?? col.width ?? cellWidthPx.value
3183
3572
  return typeof w === "number" ? w + "px" : w
3184
3573
  }
3185
3574
  const minW = (col: QSpreadsheetColumn) => (col.minWidth ?? 60) + "px"
@@ -3195,7 +3584,7 @@ const startResize = (e: PointerEvent, col: QSpreadsheetColumn) => {
3195
3584
  resizing.value = {
3196
3585
  col,
3197
3586
  x: e.clientX,
3198
- w: typeof current === "number" ? current : props.defaultColWidth,
3587
+ w: typeof current === "number" ? current : cellWidthPx.value,
3199
3588
  }
3200
3589
  window.addEventListener("pointermove", onResizeMove)
3201
3590
  window.addEventListener("pointerup", endResize)
@@ -3335,13 +3724,28 @@ const cellContent = (row: number, col: QSpreadsheetColumn) => {
3335
3724
  return cellText(row, col.name)
3336
3725
  }
3337
3726
 
3727
+ /**
3728
+ * Props des slots de cellule (`#cell-<nom>`, `#cell`) : tout ce qu'un composant
3729
+ * personnalisé peut vouloir, sans que le template ait à connaître l'API interne.
3730
+ * `setValue` passe par l'historique et les événements (`cell-change`, `update:rows`).
3731
+ */
3732
+ const cellSlotProps = (row: number, col: QSpreadsheetColumn, columnIndex: number) => ({
3733
+ row: state.value[row] ?? {},
3734
+ rowIndex: row,
3735
+ column: col,
3736
+ columnIndex,
3737
+ value: cellValue(row, col.name),
3738
+ text: cellText(row, col.name),
3739
+ setValue: (next: any) => setCellValue(row, col.name, state.value[row]?.[col.name], next),
3740
+ })
3741
+
3338
3742
  const editingCol = computed(() => (editing.value ? colOf(editing.value.column) : undefined))
3339
3743
 
3340
3744
  const selLabel = computed(() => {
3341
3745
  if (!sel.value) return ""
3342
3746
  const ci = colIndex(sel.value.column)
3343
3747
  if (ci === -1) return ""
3344
- return colLetter(ci) + (sel.value.row + 1)
3748
+ return colLetter(ci) + (rowOffset.value + sel.value.row + 1)
3345
3749
  })
3346
3750
 
3347
3751
  const optionIsActive = (opt: QSpreadsheetCellOption) => activeOption.value === opt
@@ -3369,7 +3773,7 @@ const switching = ref(false)
3369
3773
 
3370
3774
  const makeExtras = (cols: QSpreadsheetColumn[]): SheetExtras => {
3371
3775
  const widths: Record<string, number | string> = {}
3372
- for (const c of cols) widths[c.name] = c.width ?? props.defaultColWidth
3776
+ for (const c of cols) widths[c.name] = c.width ?? cellWidthPx.value
3373
3777
  return {
3374
3778
  cols: cols.map((c) => ({ ...c })),
3375
3779
  widths,
@@ -3442,7 +3846,7 @@ const canonicalColumns = (columns: QSpreadsheetColumn[] | undefined): QSpreadshe
3442
3846
  ...c,
3443
3847
  type: c.type ?? "text",
3444
3848
  options: c.options ?? [],
3445
- width: c.width ?? props.defaultColWidth,
3849
+ width: c.width ?? cellWidthPx.value,
3446
3850
  }))
3447
3851
 
3448
3852
  watch(
@@ -4630,7 +5034,12 @@ defineExpose({
4630
5034
  </script>
4631
5035
 
4632
5036
  <template>
4633
- <div ref="rootRef" class="q-spreadsheet" :class="rootClasses" :style="radiusStyle(effectiveRadius)">
5037
+ <div
5038
+ ref="rootRef"
5039
+ class="q-spreadsheet"
5040
+ :class="rootClasses"
5041
+ :style="[radiusStyle(effectiveRadius), cellSizeStyle]"
5042
+ >
4634
5043
  <!-- ═══════ Onglets de feuilles (classeur, haut) ═══════ -->
4635
5044
  <div
4636
5045
  v-if="sheetsMode && sheetsPosition === 'top'"
@@ -4936,6 +5345,7 @@ defineExpose({
4936
5345
  class="q-spreadsheet__viewport"
4937
5346
  :style="{ height: heightStyle }"
4938
5347
  tabindex="0"
5348
+ :aria-busy="loading || undefined"
4939
5349
  @keydown="onKeydown"
4940
5350
  @scroll="onViewportScroll"
4941
5351
  @contextmenu.prevent
@@ -4977,10 +5387,18 @@ defineExpose({
4977
5387
  @click="selectCol(ci, $event.shiftKey)"
4978
5388
  @contextmenu="onColContext(ci, $event)"
4979
5389
  >
4980
- <span class="q-spreadsheet__colhead-inner">
4981
- <span class="q-spreadsheet__colhead-letter">{{ colLetter(ci) }}</span>
4982
- <span class="q-spreadsheet__colhead-label">{{ col.label || col.name }}</span>
4983
- </span>
5390
+ <slot
5391
+ :name="'head-' + col.name"
5392
+ :column="col"
5393
+ :column-index="ci"
5394
+ :letter="colLetter(ci)"
5395
+ :label="col.label || col.name"
5396
+ >
5397
+ <span class="q-spreadsheet__colhead-inner">
5398
+ <span class="q-spreadsheet__colhead-letter">{{ colLetter(ci) }}</span>
5399
+ <span class="q-spreadsheet__colhead-label">{{ col.label || col.name }}</span>
5400
+ </span>
5401
+ </slot>
4984
5402
  <button
4985
5403
  class="q-spreadsheet__fbtn"
4986
5404
  :class="{ 'q-spreadsheet__fbtn--on': hasFilterFor(col.name) }"
@@ -5034,7 +5452,7 @@ defineExpose({
5034
5452
  @click="selectRow(ri, $event.shiftKey)"
5035
5453
  @contextmenu="onRowContext(ri, $event)"
5036
5454
  >
5037
- {{ ri + 1 }}
5455
+ {{ rowOffset + ri + 1 }}
5038
5456
  <span
5039
5457
  class="q-spreadsheet__row-resize"
5040
5458
  title="Resize row"
@@ -5070,47 +5488,111 @@ defineExpose({
5070
5488
  @dblclick="startEdit(ri, col.name)"
5071
5489
  @contextmenu="onCellContext(ri, col.name, $event)"
5072
5490
  >
5073
- <!-- booléen -->
5074
- <button
5075
- v-if="col.type === 'boolean'"
5076
- type="button"
5077
- class="q-spreadsheet__checkbox"
5078
- :class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
5079
- :disabled="isCellLocked(ri, col.name)"
5080
- aria-label="Toggle"
5081
- @click.stop="toggleBoolean(ri, col.name)"
5082
- >
5083
- <Icon v-if="cellValue(ri, col.name)" :icon="icons.check" aria-hidden="true" />
5084
- </button>
5085
-
5086
- <!-- select : badge coloré -->
5087
- <span
5088
- v-else-if="col.type === 'select' && badgeOf(col, cellValue(ri, col.name))"
5089
- class="q-spreadsheet__badge"
5090
- :style="badgeOf(col, cellValue(ri, col.name))!.style"
5091
- >
5092
- {{ badgeOf(col, cellValue(ri, col.name))!.label }}
5093
- </span>
5094
-
5095
- <!-- multiselect : une série de badges colorés -->
5096
- <span
5097
- v-else-if="col.type === 'multiselect' && multiBadges(col, cellValue(ri, col.name)).length"
5098
- class="q-spreadsheet__badges"
5099
- >
5491
+ <!-- contenu personnalisé : `#cell-<nom>` (prioritaire), puis `#cell` -->
5492
+ <slot
5493
+ v-if="$slots['cell-' + col.name]"
5494
+ :name="'cell-' + col.name"
5495
+ v-bind="cellSlotProps(ri, col, ci)"
5496
+ />
5497
+ <slot
5498
+ v-else-if="$slots.cell"
5499
+ name="cell"
5500
+ v-bind="cellSlotProps(ri, col, ci)"
5501
+ />
5502
+ <template v-else>
5503
+ <!-- booléen -->
5504
+ <button
5505
+ v-if="col.type === 'boolean'"
5506
+ type="button"
5507
+ class="q-spreadsheet__checkbox"
5508
+ :class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
5509
+ :disabled="isCellLocked(ri, col.name)"
5510
+ aria-label="Toggle"
5511
+ @click.stop="toggleBoolean(ri, col.name)"
5512
+ >
5513
+ <Icon v-if="cellValue(ri, col.name)" :icon="icons.check" aria-hidden="true" />
5514
+ </button>
5515
+
5516
+ <!-- fichier joint : vignette (images) ou icône typée + nom + retrait -->
5517
+ <span
5518
+ v-else-if="col.type === 'attachment'"
5519
+ class="q-spreadsheet__attachments"
5520
+ >
5521
+ <span
5522
+ v-for="att in attachmentsOf(cellValue(ri, col.name))"
5523
+ :key="att.id"
5524
+ class="q-spreadsheet__attachment"
5525
+ :title="attachmentTitle(att)"
5526
+ role="button"
5527
+ tabindex="-1"
5528
+ @click="emitAttachmentClick(ri, col.name, att, $event)"
5529
+ @dblclick.stop
5530
+ >
5531
+ <img
5532
+ v-if="att.url && att.type.startsWith('image/')"
5533
+ :src="att.url"
5534
+ class="q-spreadsheet__attachment-thumb"
5535
+ :alt="att.name"
5536
+ />
5537
+ <Icon
5538
+ v-else
5539
+ :icon="attachmentIcon(att)"
5540
+ class="q-spreadsheet__attachment-icon"
5541
+ aria-hidden="true"
5542
+ />
5543
+ <span class="q-spreadsheet__attachment-name">{{ att.name }}</span>
5544
+ <button
5545
+ v-if="!isCellLocked(ri, col.name)"
5546
+ type="button"
5547
+ class="q-spreadsheet__attachment-x"
5548
+ :aria-label="`Remove ${att.name}`"
5549
+ @click.stop="removeAttachment(ri, col.name, att.id)"
5550
+ @dblclick.stop
5551
+ >
5552
+ <Icon :icon="icons.x" aria-hidden="true" />
5553
+ </button>
5554
+ </span>
5555
+ <button
5556
+ v-if="!isCellLocked(ri, col.name)"
5557
+ type="button"
5558
+ class="q-spreadsheet__attachment-add"
5559
+ :aria-label="col.multiple ? 'Add files' : 'Add a file'"
5560
+ @click.stop="openAttachments(ri, col.name)"
5561
+ @dblclick.stop
5562
+ >
5563
+ <Icon :icon="icons.plus" aria-hidden="true" />
5564
+ </button>
5565
+ </span>
5566
+
5567
+ <!-- select : badge coloré -->
5100
5568
  <span
5101
- v-for="(badge, bi) in multiBadges(col, cellValue(ri, col.name))"
5102
- :key="bi"
5569
+ v-else-if="col.type === 'select' && badgeOf(col, cellValue(ri, col.name))"
5103
5570
  class="q-spreadsheet__badge"
5104
- :style="badge.style"
5105
- >{{ badge.label }}</span>
5106
- </span>
5571
+ :style="badgeOf(col, cellValue(ri, col.name))!.style"
5572
+ >
5573
+ {{ badgeOf(col, cellValue(ri, col.name))!.label }}
5574
+ </span>
5107
5575
 
5108
- <!-- valeur -->
5109
- <span
5110
- v-else
5111
- class="q-spreadsheet__cell-text"
5112
- :class="cellTextWrapClass(ri, col.name)"
5113
- >{{ cellContent(ri, col) }}</span>
5576
+ <!-- multiselect : une série de badges colorés -->
5577
+ <span
5578
+ v-else-if="col.type === 'multiselect' && multiBadges(col, cellValue(ri, col.name)).length"
5579
+ class="q-spreadsheet__badges"
5580
+ >
5581
+ <span
5582
+ v-for="(badge, bi) in multiBadges(col, cellValue(ri, col.name))"
5583
+ :key="bi"
5584
+ class="q-spreadsheet__badge"
5585
+ :style="badge.style"
5586
+ >{{ badge.label }}</span>
5587
+ </span>
5588
+
5589
+ <!-- valeur -->
5590
+ <span
5591
+ v-else
5592
+ class="q-spreadsheet__cell-text"
5593
+ :class="cellTextWrapClass(ri, col.name)"
5594
+ >{{ cellContent(ri, col) }}</span>
5595
+ </template>
5114
5596
  </td>
5115
5597
  </tr>
5116
5598
  <template v-if="virtualOn && bottomPad > 0">
@@ -5142,6 +5624,16 @@ defineExpose({
5142
5624
  @dblclick.stop.prevent="fillHandleDbl"
5143
5625
  />
5144
5626
 
5627
+ <!-- Sélecteur de fichiers des cellules `attachment` (input natif, masqué) -->
5628
+ <input
5629
+ ref="attachInputEl"
5630
+ type="file"
5631
+ class="q-spreadsheet__attach-input"
5632
+ tabindex="-1"
5633
+ aria-hidden="true"
5634
+ @change="onAttachmentsChange"
5635
+ />
5636
+
5145
5637
  <!-- ═══════ Éditeur overlay ═══════ -->
5146
5638
  <div
5147
5639
  v-if="editing && editingCol"
@@ -5297,6 +5789,30 @@ defineExpose({
5297
5789
  {{ changesLabel }}
5298
5790
  </span>
5299
5791
  <span class="q-spreadsheet__status-spacer" />
5792
+ <span v-if="paginationOn" class="q-spreadsheet__pager">
5793
+ <button
5794
+ class="q-spreadsheet__tool"
5795
+ type="button"
5796
+ :disabled="loading || currentPage <= 1"
5797
+ :title="t('pagePrev')"
5798
+ :aria-label="t('pagePrev')"
5799
+ @click="goPage(currentPage - 1)"
5800
+ >
5801
+ <Icon :icon="icons.chevronLeft" aria-hidden="true" />
5802
+ </button>
5803
+ <span class="q-spreadsheet__pager-range">{{ pageRangeLabel }}</span>
5804
+ <span class="q-spreadsheet__pager-page">{{ pageLabel }}</span>
5805
+ <button
5806
+ class="q-spreadsheet__tool"
5807
+ type="button"
5808
+ :disabled="loading || currentPage >= pageCount"
5809
+ :title="t('pageNext')"
5810
+ :aria-label="t('pageNext')"
5811
+ @click="goPage(currentPage + 1)"
5812
+ >
5813
+ <Icon :icon="icons.chevronRight" aria-hidden="true" />
5814
+ </button>
5815
+ </span>
5300
5816
  <span v-if="multiMode" class="q-spreadsheet__status-item">{{ statusSheets }}</span>
5301
5817
  <span class="q-spreadsheet__status-zoom">
5302
5818
  <button class="q-spreadsheet__tool" type="button" title="Zoom out" aria-label="Zoom out" @click="zoomOut">
@@ -5311,6 +5827,18 @@ defineExpose({
5311
5827
  </span>
5312
5828
  </div>
5313
5829
 
5830
+ <!--
5831
+ Chargement distant (pagination, rechargement…) : voile sur **toute** la carte.
5832
+ Posé ici (et non dans `.q-spreadsheet__viewport`) : un `absolute` dans le conteneur
5833
+ scrollable défilerait avec le contenu — le voile sortirait du cadre visible.
5834
+ -->
5835
+ <QInnerLoading :showing="loading">
5836
+ <slot name="loading">
5837
+ <span class="q-spinner-ring q-spreadsheet__loader" aria-hidden="true" />
5838
+ <span class="q-inner-loading__label">{{ loadingLabel || t('loading') }}</span>
5839
+ </slot>
5840
+ </QInnerLoading>
5841
+
5314
5842
  <!-- ═══════ Menu contextuel (clic droit) ═══════ -->
5315
5843
  <Teleport to="body">
5316
5844
  <div
@@ -5321,7 +5849,7 @@ defineExpose({
5321
5849
  @contextmenu.prevent
5322
5850
  >
5323
5851
  <div class="q-spreadsheet__ctx-title">
5324
- <span v-if="ctxMenu.kind === 'row' && sel">{{ t('rowLbl') }} {{ sel.row + 1 }}</span>
5852
+ <span v-if="ctxMenu.kind === 'row' && sel">{{ t('rowLbl') }} {{ rowOffset + sel.row + 1 }}</span>
5325
5853
  <span v-else-if="ctxMenu.kind === 'col' && sel">{{ t('colLbl') }} {{ colLetter(colIndex(sel.column)) }}</span>
5326
5854
  <span v-else>{{ selLabel || t('cellLbl') }}</span>
5327
5855
  </div>