@dnax/ui 0.0.75 → 0.0.77

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,12 @@ 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 { fxTextFor, searchTextFor } from "../lib/spreadsheetDisplay"
238
+ import { cellFontFor, cellSizePx, cellWidthFor, headerMetricsFor } from "../lib/cellSize"
239
+ import type { CellSizePreset } from "../lib/cellSize"
240
+ import { attachmentsOf as fileAttachmentsOf, fileIconKind, formatFileSize, isAttachment, selectFiles } from "../lib/fileList"
241
+ import type { FileIconKind } from "../lib/fileList"
203
242
  import { normalizeCellOptions } from "../lib/spreadsheetOptions"
204
243
  import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
205
244
  import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
@@ -213,6 +252,25 @@ import {
213
252
  interface Props {
214
253
  /** Lignes (v-model:rows) — un objet par ligne, clés = noms de colonnes */
215
254
  rows?: Record<string, any>[]
255
+ /**
256
+ * Pagination **distante** : lignes par page (0 = désactivée). `rows` doit contenir
257
+ * **uniquement la page courante** — l'app recharge sur `page-change`.
258
+ */
259
+ pageSize?: number
260
+ /** Page courante (1-based) — `v-model:page`. Sans binding, l'état est interne au composant */
261
+ page?: number
262
+ /**
263
+ * Total de lignes, toutes pages confondues. Active le compteur `page / N` et la plage
264
+ * « lignes 401–800 sur 12 000 ». À défaut : le nombre de lignes fournies.
265
+ */
266
+ totalRows?: number
267
+ /**
268
+ * Chargement **distant** en cours (pagination, rechargement…) : un voile recouvre la
269
+ * grille — la barre d'état et le pager restent visibles, flèches désactivées.
270
+ */
271
+ loading?: boolean
272
+ /** Libellé du chargement (défaut : i18n « Loading… ») */
273
+ loadingLabel?: string
216
274
  /** Colonnes ({ name, label, width, type, editable, options, chip, format, align, … }) */
217
275
  columns?: QSpreadsheetColumn[]
218
276
  /** Hauteur du conteneur scrollable (nombre px ou chaîne CSS, défaut "360px") */
@@ -221,6 +279,13 @@ interface Props {
221
279
  rowHeight?: number
222
280
  /** Largeur de colonne par défaut en px (défaut 160) */
223
281
  defaultColWidth?: number
282
+ /**
283
+ * Taille des cellules — preset `sm` (28px) · `md` (32px, défaut) · `lg` (40px), ou une
284
+ * hauteur en px (`:size-cell="48"`, `66`…). Pilote la hauteur de ligne, la largeur de
285
+ * colonne par défaut (ratio 5:1) et la police des cellules. `rowHeight` /
286
+ * `defaultColWidth` restent prioritaires s'ils sont fournis.
287
+ */
288
+ sizeCell?: CellSizePreset | number
224
289
  /** Affiche la barre d'outils */
225
290
  showToolbar?: boolean
226
291
  /** Affiche la barre de formule (fx) au-dessus de la grille */
@@ -325,11 +390,15 @@ interface Props {
325
390
  }
326
391
 
327
392
  const props = withDefaults(defineProps<Props>(), {
393
+ pageSize: 0,
394
+ totalRows: 0,
395
+ loading: false,
328
396
  rows: () => [],
329
397
  columns: () => [],
330
398
  height: "360px",
331
- rowHeight: 32,
332
- defaultColWidth: 160,
399
+ rowHeight: undefined,
400
+ defaultColWidth: undefined,
401
+ sizeCell: undefined,
333
402
  showToolbar: true,
334
403
  showFormulaBar: true,
335
404
  frozenRows: 0,
@@ -435,6 +504,11 @@ const I18N = {
435
504
  sheetsDeleted: "{n} sheet(s) deleted",
436
505
  formatted: "formatting",
437
506
  empty: "…",
507
+ pagePrev: "Previous page",
508
+ pageNext: "Next page",
509
+ pageRange: "Rows {from}–{to} of {total}",
510
+ pageLabel: "{page} / {count}",
511
+ loading: "Loading…",
438
512
  },
439
513
  fr: {
440
514
  sheet: "Feuille",
@@ -516,6 +590,11 @@ const I18N = {
516
590
  sheetsDeleted: "{n} feuille(s) supprimée(s)",
517
591
  formatted: "mise en forme",
518
592
  empty: "…",
593
+ pagePrev: "Page précédente",
594
+ pageNext: "Page suivante",
595
+ pageRange: "Lignes {from}–{to} sur {total}",
596
+ pageLabel: "{page} / {count}",
597
+ loading: "Chargement…",
519
598
  },
520
599
  } as const
521
600
  type UiKey = keyof (typeof I18N)["en"]
@@ -535,13 +614,80 @@ const emit = defineEmits<{
535
614
  "cell-change": [payload: { row: number; column: string; oldValue: any; newValue: any; rows: Record<string, any>[] }]
536
615
  "cell-edit-start": [payload: { row: number; column: string }]
537
616
  "cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
617
+ /**
618
+ * Fichiers choisis dans une cellule `attachment`. `files` = fichiers **ajoutés**
619
+ * (à uploader), `attachments` = état complet de la cellule, `rejected` = refusés
620
+ * (type / taille / quota). Les `QSpreadsheetAttachment` ne portent **pas** le `File`.
621
+ */
622
+ "files": [payload: {
623
+ row: number
624
+ column: string
625
+ rowIndex: number
626
+ files: File[]
627
+ attachments: QSpreadsheetAttachment[]
628
+ rejected: { file: File; reason: "type" | "size" | "count" }[]
629
+ }]
630
+ /**
631
+ * Clic sur un fichier joint : à l'app d'ouvrir son aperçu (modal, visionneuse PDF…).
632
+ * Rien n'est ouvert par le composant — on fournit la cellule **et** la ligne complète.
633
+ */
634
+ "attachment-click": [payload: {
635
+ /** Index de la ligne */
636
+ row: number
637
+ /** Nom de la colonne */
638
+ column: string
639
+ /** Fichier cliqué */
640
+ attachment: QSpreadsheetAttachment
641
+ /** Tous les fichiers de la cellule */
642
+ attachments: QSpreadsheetAttachment[]
643
+ /** Valeur brute de la cellule */
644
+ value: any
645
+ /** Toute la ligne (toutes les colonnes) */
646
+ rowData: Record<string, any>
647
+ event: MouseEvent
648
+ }]
538
649
  "selection-change": [value: QSpreadsheetSelection | null]
539
650
  "structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
540
651
  "prefill": [payload: { count: number; mode: "auto" | "manual" }]
541
652
  "update:dirty": [value: boolean]
542
653
  "update:changes": [value: QSpreadsheetChanges]
654
+ "update:page": [value: number]
655
+ /**
656
+ * Changement de page — c'est ici que l'app **charge** les données de la page demandée.
657
+ * `offset` = index global de la première ligne de la nouvelle page (0-based).
658
+ */
659
+ "page-change": [payload: {
660
+ page: number
661
+ previousPage: number
662
+ pageSize: number
663
+ offset: number
664
+ totalRows: number
665
+ pageCount: number
666
+ }]
543
667
  }>()
544
668
 
669
+ // ─── Taille des cellules (`sizeCell`) ───
670
+ // Une seule échelle pilote tout : hauteur de ligne → largeur de colonne par défaut
671
+ // (ratio 5:1) → police des cellules. `rowHeight` / `defaultColWidth` restent prioritaires
672
+ // s'ils sont fournis (API historique).
673
+ const cellHeightPx = computed(() => props.rowHeight ?? cellSizePx(props.sizeCell))
674
+ const cellWidthPx = computed(() => props.defaultColWidth ?? cellWidthFor(cellHeightPx.value))
675
+ const cellFontPx = computed(() => cellFontFor(cellHeightPx.value))
676
+ /** En-têtes accordés aux cellules (hauteur, gouttière, polices) */
677
+ const headerMetrics = computed(() => headerMetricsFor(cellHeightPx.value))
678
+
679
+ /** Variables CSS de la grille (police des cellules + hauteur de repli) */
680
+ const cellSizeStyle = computed(() => ({
681
+ "--q-spreadsheet-font": `${cellFontPx.value}px`,
682
+ "--q-spreadsheet-row-height": `${cellHeightPx.value}px`,
683
+ // En-têtes : mêmes proportions que les cellules
684
+ "--q-spreadsheet-head-height": `${headerMetrics.value.height}px`,
685
+ "--q-spreadsheet-head-font": `${headerMetrics.value.labelFont}px`,
686
+ "--q-spreadsheet-head-letter-font": `${headerMetrics.value.letterFont}px`,
687
+ "--q-spreadsheet-row-font": `${headerMetrics.value.rowFont}px`,
688
+ "--q-spreadsheet-gutter-width": `${headerMetrics.value.gutter}px`,
689
+ }))
690
+
545
691
  // ─── Clés de ligne (`_key`) ───
546
692
  /**
547
693
  * Propriété injectée automatiquement sur chaque ligne : une clé stable (uuid) qui
@@ -605,6 +751,81 @@ const pushRows = (next: Record<string, any>[]) => {
605
751
  emit("update:rows", next)
606
752
  }
607
753
 
754
+ // ─── Pagination (distante) ───
755
+ // `rows` contient **la page courante** : le tableau ne découpe rien lui-même (les index
756
+ // internes — sélection, édition, historique — restent locaux à la page). La numérotation
757
+ // des lignes et le pager sont décalés de `rowOffset`.
758
+ const pageSizeN = computed(() => Math.max(0, Math.trunc(props.pageSize || 0)))
759
+ const paginationOn = computed(() => pageSizeN.value > 0)
760
+
761
+ /** Total de lignes, toutes pages : `totalRows` (distant) sinon les lignes fournies */
762
+ const totalRowsN = computed(() =>
763
+ props.totalRows && props.totalRows > 0 ? props.totalRows : state.value.length,
764
+ )
765
+
766
+ /** Page interne : repli quand `page` n'est pas lié (`v-model:page` absent) */
767
+ const localPage = ref(1)
768
+
769
+ const pageState = computed(() =>
770
+ pageInfo({
771
+ page: props.page ?? localPage.value,
772
+ pageSize: pageSizeN.value,
773
+ total: totalRowsN.value,
774
+ loaded: state.value.length,
775
+ }),
776
+ )
777
+
778
+ /** Page affichée (bornée au nombre de pages) */
779
+ const currentPage = computed(() => pageState.value.page)
780
+ const pageCount = computed(() => pageState.value.pageCount)
781
+ /** Décalage global de la première ligne affichée — sert à la numérotation */
782
+ const rowOffset = computed(() => pageState.value.offset)
783
+
784
+ const numberFormat = computed(() => new Intl.NumberFormat(lang.value))
785
+ const pageRangeLabel = computed(() => {
786
+ if (!paginationOn.value) return ""
787
+ const { from, to } = pageState.value
788
+ const nf = numberFormat.value
789
+ return fmt("pageRange", { from: nf.format(from), to: nf.format(to), total: nf.format(totalRowsN.value) })
790
+ })
791
+ const pageLabel = computed(() =>
792
+ fmt("pageLabel", { page: currentPage.value, count: pageCount.value }),
793
+ )
794
+
795
+ /** Navigation : met à jour l'état interne (repli), émet `update:page` + `page-change` */
796
+ const goPage = (next: number) => {
797
+ if (!paginationOn.value) return
798
+ const target = Math.min(Math.max(1, Math.trunc(next)), pageCount.value)
799
+ const previous = currentPage.value
800
+ localPage.value = target
801
+ if (target === previous) return
802
+ emit("update:page", target)
803
+ emit("page-change", {
804
+ page: target,
805
+ previousPage: previous,
806
+ pageSize: pageSizeN.value,
807
+ offset: (target - 1) * pageSizeN.value,
808
+ totalRows: totalRowsN.value,
809
+ pageCount: pageCount.value,
810
+ })
811
+ }
812
+
813
+ // La pagination est **distante** : des lignes au-delà de `pageSize` signifient que l'app
814
+ // n'a pas découpé. On le signale plutôt que de tronquer silencieusement.
815
+ if (typeof document !== "undefined") {
816
+ watch(
817
+ [paginationOn, pageSizeN, () => state.value.length],
818
+ () => {
819
+ if (!paginationOn.value || state.value.length <= pageSizeN.value) return
820
+ console.warn(
821
+ `[dnax/ui] QSpreadsheet : ${state.value.length} lignes pour pageSize=${pageSizeN.value}. ` +
822
+ "La pagination est distante : passez seulement la page courante (`rows`) et `total-rows`.",
823
+ )
824
+ },
825
+ { immediate: true },
826
+ )
827
+ }
828
+
608
829
  // ─── Colonnes internes ───
609
830
  const cols = ref<QSpreadsheetColumn[]>([])
610
831
  watch(
@@ -701,12 +922,14 @@ const choiceText = (col: QSpreadsheetColumn | undefined, raw: any): string => {
701
922
 
702
923
  // ─── Sélection (ancre + plage) ───
703
924
  const sel = ref<QSpreadsheetSelection | null>(null)
925
+ // `immediate` : une cellule **présélectionnée** (`:selected`) doit être active dès le premier
926
+ // rendu (SSR compris) — sans ça, elle était ignorée jusqu'au premier changement de prop.
704
927
  watch(
705
928
  () => props.selected,
706
929
  (v) => {
707
930
  if (v !== sel.value) sel.value = v ? { ...v } : null
708
931
  },
709
- { deep: true },
932
+ { deep: true, immediate: true },
710
933
  )
711
934
  const setSel = (s: QSpreadsheetSelection | null) => {
712
935
  sel.value = s
@@ -881,6 +1104,8 @@ const cellText = (row: number, column: string): string => {
881
1104
  const ev = evalAt(row, column)
882
1105
  if (isError(ev)) return ev.toString()
883
1106
  if (col?.type === "select" || col?.type === "multiselect") return "" // rendu via badge / label
1107
+ // Fichiers joints : le texte « utile » (recherche, copie, CSV, formule) = les noms
1108
+ if (col?.type === "attachment") return attachmentsOf(ev).map((att) => att.name).join(", ")
884
1109
  let out: any = ev === null || ev === undefined ? "" : ev
885
1110
  // Apostrophe littérale (paste values) : affichée sans le préfixe
886
1111
  if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
@@ -900,6 +1125,10 @@ const cellTitle = (row: number, column: string): string | undefined => {
900
1125
  const text = choiceText(col, v)
901
1126
  return text === "" ? undefined : text
902
1127
  }
1128
+ if (col?.type === "attachment") {
1129
+ const list = attachmentsOf(v)
1130
+ return list.length ? list.map((att) => attachmentTitle(att)).join("\n") : undefined
1131
+ }
903
1132
  return undefined
904
1133
  }
905
1134
 
@@ -1018,8 +1247,15 @@ const editorInputMode = computed(() => {
1018
1247
  })
1019
1248
 
1020
1249
  const startEdit = (row: number, column: string, initial?: string) => {
1250
+ if (props.loading) return
1021
1251
  const col = colOf(column)
1022
1252
  if (isCellLocked(row, column) || col?.type === "boolean") return
1253
+ // Fichier joint : pas d'éditeur texte — Entrée / double-clic ouvrent le sélecteur
1254
+ // (une simple frappe de caractère ne doit **pas** déclencher de boîte de dialogue)
1255
+ if (col?.type === "attachment") {
1256
+ if (initial === undefined) openAttachments(row, column)
1257
+ return
1258
+ }
1023
1259
  const raw = state.value[row]?.[column]
1024
1260
  if (col?.type === "select") {
1025
1261
  const opt = cellOptionsOf(col).find((o) => o.value === raw)
@@ -1093,6 +1329,8 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
1093
1329
  const raw = String(text ?? "")
1094
1330
  const t = raw.trim()
1095
1331
  if (t === "") return null
1332
+ // Fichier joint : jamais cohérent depuis du texte (saisie, collage, import) → on garde
1333
+ if (col?.type === "attachment") return old
1096
1334
  // Formule A1 : conservée brute dans la cellule (booléen/choix exclus) —
1097
1335
  // `t` (déjà trimé) évite qu'un `" =…"` ne soit traité comme une formule par
1098
1336
  // `isFormulaRaw` tout en échouant au parsing du moteur.
@@ -1181,6 +1419,155 @@ const toggleBoolean = (row: number, column: string) => {
1181
1419
  setCellValue(row, column, old, !old)
1182
1420
  }
1183
1421
 
1422
+ // ─── Fichiers joints (type `attachment`) ───
1423
+ /**
1424
+ * Valeur d'une cellule `attachment` → tableau typé. La **normalisation** (tableau,
1425
+ * fichier seul, entrées invalides ignorées) vit dans `lib/fileList.ts`, testée.
1426
+ */
1427
+ const attachmentsOf = (value: unknown): QSpreadsheetAttachment[] =>
1428
+ fileAttachmentsOf(value) as QSpreadsheetAttachment[]
1429
+
1430
+ const ATTACHMENT_ICONS: Record<FileIconKind, string> = {
1431
+ image: icons.fileImage,
1432
+ video: icons.fileVideo,
1433
+ audio: icons.fileAudio,
1434
+ archive: icons.fileArchive,
1435
+ spreadsheet: icons.fileSpreadsheet,
1436
+ code: icons.fileCode,
1437
+ file: icons.fileText,
1438
+ }
1439
+
1440
+ const attachmentIcon = (att: QSpreadsheetAttachment) => ATTACHMENT_ICONS[fileIconKind(att)]
1441
+
1442
+ const attachmentTitle = (att: QSpreadsheetAttachment) =>
1443
+ `${att.name} — ${formatFileSize(att.size)}`
1444
+
1445
+ /** Object URLs créés par le composant (jamais une URL fournie par l'app) */
1446
+ const localAttachmentUrls = new Set<string>()
1447
+
1448
+ const releaseAttachmentUrl = (url?: string) => {
1449
+ if (!url || !localAttachmentUrls.has(url)) return
1450
+ URL.revokeObjectURL(url)
1451
+ localAttachmentUrls.delete(url)
1452
+ }
1453
+
1454
+ const attachInputEl = ref<HTMLInputElement | null>(null)
1455
+ const attachTarget = ref<{ row: number; column: string } | null>(null)
1456
+
1457
+ /** Ouvre le sélecteur de fichiers d'une cellule (`accept` / `multiple` de la colonne) */
1458
+ const openAttachments = (row: number, column: string) => {
1459
+ const col = colOf(column)
1460
+ if (!col || col.type !== "attachment" || isCellLocked(row, column)) return
1461
+ const input = attachInputEl.value
1462
+ if (!input) return
1463
+ attachTarget.value = { row, column }
1464
+ input.accept = col.accept ?? ""
1465
+ // `multiple` : explicite, ou déduit du quota (`maxFiles > 1`)
1466
+ input.multiple = col.multiple === true || (col.maxFiles ?? 0) > 1
1467
+ input.value = "" // permet de re-sélectionner le même fichier
1468
+ input.click()
1469
+ }
1470
+
1471
+ const addAttachments = (row: number, column: string, incoming: File[]) => {
1472
+ const col = colOf(column)
1473
+ if (!col || col.type !== "attachment" || isCellLocked(row, column)) return
1474
+ const old = state.value[row]?.[column]
1475
+ const current = attachmentsOf(old)
1476
+ // Mono-fichier par défaut ; `multiple` (ou un quota > 1, ou un existant multiple)
1477
+ const multiple = col.multiple === true || current.length > 1 || (col.maxFiles ?? 0) > 1
1478
+ const maxFiles = col.maxFiles && col.maxFiles > 0 ? col.maxFiles : multiple ? 0 : 1
1479
+
1480
+ const { accepted, rejected } = selectFiles(incoming, {
1481
+ accept: col.accept,
1482
+ maxSize: col.maxFileSize,
1483
+ maxFiles,
1484
+ // En mono-fichier le nouveau remplace l'ancien : il ne consomme pas de quota
1485
+ current: multiple ? current.length : 0,
1486
+ })
1487
+
1488
+ if (accepted.length === 0) {
1489
+ if (rejected.length) {
1490
+ emit("files", { row, column, rowIndex: row, files: [], attachments: current, rejected })
1491
+ }
1492
+ return
1493
+ }
1494
+
1495
+ const added: QSpreadsheetAttachment[] = accepted.map((file) => {
1496
+ const url =
1497
+ typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
1498
+ ? URL.createObjectURL(file)
1499
+ : undefined
1500
+ if (url) localAttachmentUrls.add(url)
1501
+ return { id: newRowKey(), name: file.name, size: file.size, type: file.type, url }
1502
+ })
1503
+
1504
+ let next: QSpreadsheetAttachment[]
1505
+ if (multiple) {
1506
+ next = [...current, ...added]
1507
+ } else {
1508
+ // Remplacement : les object URLs de l'ancien fichier sont libérés
1509
+ for (const att of current) releaseAttachmentUrl(att.url)
1510
+ next = added.slice(0, 1)
1511
+ }
1512
+
1513
+ pushHistory()
1514
+ setCellValue(row, column, old, next)
1515
+ emit("files", { row, column, rowIndex: row, files: accepted, attachments: next, rejected })
1516
+ }
1517
+
1518
+ const removeAttachment = (row: number, column: string, id: string) => {
1519
+ if (isCellLocked(row, column)) return
1520
+ const old = state.value[row]?.[column]
1521
+ const current = attachmentsOf(old)
1522
+ const target = current.find((att) => att.id === id)
1523
+ if (!target) return
1524
+ releaseAttachmentUrl(target.url)
1525
+ const next = current.filter((att) => att.id !== id)
1526
+ pushHistory()
1527
+ setCellValue(row, column, old, next.length ? next : null)
1528
+ }
1529
+
1530
+ const onAttachmentsChange = (e: Event) => {
1531
+ const input = e.target as HTMLInputElement
1532
+ const files = Array.from(input.files ?? [])
1533
+ input.value = "" // permet de re-sélectionner le même fichier
1534
+ const target = attachTarget.value
1535
+ attachTarget.value = null
1536
+ if (!target || files.length === 0) return
1537
+ addAttachments(target.row, target.column, files)
1538
+ }
1539
+
1540
+ /**
1541
+ * Clic sur une puce de fichier : le composant n'ouvre **rien** lui-même (il ne connaît pas
1542
+ * la visionneuse de l'app) — il émet `attachment-click` avec la cellule et la ligne
1543
+ * complète. Le clic sur le `×` (retrait) est stoppé et ne passe pas ici.
1544
+ */
1545
+ const emitAttachmentClick = (
1546
+ row: number,
1547
+ column: string,
1548
+ attachment: QSpreadsheetAttachment,
1549
+ event: MouseEvent,
1550
+ ) => {
1551
+ const value = state.value[row]?.[column]
1552
+ emit("attachment-click", {
1553
+ row,
1554
+ column,
1555
+ attachment,
1556
+ attachments: attachmentsOf(value),
1557
+ value,
1558
+ rowData: state.value[row] ?? {},
1559
+ event,
1560
+ })
1561
+ }
1562
+
1563
+ /** Démontage : libère les object URLs encore en vie (l'app garde les siens) */
1564
+ onBeforeUnmount(() => {
1565
+ for (const url of [...localAttachmentUrls]) {
1566
+ URL.revokeObjectURL(url)
1567
+ localAttachmentUrls.delete(url)
1568
+ }
1569
+ })
1570
+
1184
1571
  const clearSelection = () => {
1185
1572
  const rect = selRect.value
1186
1573
  if (!rect) return
@@ -1190,8 +1577,13 @@ const clearSelection = () => {
1190
1577
  const col = cols.value[c]
1191
1578
  if (!col || isCellLocked(r, col.name)) continue
1192
1579
  const v = state.value[r]?.[col.name]
1193
- if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
1580
+ if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0)) {
1581
+ // Effacer une cellule `attachment` libère ses object URLs locales
1582
+ if (col.type === "attachment") {
1583
+ for (const att of attachmentsOf(v)) releaseAttachmentUrl(att.url)
1584
+ }
1194
1585
  changed.push({ row: r, column: col.name })
1586
+ }
1195
1587
  }
1196
1588
  }
1197
1589
  if (!changed.length) return
@@ -1512,6 +1904,10 @@ const sortByColumn = (column?: string, desc = false) => {
1512
1904
  // `label` peut être numérique → toString avant comparaison ; liste → libellés joints
1513
1905
  return choiceText(col, raw).toLowerCase()
1514
1906
  }
1907
+ // Fichier joint : tri sur les noms (jamais `[object Object]`)
1908
+ if (col?.type === "attachment") {
1909
+ return attachmentsOf(raw).map((att) => att.name).join(", ").toLowerCase()
1910
+ }
1515
1911
  return String(ev).toLowerCase()
1516
1912
  }
1517
1913
  const sortedIdx = state.value
@@ -1606,7 +2002,7 @@ const jumpPage = (dir: 1 | -1, extend = false) => {
1606
2002
  if (!sel.value || !visibleRows.value.length) return
1607
2003
  let idx = visibleRows.value.indexOf(sel.value.row)
1608
2004
  if (idx < 0) idx = 0
1609
- const page = Math.max(1, Math.floor(viewH.value / props.rowHeight))
2005
+ const page = Math.max(1, Math.floor(viewH.value / cellHeightPx.value))
1610
2006
  let target = idx
1611
2007
  for (let k = 0; k < page; k++) {
1612
2008
  const n = nextVisibleIndex(target, dir)
@@ -1712,6 +2108,8 @@ const fillRightKey = () => {
1712
2108
 
1713
2109
  // ─── Clavier global (navigation / édition) ───
1714
2110
  const onKeydown = (e: KeyboardEvent) => {
2111
+ // Données en cours de chargement : on ne navigue ni n'édite une page périmée
2112
+ if (props.loading) return
1715
2113
  if (editing.value) return
1716
2114
  if (!sel.value || !cols.value.length) return
1717
2115
  const mod = e.ctrlKey || e.metaKey
@@ -1952,13 +2350,17 @@ const fxInputEl = ref<HTMLInputElement | null>(null)
1952
2350
 
1953
2351
  const syncFx = () => {
1954
2352
  const s = sel.value
2353
+ const col = s ? colOf(s.column) : undefined
1955
2354
  const raw =
1956
2355
  s && s.row >= 0 && s.row < state.value.length
1957
2356
  ? state.value[s.row]?.[s.column]
1958
2357
  : undefined
1959
- fxDraft.value = raw === null || raw === undefined ? "" : String(raw)
2358
+ // Fichier joint : rien à montrer en fx (`String(raw)` affichait « [object Object]… »)
2359
+ fxDraft.value = fxTextFor(raw, col?.type)
1960
2360
  }
1961
- watch([sel, state], syncFx)
2361
+ // `immediate` : avec une cellule présélectionnée (`selected`), la barre fx affiche la
2362
+ // valeur dès le premier rendu (et en SSR) au lieu de rester vide jusqu'au premier clic.
2363
+ watch([sel, state], syncFx, { immediate: true })
1962
2364
 
1963
2365
  const fxCanEdit = computed(() => {
1964
2366
  if (!sel.value) return false
@@ -1969,10 +2371,17 @@ const fxCanEdit = computed(() => {
1969
2371
  !!col &&
1970
2372
  col.type !== "boolean" &&
1971
2373
  col.type !== "select" &&
1972
- col.type !== "multiselect"
2374
+ col.type !== "multiselect" &&
2375
+ col.type !== "attachment"
1973
2376
  )
1974
2377
  })
1975
2378
 
2379
+ /** Placeholder de la barre fx : vide pour un fichier joint (rien à éditer ni à montrer) */
2380
+ const fxPlaceholderText = computed(() => {
2381
+ if (sel.value && colOf(sel.value.column)?.type === "attachment") return ""
2382
+ return fxCanEdit.value ? t("fxPlaceholder") : t("fxNoSel")
2383
+ })
2384
+
1976
2385
  /**
1977
2386
  * Aperçu **live** du résultat de la formule en cours de saisie dans la barre fx.
1978
2387
  * Évaluée dans le contexte de la ligne sélectionnée (références par nom de colonne
@@ -2294,7 +2703,7 @@ const acceptFxAt = (i: number) => {
2294
2703
 
2295
2704
  // ════════ Hauteurs de lignes (resize au drag sur le numéro) ════════
2296
2705
  const rowHeights = ref<Record<number, number>>({})
2297
- const rowH = (r: number) => rowHeights.value[r] ?? props.rowHeight
2706
+ const rowH = (r: number) => rowHeights.value[r] ?? cellHeightPx.value
2298
2707
 
2299
2708
  const rowResizing = ref<{ r: number; y: number; h: number } | null>(null)
2300
2709
  const startRowResize = (e: PointerEvent, r: number) => {
@@ -2446,13 +2855,13 @@ const clearFormatSelection = () => {
2446
2855
  // ════════ Freeze panes (gel lignes / colonnes) ════════
2447
2856
  const frozenRowsN = computed(() => Math.max(0, Math.min(props.frozenRows ?? 0, state.value.length)))
2448
2857
  const frozenColsN = computed(() => Math.max(0, Math.min(props.frozenCols ?? 0, cols.value.length)))
2449
- const gutterW = computed(() => (props.showRowNumbers ? 34 : 0))
2450
- const headH = computed(() => (props.showColumnHeaders ? 28 : 0))
2858
+ const gutterW = computed(() => (props.showRowNumbers ? headerMetrics.value.gutter : 0))
2859
+ const headH = computed(() => (props.showColumnHeaders ? headerMetrics.value.height : 0))
2451
2860
  const colWNum = (ci: number) => {
2452
2861
  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
2862
+ if (!c) return cellWidthPx.value
2863
+ const w = colWidths.value[c.name] ?? c.width ?? cellWidthPx.value
2864
+ return typeof w === "number" ? w : parseFloat(w) || cellWidthPx.value
2456
2865
  }
2457
2866
  const frozenLeftOf = (ci: number) => {
2458
2867
  let l = gutterW.value
@@ -2505,7 +2914,9 @@ const filters = ref<Record<string, string[] | null>>({})
2505
2914
  const filterValuesOf = (raw: any): any[] => {
2506
2915
  if (Array.isArray(raw)) {
2507
2916
  const values = raw.filter((v) => !isBlankValue(v))
2508
- return values.length ? values : [null]
2917
+ // Fichier joint : la valeur filtrante est le **nom** du fichier, pas l'objet
2918
+ const mapped = values.map((v) => (isAttachment(v) ? v.name : v))
2919
+ return mapped.length ? mapped : [null]
2509
2920
  }
2510
2921
  return isBlankValue(raw) ? [null] : [raw]
2511
2922
  }
@@ -2668,7 +3079,7 @@ const updateRange = () => {
2668
3079
  }
2669
3080
  const start = Math.max(0, lo - VIRTUAL_OVERSCAN)
2670
3081
  let end = start
2671
- while (end < n && info.rows[end]!.top < bottom + VIRTUAL_OVERSCAN * props.rowHeight) end++
3082
+ while (end < n && info.rows[end]!.top < bottom + VIRTUAL_OVERSCAN * cellHeightPx.value) end++
2672
3083
  end = Math.min(n - 1, end + VIRTUAL_OVERSCAN)
2673
3084
  renderRange.value = { start, end }
2674
3085
  topPad.value = Math.max(0, info.rows[start]!.top - headH.value)
@@ -2692,7 +3103,7 @@ const ensureRowVisible = (realRow: number) => {
2692
3103
  // scroll-behavior: smooth (CSS) anime la remontée / descente ; le focus est
2693
3104
  // différé jusqu'à ce que la ligne soit effectivement rendue par la fenêtre
2694
3105
  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)
3106
+ el.scrollTop = Math.max(0, info.top - headH.value - VIRTUAL_OVERSCAN * cellHeightPx.value)
2696
3107
  updateRange()
2697
3108
  }
2698
3109
 
@@ -2902,7 +3313,7 @@ const insertColumnAt = (pos: "left" | "right") => {
2902
3313
  const nextCols = [...cols.value]
2903
3314
  nextCols.splice(idx, 0, col)
2904
3315
  cols.value = nextCols
2905
- colWidths.value = { ...colWidths.value, [name]: props.defaultColWidth }
3316
+ colWidths.value = { ...colWidths.value, [name]: cellWidthPx.value }
2906
3317
  const nextRows = state.value.map((r) => ({ ...r, [name]: "" }))
2907
3318
  state.value = nextRows
2908
3319
  emit("update:columns", nextCols)
@@ -3173,13 +3584,13 @@ const isActiveRow = (r: number) => {
3173
3584
  const colWidths = ref<Record<string, string | number>>({})
3174
3585
  const syncColWidths = () => {
3175
3586
  const next: Record<string, string | number> = {}
3176
- for (const c of cols.value) next[c.name] = c.width ?? props.defaultColWidth
3587
+ for (const c of cols.value) next[c.name] = c.width ?? cellWidthPx.value
3177
3588
  colWidths.value = next
3178
3589
  }
3179
3590
  watch(cols, syncColWidths, { immediate: true, deep: true })
3180
3591
 
3181
3592
  const widthStyle = (col: QSpreadsheetColumn) => {
3182
- const w = colWidths.value[col.name] ?? col.width ?? props.defaultColWidth
3593
+ const w = colWidths.value[col.name] ?? col.width ?? cellWidthPx.value
3183
3594
  return typeof w === "number" ? w + "px" : w
3184
3595
  }
3185
3596
  const minW = (col: QSpreadsheetColumn) => (col.minWidth ?? 60) + "px"
@@ -3195,7 +3606,7 @@ const startResize = (e: PointerEvent, col: QSpreadsheetColumn) => {
3195
3606
  resizing.value = {
3196
3607
  col,
3197
3608
  x: e.clientX,
3198
- w: typeof current === "number" ? current : props.defaultColWidth,
3609
+ w: typeof current === "number" ? current : cellWidthPx.value,
3199
3610
  }
3200
3611
  window.addEventListener("pointermove", onResizeMove)
3201
3612
  window.addEventListener("pointerup", endResize)
@@ -3335,13 +3746,28 @@ const cellContent = (row: number, col: QSpreadsheetColumn) => {
3335
3746
  return cellText(row, col.name)
3336
3747
  }
3337
3748
 
3749
+ /**
3750
+ * Props des slots de cellule (`#cell-<nom>`, `#cell`) : tout ce qu'un composant
3751
+ * personnalisé peut vouloir, sans que le template ait à connaître l'API interne.
3752
+ * `setValue` passe par l'historique et les événements (`cell-change`, `update:rows`).
3753
+ */
3754
+ const cellSlotProps = (row: number, col: QSpreadsheetColumn, columnIndex: number) => ({
3755
+ row: state.value[row] ?? {},
3756
+ rowIndex: row,
3757
+ column: col,
3758
+ columnIndex,
3759
+ value: cellValue(row, col.name),
3760
+ text: cellText(row, col.name),
3761
+ setValue: (next: any) => setCellValue(row, col.name, state.value[row]?.[col.name], next),
3762
+ })
3763
+
3338
3764
  const editingCol = computed(() => (editing.value ? colOf(editing.value.column) : undefined))
3339
3765
 
3340
3766
  const selLabel = computed(() => {
3341
3767
  if (!sel.value) return ""
3342
3768
  const ci = colIndex(sel.value.column)
3343
3769
  if (ci === -1) return ""
3344
- return colLetter(ci) + (sel.value.row + 1)
3770
+ return colLetter(ci) + (rowOffset.value + sel.value.row + 1)
3345
3771
  })
3346
3772
 
3347
3773
  const optionIsActive = (opt: QSpreadsheetCellOption) => activeOption.value === opt
@@ -3369,7 +3795,7 @@ const switching = ref(false)
3369
3795
 
3370
3796
  const makeExtras = (cols: QSpreadsheetColumn[]): SheetExtras => {
3371
3797
  const widths: Record<string, number | string> = {}
3372
- for (const c of cols) widths[c.name] = c.width ?? props.defaultColWidth
3798
+ for (const c of cols) widths[c.name] = c.width ?? cellWidthPx.value
3373
3799
  return {
3374
3800
  cols: cols.map((c) => ({ ...c })),
3375
3801
  widths,
@@ -3442,7 +3868,7 @@ const canonicalColumns = (columns: QSpreadsheetColumn[] | undefined): QSpreadshe
3442
3868
  ...c,
3443
3869
  type: c.type ?? "text",
3444
3870
  options: c.options ?? [],
3445
- width: c.width ?? props.defaultColWidth,
3871
+ width: c.width ?? cellWidthPx.value,
3446
3872
  }))
3447
3873
 
3448
3874
  watch(
@@ -3788,7 +4214,8 @@ const findTextOf = (r: number, ci: number): string => {
3788
4214
  const raw = state.value[r]?.[col.name]
3789
4215
  // `label` peut être un nombre : findScan fait `.toLowerCase()` sur le retour
3790
4216
  if (col.type === "select" || col.type === "multiselect") return choiceText(col, raw)
3791
- return raw === null || raw === undefined ? "" : String(raw)
4217
+ // Fichier joint → noms de fichiers ; jamais « [object Object] » (cf. lib/spreadsheetDisplay)
4218
+ return searchTextFor(raw, col.type)
3792
4219
  }
3793
4220
  const findScan = () => {
3794
4221
  const q = findQuery.value
@@ -4630,7 +5057,12 @@ defineExpose({
4630
5057
  </script>
4631
5058
 
4632
5059
  <template>
4633
- <div ref="rootRef" class="q-spreadsheet" :class="rootClasses" :style="radiusStyle(effectiveRadius)">
5060
+ <div
5061
+ ref="rootRef"
5062
+ class="q-spreadsheet"
5063
+ :class="rootClasses"
5064
+ :style="[radiusStyle(effectiveRadius), cellSizeStyle]"
5065
+ >
4634
5066
  <!-- ═══════ Onglets de feuilles (classeur, haut) ═══════ -->
4635
5067
  <div
4636
5068
  v-if="sheetsMode && sheetsPosition === 'top'"
@@ -4762,7 +5194,7 @@ defineExpose({
4762
5194
  class="q-spreadsheet__fx-input"
4763
5195
  :value="fxDraft"
4764
5196
  :disabled="readonly || disable"
4765
- :placeholder="fxCanEdit ? t('fxPlaceholder') : t('fxNoSel')"
5197
+ :placeholder="fxPlaceholderText"
4766
5198
  spellcheck="false"
4767
5199
  @input="onFxInput"
4768
5200
  @keyup="syncFxCaret"
@@ -4936,6 +5368,7 @@ defineExpose({
4936
5368
  class="q-spreadsheet__viewport"
4937
5369
  :style="{ height: heightStyle }"
4938
5370
  tabindex="0"
5371
+ :aria-busy="loading || undefined"
4939
5372
  @keydown="onKeydown"
4940
5373
  @scroll="onViewportScroll"
4941
5374
  @contextmenu.prevent
@@ -4977,10 +5410,18 @@ defineExpose({
4977
5410
  @click="selectCol(ci, $event.shiftKey)"
4978
5411
  @contextmenu="onColContext(ci, $event)"
4979
5412
  >
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>
5413
+ <slot
5414
+ :name="'head-' + col.name"
5415
+ :column="col"
5416
+ :column-index="ci"
5417
+ :letter="colLetter(ci)"
5418
+ :label="col.label || col.name"
5419
+ >
5420
+ <span class="q-spreadsheet__colhead-inner">
5421
+ <span class="q-spreadsheet__colhead-letter">{{ colLetter(ci) }}</span>
5422
+ <span class="q-spreadsheet__colhead-label">{{ col.label || col.name }}</span>
5423
+ </span>
5424
+ </slot>
4984
5425
  <button
4985
5426
  class="q-spreadsheet__fbtn"
4986
5427
  :class="{ 'q-spreadsheet__fbtn--on': hasFilterFor(col.name) }"
@@ -5034,7 +5475,7 @@ defineExpose({
5034
5475
  @click="selectRow(ri, $event.shiftKey)"
5035
5476
  @contextmenu="onRowContext(ri, $event)"
5036
5477
  >
5037
- {{ ri + 1 }}
5478
+ {{ rowOffset + ri + 1 }}
5038
5479
  <span
5039
5480
  class="q-spreadsheet__row-resize"
5040
5481
  title="Resize row"
@@ -5070,47 +5511,111 @@ defineExpose({
5070
5511
  @dblclick="startEdit(ri, col.name)"
5071
5512
  @contextmenu="onCellContext(ri, col.name, $event)"
5072
5513
  >
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
- >
5514
+ <!-- contenu personnalisé : `#cell-<nom>` (prioritaire), puis `#cell` -->
5515
+ <slot
5516
+ v-if="$slots['cell-' + col.name]"
5517
+ :name="'cell-' + col.name"
5518
+ v-bind="cellSlotProps(ri, col, ci)"
5519
+ />
5520
+ <slot
5521
+ v-else-if="$slots.cell"
5522
+ name="cell"
5523
+ v-bind="cellSlotProps(ri, col, ci)"
5524
+ />
5525
+ <template v-else>
5526
+ <!-- booléen -->
5527
+ <button
5528
+ v-if="col.type === 'boolean'"
5529
+ type="button"
5530
+ class="q-spreadsheet__checkbox"
5531
+ :class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
5532
+ :disabled="isCellLocked(ri, col.name)"
5533
+ aria-label="Toggle"
5534
+ @click.stop="toggleBoolean(ri, col.name)"
5535
+ >
5536
+ <Icon v-if="cellValue(ri, col.name)" :icon="icons.check" aria-hidden="true" />
5537
+ </button>
5538
+
5539
+ <!-- fichier joint : vignette (images) ou icône typée + nom + retrait -->
5540
+ <span
5541
+ v-else-if="col.type === 'attachment'"
5542
+ class="q-spreadsheet__attachments"
5543
+ >
5544
+ <span
5545
+ v-for="att in attachmentsOf(cellValue(ri, col.name))"
5546
+ :key="att.id"
5547
+ class="q-spreadsheet__attachment"
5548
+ :title="attachmentTitle(att)"
5549
+ role="button"
5550
+ tabindex="-1"
5551
+ @click="emitAttachmentClick(ri, col.name, att, $event)"
5552
+ @dblclick.stop
5553
+ >
5554
+ <img
5555
+ v-if="att.url && att.type.startsWith('image/')"
5556
+ :src="att.url"
5557
+ class="q-spreadsheet__attachment-thumb"
5558
+ :alt="att.name"
5559
+ />
5560
+ <Icon
5561
+ v-else
5562
+ :icon="attachmentIcon(att)"
5563
+ class="q-spreadsheet__attachment-icon"
5564
+ aria-hidden="true"
5565
+ />
5566
+ <span class="q-spreadsheet__attachment-name">{{ att.name }}</span>
5567
+ <button
5568
+ v-if="!isCellLocked(ri, col.name)"
5569
+ type="button"
5570
+ class="q-spreadsheet__attachment-x"
5571
+ :aria-label="`Remove ${att.name}`"
5572
+ @click.stop="removeAttachment(ri, col.name, att.id)"
5573
+ @dblclick.stop
5574
+ >
5575
+ <Icon :icon="icons.x" aria-hidden="true" />
5576
+ </button>
5577
+ </span>
5578
+ <button
5579
+ v-if="!isCellLocked(ri, col.name)"
5580
+ type="button"
5581
+ class="q-spreadsheet__attachment-add"
5582
+ :aria-label="col.multiple ? 'Add files' : 'Add a file'"
5583
+ @click.stop="openAttachments(ri, col.name)"
5584
+ @dblclick.stop
5585
+ >
5586
+ <Icon :icon="icons.plus" aria-hidden="true" />
5587
+ </button>
5588
+ </span>
5589
+
5590
+ <!-- select : badge coloré -->
5100
5591
  <span
5101
- v-for="(badge, bi) in multiBadges(col, cellValue(ri, col.name))"
5102
- :key="bi"
5592
+ v-else-if="col.type === 'select' && badgeOf(col, cellValue(ri, col.name))"
5103
5593
  class="q-spreadsheet__badge"
5104
- :style="badge.style"
5105
- >{{ badge.label }}</span>
5106
- </span>
5594
+ :style="badgeOf(col, cellValue(ri, col.name))!.style"
5595
+ >
5596
+ {{ badgeOf(col, cellValue(ri, col.name))!.label }}
5597
+ </span>
5107
5598
 
5108
- <!-- valeur -->
5109
- <span
5110
- v-else
5111
- class="q-spreadsheet__cell-text"
5112
- :class="cellTextWrapClass(ri, col.name)"
5113
- >{{ cellContent(ri, col) }}</span>
5599
+ <!-- multiselect : une série de badges colorés -->
5600
+ <span
5601
+ v-else-if="col.type === 'multiselect' && multiBadges(col, cellValue(ri, col.name)).length"
5602
+ class="q-spreadsheet__badges"
5603
+ >
5604
+ <span
5605
+ v-for="(badge, bi) in multiBadges(col, cellValue(ri, col.name))"
5606
+ :key="bi"
5607
+ class="q-spreadsheet__badge"
5608
+ :style="badge.style"
5609
+ >{{ badge.label }}</span>
5610
+ </span>
5611
+
5612
+ <!-- valeur -->
5613
+ <span
5614
+ v-else
5615
+ class="q-spreadsheet__cell-text"
5616
+ :class="cellTextWrapClass(ri, col.name)"
5617
+ >{{ cellContent(ri, col) }}</span>
5618
+ </template>
5114
5619
  </td>
5115
5620
  </tr>
5116
5621
  <template v-if="virtualOn && bottomPad > 0">
@@ -5142,6 +5647,16 @@ defineExpose({
5142
5647
  @dblclick.stop.prevent="fillHandleDbl"
5143
5648
  />
5144
5649
 
5650
+ <!-- Sélecteur de fichiers des cellules `attachment` (input natif, masqué) -->
5651
+ <input
5652
+ ref="attachInputEl"
5653
+ type="file"
5654
+ class="q-spreadsheet__attach-input"
5655
+ tabindex="-1"
5656
+ aria-hidden="true"
5657
+ @change="onAttachmentsChange"
5658
+ />
5659
+
5145
5660
  <!-- ═══════ Éditeur overlay ═══════ -->
5146
5661
  <div
5147
5662
  v-if="editing && editingCol"
@@ -5297,6 +5812,30 @@ defineExpose({
5297
5812
  {{ changesLabel }}
5298
5813
  </span>
5299
5814
  <span class="q-spreadsheet__status-spacer" />
5815
+ <span v-if="paginationOn" class="q-spreadsheet__pager">
5816
+ <button
5817
+ class="q-spreadsheet__tool"
5818
+ type="button"
5819
+ :disabled="loading || currentPage <= 1"
5820
+ :title="t('pagePrev')"
5821
+ :aria-label="t('pagePrev')"
5822
+ @click="goPage(currentPage - 1)"
5823
+ >
5824
+ <Icon :icon="icons.chevronLeft" aria-hidden="true" />
5825
+ </button>
5826
+ <span class="q-spreadsheet__pager-range">{{ pageRangeLabel }}</span>
5827
+ <span class="q-spreadsheet__pager-page">{{ pageLabel }}</span>
5828
+ <button
5829
+ class="q-spreadsheet__tool"
5830
+ type="button"
5831
+ :disabled="loading || currentPage >= pageCount"
5832
+ :title="t('pageNext')"
5833
+ :aria-label="t('pageNext')"
5834
+ @click="goPage(currentPage + 1)"
5835
+ >
5836
+ <Icon :icon="icons.chevronRight" aria-hidden="true" />
5837
+ </button>
5838
+ </span>
5300
5839
  <span v-if="multiMode" class="q-spreadsheet__status-item">{{ statusSheets }}</span>
5301
5840
  <span class="q-spreadsheet__status-zoom">
5302
5841
  <button class="q-spreadsheet__tool" type="button" title="Zoom out" aria-label="Zoom out" @click="zoomOut">
@@ -5311,6 +5850,18 @@ defineExpose({
5311
5850
  </span>
5312
5851
  </div>
5313
5852
 
5853
+ <!--
5854
+ Chargement distant (pagination, rechargement…) : voile sur **toute** la carte.
5855
+ Posé ici (et non dans `.q-spreadsheet__viewport`) : un `absolute` dans le conteneur
5856
+ scrollable défilerait avec le contenu — le voile sortirait du cadre visible.
5857
+ -->
5858
+ <QInnerLoading :showing="loading">
5859
+ <slot name="loading">
5860
+ <span class="q-spinner-ring q-spreadsheet__loader" aria-hidden="true" />
5861
+ <span class="q-inner-loading__label">{{ loadingLabel || t('loading') }}</span>
5862
+ </slot>
5863
+ </QInnerLoading>
5864
+
5314
5865
  <!-- ═══════ Menu contextuel (clic droit) ═══════ -->
5315
5866
  <Teleport to="body">
5316
5867
  <div
@@ -5321,7 +5872,7 @@ defineExpose({
5321
5872
  @contextmenu.prevent
5322
5873
  >
5323
5874
  <div class="q-spreadsheet__ctx-title">
5324
- <span v-if="ctxMenu.kind === 'row' && sel">{{ t('rowLbl') }} {{ sel.row + 1 }}</span>
5875
+ <span v-if="ctxMenu.kind === 'row' && sel">{{ t('rowLbl') }} {{ rowOffset + sel.row + 1 }}</span>
5325
5876
  <span v-else-if="ctxMenu.kind === 'col' && sel">{{ t('colLbl') }} {{ colLetter(colIndex(sel.column)) }}</span>
5326
5877
  <span v-else>{{ selLabel || t('cellLbl') }}</span>
5327
5878
  </div>