@dnax/ui 0.0.48 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QImagePicker.vue +28 -1
  3. package/components/QInputChat.vue +859 -0
  4. package/components/QInputCurrency.vue +319 -0
  5. package/components/QItemLabel.vue +99 -0
  6. package/components/QMap.vue +410 -0
  7. package/components/QNumericKeyboard.vue +304 -0
  8. package/components/QPage.vue +26 -6
  9. package/components/QPageContainer.vue +32 -0
  10. package/components/QPopupProxy.vue +378 -0
  11. package/components/QQrcode.vue +132 -0
  12. package/components/QSpreadsheet.vue +395 -69
  13. package/components/QTime.vue +845 -0
  14. package/components/QTimeline.vue +111 -0
  15. package/components/QTimelineEntry.vue +271 -0
  16. package/index.ts +11 -1
  17. package/lib/bottomSheet.test.ts +121 -0
  18. package/lib/bottomSheet.ts +115 -0
  19. package/lib/color.ts +13 -0
  20. package/lib/currency.test.ts +193 -0
  21. package/lib/currency.ts +301 -0
  22. package/lib/fixedLayout.ts +12 -11
  23. package/lib/icons.ts +3 -20
  24. package/lib/map.test.ts +365 -0
  25. package/lib/map.ts +438 -0
  26. package/lib/mapEngine.ts +158 -0
  27. package/lib/mapLeaflet.ts +142 -0
  28. package/lib/mapMaplibre.ts +106 -0
  29. package/lib/mapMaptiler.ts +93 -0
  30. package/lib/numericKeyboard.test.ts +179 -0
  31. package/lib/numericKeyboard.ts +166 -0
  32. package/lib/pagePadding.test.ts +37 -0
  33. package/lib/pagePadding.ts +32 -0
  34. package/lib/qrcode.test.ts +103 -0
  35. package/lib/qrcode.ts +125 -0
  36. package/lib/spreadsheetChanges.test.ts +230 -0
  37. package/lib/spreadsheetChanges.ts +331 -0
  38. package/package.json +5 -17
  39. package/shims.d.ts +19 -0
  40. package/styles/main.css +689 -884
  41. package/components/QTiptap.vue +0 -996
  42. package/components/internal/QTiptapTableView.vue +0 -123
  43. package/components/internal/QTiptapTaskItemView.vue +0 -49
  44. package/lib/tiptap-table.ts +0 -19
  45. package/lib/tiptap-task-list.ts +0 -16
@@ -4,7 +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, select avec badges colorés).
7
+ // (texte, nombre, entier, booléen, date, datetime, select / multiselect avec badges colorés).
8
+ // Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
9
+ // ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
10
+ // (`lib/spreadsheetChanges.ts`).
8
11
  export type QSpreadsheetCellType =
9
12
  | "string"
10
13
  | "text"
@@ -16,6 +19,7 @@ export type QSpreadsheetCellType =
16
19
  | "date"
17
20
  | "datetime"
18
21
  | "select"
22
+ | "multiselect"
19
23
 
20
24
  export interface QSpreadsheetCellOption {
21
25
  /** Valeur stockée dans la cellule */
@@ -44,9 +48,9 @@ export interface QSpreadsheetColumn {
44
48
  type?: QSpreadsheetCellType
45
49
  /** La cellule est éditable (Entrée / double-clic / frappe) */
46
50
  editable?: boolean
47
- /** Pour type "select" : options (badges colorés) */
51
+ /** Pour type "select" / "multiselect" : options (badges colorés) */
48
52
  options?: QSpreadsheetCellOption[]
49
- /** Pour type "select" : rend l'option active en badge coloré (sinon texte) */
53
+ /** Pour type "select" / "multiselect" : rend les options actives en badge coloré (sinon texte) */
50
54
  chip?: boolean
51
55
  /** Validation à la saisie : { min, max, integer?, pattern?, message? } */
52
56
  validation?: QSpreadsheetValidation
@@ -122,6 +126,18 @@ export interface QSpreadsheetDocument {
122
126
  filters?: Record<string, string[] | null>
123
127
  })[]
124
128
  }
129
+
130
+ // Suivi des modifications (delta référence → état courant) — défini dans `lib/spreadsheetChanges.ts`
131
+ // (pur, testé) et ré-exporté ici pour les consommateurs du composant.
132
+ export type {
133
+ QSpreadsheetChanges,
134
+ QSpreadsheetExtraPart,
135
+ QSpreadsheetRowRef,
136
+ QSpreadsheetRowsDelta,
137
+ QSpreadsheetRowUpdate,
138
+ QSpreadsheetSheetUpdate,
139
+ QSpreadsheetSheetsDelta,
140
+ } from "../lib/spreadsheetChanges"
125
141
  </script>
126
142
 
127
143
  <script setup lang="ts">
@@ -145,6 +161,11 @@ import {
145
161
  deriveCols,
146
162
  parseCsv,
147
163
  } from "../lib/spreadsheet"
164
+ import {
165
+ DEFAULT_ROW_KEY,
166
+ diffDocuments,
167
+ type QSpreadsheetChanges,
168
+ } from "../lib/spreadsheetChanges"
148
169
 
149
170
  interface Props {
150
171
  /** Lignes (v-model:rows) — un objet par ligne, clés = noms de colonnes */
@@ -193,6 +214,12 @@ interface Props {
193
214
  readonly?: boolean
194
215
  /** Désactive tout (lecture seule + grisée) */
195
216
  disable?: boolean
217
+ /** Affiche l'indicateur « modifié » dans la barre d'état (dès que le document diffère de la référence) */
218
+ showChanges?: boolean
219
+ /** État « modifié » (`v-model:dirty`) — vrai dès que le document diffère de la référence */
220
+ dirty?: boolean
221
+ /** Delta des modifications (`v-model:changes`) — lignes & feuilles ajoutées / modifiées / supprimées */
222
+ changes?: QSpreadsheetChanges
196
223
  /** Coins arrondis (échelle xs|sm|md|lg ou none) — défaut : composantProps */
197
224
  radius?: RadiusProp
198
225
  }
@@ -221,6 +248,9 @@ const props = withDefaults(defineProps<Props>(), {
221
248
  dark: false,
222
249
  readonly: false,
223
250
  disable: false,
251
+ showChanges: true,
252
+ dirty: false,
253
+ changes: undefined,
224
254
  })
225
255
 
226
256
  // ─── i18n (en | fr) ───
@@ -293,6 +323,14 @@ const I18N = {
293
323
  statusSheets: "{n} sheets",
294
324
  addSheet: "Add sheet",
295
325
  removeSheet: "Remove sheet",
326
+ modified: "{n} modified",
327
+ rowsAdded: "{n} row(s) added",
328
+ rowsUpdated: "{n} row(s) updated",
329
+ rowsDeleted: "{n} row(s) deleted",
330
+ sheetsAdded: "{n} sheet(s) added",
331
+ sheetsUpdated: "{n} sheet(s) updated",
332
+ sheetsDeleted: "{n} sheet(s) deleted",
333
+ formatted: "formatting",
296
334
  empty: "…",
297
335
  },
298
336
  fr: {
@@ -363,6 +401,14 @@ const I18N = {
363
401
  statusSheets: "{n} feuilles",
364
402
  addSheet: "Ajouter une feuille",
365
403
  removeSheet: "Supprimer la feuille",
404
+ modified: "{n} modification(s)",
405
+ rowsAdded: "{n} ligne(s) ajoutée(s)",
406
+ rowsUpdated: "{n} ligne(s) modifiée(s)",
407
+ rowsDeleted: "{n} ligne(s) supprimée(s)",
408
+ sheetsAdded: "{n} feuille(s) ajoutée(s)",
409
+ sheetsUpdated: "{n} feuille(s) modifiée(s)",
410
+ sheetsDeleted: "{n} feuille(s) supprimée(s)",
411
+ formatted: "mise en forme",
366
412
  empty: "…",
367
413
  },
368
414
  } as const
@@ -385,6 +431,8 @@ const emit = defineEmits<{
385
431
  "cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
386
432
  "selection-change": [value: QSpreadsheetSelection | null]
387
433
  "structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
434
+ "update:dirty": [value: boolean]
435
+ "update:changes": [value: QSpreadsheetChanges]
388
436
  }>()
389
437
 
390
438
  // ─── Clés de ligne (`_key`) ───
@@ -395,7 +443,13 @@ const emit = defineEmits<{
395
443
  * `toJSON()` / `loadDocument()`. Le CSV et le presse-papiers ne l'exportent pas
396
444
  * (ils n'itèrent que sur les colonnes déclarées).
397
445
  */
398
- const ROW_KEY = "_key"
446
+ const ROW_KEY = DEFAULT_ROW_KEY
447
+
448
+ /**
449
+ * Le suivi des modifications ne « rebase » qu'**après le montage** : les watchers de props
450
+ * tournent avant eux (`immediate`) et n'ont pas encore de référence à comparer.
451
+ */
452
+ let trackReady = false
399
453
 
400
454
  /** uuid v4 si le contexte le permet, sinon repli horodaté (SSR, http non sécurisé…) */
401
455
  const newRowKey = (): string => {
@@ -429,6 +483,8 @@ watch(
429
483
  // état interne partagent les mêmes `_key`.
430
484
  ensureRowKeys(v)
431
485
  state.value = v.map((r) => ({ ...r }))
486
+ // Lignes **externes** (nouveau document) → nouvelle référence de comparaison
487
+ if (trackReady) acceptChanges()
432
488
  }
433
489
  },
434
490
  { immediate: true },
@@ -443,7 +499,11 @@ const cols = ref<QSpreadsheetColumn[]>([])
443
499
  watch(
444
500
  () => props.columns,
445
501
  (v) => {
446
- if (v && v !== cols.value) cols.value = v.map((c) => ({ ...c }))
502
+ if (v && v !== cols.value) {
503
+ cols.value = v.map((c) => ({ ...c }))
504
+ // Colonnes **externes** (nouveau schéma) → nouvelle référence de comparaison
505
+ if (trackReady) acceptChanges()
506
+ }
447
507
  },
448
508
  { immediate: true },
449
509
  )
@@ -456,6 +516,38 @@ const colIndex = (name: string) => cols.value.findIndex((c) => c.name === name)
456
516
  const colNameAt = (i: number) => cols.value[i]?.name ?? ""
457
517
  const colOf = (name: string) => cols.value.find((c) => c.name === name)
458
518
 
519
+ // ─── Cellules à choix (select = une valeur, multiselect = un tableau de valeurs) ───
520
+ /**
521
+ * Valeurs d'une cellule à choix multiples, toujours sous forme de liste : un tableau est
522
+ * repris tel quel, une valeur nue (`null`, `"it"`…) devient `[valeur]` (ou `[]` si vide).
523
+ * C'est ce qui permet de lire indifféremment un `select` (scalaire) et un `multiselect`.
524
+ */
525
+ const multiValues = (raw: any): any[] => {
526
+ if (Array.isArray(raw)) return raw
527
+ return isBlankValue(raw) ? [] : [raw]
528
+ }
529
+
530
+ /** Libellés d'une cellule à choix multiples (le `value` brut si l'option est inconnue) */
531
+ const multiLabels = (col: QSpreadsheetColumn | undefined, raw: any): string[] =>
532
+ multiValues(raw).map((v) => {
533
+ const opt = col?.options?.find((o) => o.value === v)
534
+ return String(opt?.label ?? v)
535
+ })
536
+
537
+ /**
538
+ * Texte d'une cellule `select` / `multiselect` : les **libellés** (jamais le `value`
539
+ * stocké), pour la copie, le CSV, la recherche, le filtre et les info-bulles.
540
+ */
541
+ const choiceText = (col: QSpreadsheetColumn | undefined, raw: any): string => {
542
+ if (col?.type === "multiselect") return multiLabels(col, raw).join(", ")
543
+ if (isBlankValue(raw)) return ""
544
+ if (col?.type === "select") {
545
+ const opt = col.options?.find((o) => o.value === raw)
546
+ if (opt?.label !== undefined) return String(opt.label)
547
+ }
548
+ return String(raw)
549
+ }
550
+
459
551
  // ─── Sélection (ancre + plage) ───
460
552
  const sel = ref<QSpreadsheetSelection | null>(null)
461
553
  watch(
@@ -635,7 +727,7 @@ const cellText = (row: number, column: string): string => {
635
727
  const col = colOf(column)
636
728
  const ev = evalAt(row, column)
637
729
  if (isError(ev)) return ev.toString()
638
- if (col?.type === "select") return "" // rendu via badge / label
730
+ if (col?.type === "select" || col?.type === "multiselect") return "" // rendu via badge / label
639
731
  let out: any = ev === null || ev === undefined ? "" : ev
640
732
  // Apostrophe littérale (paste values) : affichée sans le préfixe
641
733
  if (typeof out === "string" && out.startsWith("'")) out = out.slice(1)
@@ -648,11 +740,10 @@ const cellTitle = (row: number, column: string): string | undefined => {
648
740
  const col = colOf(column)
649
741
  const v = state.value[row]?.[column]
650
742
  if (isFormulaRaw(v)) return String(v)
651
- if (col?.type === "select") {
652
- const opt = col.options?.find((o) => o.value === v)
653
- // `label` peut être un nombre → `:title` attend une string
654
- if (opt?.label !== undefined) return String(opt.label)
655
- return v === null || v === undefined ? undefined : String(v)
743
+ if (col?.type === "select" || col?.type === "multiselect") {
744
+ // `label` peut être un nombre → `:title` attend une string ; liste → libellés joints
745
+ const text = choiceText(col, v)
746
+ return text === "" ? undefined : text
656
747
  }
657
748
  return undefined
658
749
  }
@@ -696,13 +787,22 @@ const columnOf = (name: string) => colOf(name)
696
787
  const editingIsFormula = computed(() => {
697
788
  if (!editing.value) return false
698
789
  const col = colOf(editing.value.column)
699
- if (!col || col.type === "boolean" || col.type === "select") return false
790
+ if (!col || col.type === "boolean" || col.type === "select" || col.type === "multiselect")
791
+ return false
700
792
  return String(draft.value ?? "").trim().startsWith("=")
701
793
  })
794
+ /** Colonne `select` (choix unique) en cours d'édition */
702
795
  const editingIsSelect = computed(() => {
703
796
  const col = editing.value ? colOf(editing.value.column) : undefined
704
797
  return col?.type === "select"
705
798
  })
799
+ /** Colonne `multiselect` (choix multiples) en cours d'édition */
800
+ const editingIsMulti = computed(() => {
801
+ const col = editing.value ? colOf(editing.value.column) : undefined
802
+ return col?.type === "multiselect"
803
+ })
804
+ /** L'une ou l'autre des colonnes à choix : panneau d'options, flèches, recherche */
805
+ const editingIsChoice = computed(() => editingIsSelect.value || editingIsMulti.value)
706
806
  const draftLines = computed(() =>
707
807
  Math.min(5, Math.max(1, String(draft.value ?? "").split("\n").length)),
708
808
  )
@@ -746,6 +846,10 @@ const startEdit = (row: number, column: string, initial?: string) => {
746
846
  // `label` peut être un nombre (options numériques) : `draft` alimente un
747
847
  // <input>/<textarea> et sert à `.trim()` / `.toLowerCase()` → toujours une string.
748
848
  draft.value = String(initial ?? opt?.label ?? "")
849
+ } else if (col?.type === "multiselect") {
850
+ // Le champ fait office de **recherche** : vide à l'ouverture (toutes les options
851
+ // visibles, les valeurs courantes cochées), ou le caractère tapé pour filtrer direct.
852
+ draft.value = String(initial ?? "")
749
853
  } else {
750
854
  let rawStr = String(raw ?? "")
751
855
  if (rawStr.startsWith("'")) rawStr = rawStr.slice(1)
@@ -781,7 +885,7 @@ const positionEditor = () => {
781
885
  nextTick(() => {
782
886
  const el = editingIsFormula.value ? editAreaEl.value : editInputEl.value
783
887
  el?.focus({ preventScroll: true })
784
- if (!editingIsSelect.value) el?.select()
888
+ if (!editingIsChoice.value) el?.select()
785
889
  })
786
890
  }
787
891
 
@@ -807,9 +911,10 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
807
911
  const raw = String(text ?? "")
808
912
  const t = raw.trim()
809
913
  if (t === "") return null
810
- // Formule A1 : conservée brute dans la cellule (booléen/select exclus)
914
+ // Formule A1 : conservée brute dans la cellule (booléen/choix exclus)
811
915
  if (t.startsWith("=")) {
812
- if (col?.type === "boolean" || col?.type === "select") return old
916
+ if (col?.type === "boolean" || col?.type === "select" || col?.type === "multiselect")
917
+ return old
813
918
  return raw
814
919
  }
815
920
  if (col?.type === "number") {
@@ -827,6 +932,20 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
827
932
  const opt = col.options?.find((o) => String(o.label ?? "") === raw || String(o.value) === raw)
828
933
  return opt ? opt.value : old
829
934
  }
935
+ // `multiselect` : collage / saisie « a, b » → tableau de valeurs d'option
936
+ if (col?.type === "multiselect") {
937
+ const parts = raw
938
+ .split(",")
939
+ .map((s) => s.trim())
940
+ .filter((s) => s !== "")
941
+ if (!parts.length) return null
942
+ return parts.map((part) => {
943
+ const opt = col.options?.find(
944
+ (o) => String(o.label ?? "") === part || String(o.value) === part,
945
+ )
946
+ return opt ? opt.value : part
947
+ })
948
+ }
830
949
  return raw
831
950
  }
832
951
 
@@ -836,7 +955,9 @@ const commitEdit = async () => {
836
955
  const col = colOf(column)
837
956
  const old = state.value[row]?.[column]
838
957
  const text = draft.value
839
- const next = coerceValue(col, text, old)
958
+ // `multiselect` : la valeur est posée au fil des cases à cocher (le champ n'est
959
+ // qu'une recherche) — on ne la réécrit donc pas au moment de fermer l'éditeur.
960
+ const next = col?.type === "multiselect" ? old : coerceValue(col, text, old)
840
961
  editing.value = null
841
962
  emit("cell-edit-end", { row, column, canceled: false })
842
963
  nextTick(() => focusCell(row, column))
@@ -855,7 +976,7 @@ const clearCell = (row: number, column: string) => {
855
976
  const col = colOf(column)
856
977
  if (col?.editable === false || props.readonly || props.disable) return
857
978
  const old = state.value[row]?.[column]
858
- if (old === null || old === undefined || old === "") return
979
+ if (isBlankValue(old) || (Array.isArray(old) && old.length === 0)) return
859
980
  setCellValue(row, column, old, null)
860
981
  }
861
982
 
@@ -876,7 +997,8 @@ const clearSelection = () => {
876
997
  const col = cols.value[c]
877
998
  if (!col || col.editable === false || props.readonly || props.disable) continue
878
999
  const v = state.value[r]?.[col.name]
879
- if (v !== null && v !== undefined && v !== "") changed.push({ row: r, column: col.name })
1000
+ if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
1001
+ changed.push({ row: r, column: col.name })
880
1002
  }
881
1003
  }
882
1004
  if (!changed.length) return
@@ -1121,10 +1243,9 @@ const sortByColumn = (column?: string, desc = false) => {
1121
1243
  }
1122
1244
  if (col?.type === "number" || col?.type === "integer") return Number(ev) || 0
1123
1245
  if (col?.type === "boolean") return ev ? 1 : 0
1124
- if (col?.type === "select") {
1125
- const opt = col.options?.find((o) => o.value === raw)
1126
- // `label` peut être numérique → toString avant comparaison
1127
- return String(opt?.label ?? ev ?? "").toLowerCase()
1246
+ if (col?.type === "select" || col?.type === "multiselect") {
1247
+ // `label` peut être numérique → toString avant comparaison ; liste → libellés joints
1248
+ return choiceText(col, raw).toLowerCase()
1128
1249
  }
1129
1250
  return String(ev).toLowerCase()
1130
1251
  }
@@ -1157,9 +1278,8 @@ const copySelection = async () => {
1157
1278
  const col = colOf(name)
1158
1279
  const raw = state.value[r]?.[name]
1159
1280
  let text: string
1160
- if (col?.type === "select") {
1161
- const opt = col.options?.find((o) => o.value === raw)
1162
- text = opt ? String(opt.label ?? "") : raw === null || raw === undefined ? "" : String(raw)
1281
+ if (col?.type === "select" || col?.type === "multiselect") {
1282
+ text = choiceText(col, raw)
1163
1283
  } else {
1164
1284
  // Formules : copie la valeur AFFICHÉE (évaluée), comme Excel
1165
1285
  text = cellText(r, name)
@@ -1472,8 +1592,17 @@ const onEditKeydown = (e: KeyboardEvent) => {
1472
1592
  if (e.key === "Enter") {
1473
1593
  e.preventDefault()
1474
1594
  e.stopPropagation()
1595
+ const query = String(draft.value ?? "").trim()
1475
1596
  if (editingIsSelect.value && selectOptions.value.length && activeOption.value) {
1476
1597
  pickOption(activeOption.value)
1598
+ } else if (
1599
+ editingIsMulti.value &&
1600
+ query !== "" &&
1601
+ selectOptions.value.length &&
1602
+ activeOption.value
1603
+ ) {
1604
+ // Choix multiples : Entrée coche la suggestion quand on filtre, sinon ferme
1605
+ toggleMultiOption(activeOption.value)
1477
1606
  } else {
1478
1607
  commitEdit()
1479
1608
  moveSelection(1, 0)
@@ -1493,7 +1622,7 @@ const onEditKeydown = (e: KeyboardEvent) => {
1493
1622
  moveSelection(0, e.shiftKey ? -1 : 1)
1494
1623
  return
1495
1624
  }
1496
- if (editingIsSelect.value && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1625
+ if (editingIsChoice.value && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1497
1626
  e.preventDefault()
1498
1627
  e.stopPropagation()
1499
1628
  const opts = selectOptions.value
@@ -1505,19 +1634,19 @@ const onEditKeydown = (e: KeyboardEvent) => {
1505
1634
  }
1506
1635
  }
1507
1636
 
1508
- // ─── Éditeur "select" (liste d'options) ───
1637
+ // ─── Éditeur "select" / "multiselect" (liste d'options) ───
1509
1638
  const activeOption = ref<QSpreadsheetCellOption | null>(null)
1510
1639
  const selectOptions = computed<QSpreadsheetCellOption[]>(() => {
1511
1640
  if (!editing.value) return []
1512
1641
  const col = colOf(editing.value.column)
1513
- if (col?.type !== "select") return []
1642
+ if (col?.type !== "select" && col?.type !== "multiselect") return []
1514
1643
  const q = String(draft.value ?? "").trim().toLowerCase()
1515
1644
  return (col.options ?? []).filter(
1516
1645
  (o) => !q || String(o.label ?? o.value ?? "").toLowerCase().includes(q),
1517
1646
  )
1518
1647
  })
1519
1648
  watch(selectOptions, () => {
1520
- if (editing.value && colOf(editing.value.column)?.type === "select") {
1649
+ if (editing.value && editingIsChoice.value) {
1521
1650
  activeOption.value = selectOptions.value[0] ?? null
1522
1651
  }
1523
1652
  })
@@ -1526,6 +1655,30 @@ const pickOption = (opt: QSpreadsheetCellOption) => {
1526
1655
  commitEdit()
1527
1656
  }
1528
1657
 
1658
+ /** Valeur courante (tableau) de la cellule à choix multiples en cours d'édition */
1659
+ const editingMultiValues = computed<any[]>(() => {
1660
+ if (!editing.value) return []
1661
+ return multiValues(state.value[editing.value.row]?.[editing.value.column])
1662
+ })
1663
+
1664
+ /** L'option est-elle déjà cochée (choix multiples) ? */
1665
+ const multiSelected = (opt: QSpreadsheetCellOption) =>
1666
+ editingMultiValues.value.some((v) => v === opt.value)
1667
+
1668
+ /** Coche / décoche une option (la valeur est écrite **immédiatement**, l'éditeur reste ouvert) */
1669
+ const toggleMultiOption = (opt: QSpreadsheetCellOption) => {
1670
+ if (!editing.value) return
1671
+ const { row, column } = editing.value
1672
+ const col = colOf(column)
1673
+ if (col?.editable === false || props.readonly || props.disable) return
1674
+ const old = state.value[row]?.[column]
1675
+ const current = multiValues(old)
1676
+ const next = multiSelected(opt)
1677
+ ? current.filter((v) => v !== opt.value)
1678
+ : [...current, opt.value]
1679
+ validateAndSet(row, column, old, next)
1680
+ }
1681
+
1529
1682
  // ─── Barre de formule (fx) ───
1530
1683
  const fxDraft = ref("")
1531
1684
  const fxInputEl = ref<HTMLInputElement | null>(null)
@@ -1543,7 +1696,13 @@ watch([sel, state], syncFx)
1543
1696
  const fxCanEdit = computed(() => {
1544
1697
  if (props.readonly || props.disable || !sel.value) return false
1545
1698
  const col = colOf(sel.value.column)
1546
- return !!col && col.editable !== false && col.type !== "boolean" && col.type !== "select"
1699
+ return (
1700
+ !!col &&
1701
+ col.editable !== false &&
1702
+ col.type !== "boolean" &&
1703
+ col.type !== "select" &&
1704
+ col.type !== "multiselect"
1705
+ )
1547
1706
  })
1548
1707
 
1549
1708
  const commitFx = async () => {
@@ -1924,6 +2083,18 @@ const rownumInline = (ri: number) => (isFrozenRow(ri) ? { top: frozenTopOf(ri) +
1924
2083
  // ════════ Filtres par colonne ════════
1925
2084
  const FILTER_BLANK = "__q_spreadsheet_blank__"
1926
2085
  const filters = ref<Record<string, string[] | null>>({})
2086
+ /**
2087
+ * Valeurs d'une cellule vues par le filtre : un tableau donne **une valeur par élément**
2088
+ * (un `multiselect` compte donc dans chaque valeur qu'il contient), une cellule vide `[null]`.
2089
+ */
2090
+ const filterValuesOf = (raw: any): any[] => {
2091
+ if (Array.isArray(raw)) {
2092
+ const values = raw.filter((v) => !isBlankValue(v))
2093
+ return values.length ? values : [null]
2094
+ }
2095
+ return isBlankValue(raw) ? [null] : [raw]
2096
+ }
2097
+ const filterKeyOf = (value: any) => (isBlankValue(value) ? FILTER_BLANK : String(value))
1927
2098
  const activeFilterOf = (name: string) => filters.value[name] ?? null
1928
2099
  const hasFilterFor = (name: string) => {
1929
2100
  const v = filters.value[name]
@@ -1935,9 +2106,9 @@ const hasActiveFilters = computed(() =>
1935
2106
  const rowFilteredOut = (r: number) => {
1936
2107
  for (const [name, allowed] of Object.entries(filters.value)) {
1937
2108
  if (!allowed || !allowed.length) continue
1938
- const raw = state.value[r]?.[name]
1939
- const key = isBlankValue(raw) ? FILTER_BLANK : String(raw)
1940
- if (!allowed.includes(key)) return true
2109
+ const keys = filterValuesOf(state.value[r]?.[name]).map(filterKeyOf)
2110
+ // Un `multiselect` retient la ligne si **au moins une** de ses valeurs est autorisée
2111
+ if (!keys.some((k) => allowed.includes(k))) return true
1941
2112
  }
1942
2113
  return false
1943
2114
  }
@@ -2120,22 +2291,22 @@ interface FilterValueItem {
2120
2291
  const filterValueItems = (name: string): FilterValueItem[] => {
2121
2292
  const col = colOf(name)
2122
2293
  const map = new Map<string, { blank: boolean; raw: any; count: number }>()
2294
+ // Une cellule à choix multiples alimente une entrée **par valeur** qu'elle contient
2123
2295
  for (const row of state.value) {
2124
- const raw = row?.[name]
2125
- const key = isBlankValue(raw) ? FILTER_BLANK : String(raw)
2126
- const found = map.get(key)
2127
- if (found) found.count++
2128
- else map.set(key, { blank: isBlankValue(raw), raw, count: 1 })
2296
+ for (const value of filterValuesOf(row?.[name])) {
2297
+ const key = filterKeyOf(value)
2298
+ const found = map.get(key)
2299
+ if (found) found.count++
2300
+ else map.set(key, { blank: isBlankValue(value), raw: value, count: 1 })
2301
+ }
2129
2302
  }
2130
2303
  const allowed = activeFilterOf(name)
2304
+ const isChoice = col?.type === "select" || col?.type === "multiselect"
2131
2305
  const items: FilterValueItem[] = [...map.entries()].map(([key, info]) => {
2132
2306
  let label: string
2133
2307
  if (info.blank) label = t("blanks")
2134
- else if (col?.type === "select") {
2135
- const opt = col.options?.find((o) => o.value === info.raw)
2136
- // `label` peut être numérique → normalisé (label.localeCompare plus bas)
2137
- label = opt?.label === undefined ? String(info.raw) : String(opt.label)
2138
- } else label = String(info.raw)
2308
+ else if (isChoice && col) label = choiceText(col, info.raw)
2309
+ else label = String(info.raw)
2139
2310
  return { key, blank: info.blank, label, count: info.count, active: allowed ? allowed.includes(key) : true }
2140
2311
  })
2141
2312
  items.sort((a, b) => a.label.localeCompare(b.label))
@@ -2333,6 +2504,18 @@ const badgeOf = (col: QSpreadsheetColumn | undefined, val: any) => {
2333
2504
  return { label: opt.label, style: badgeStyle(opt) }
2334
2505
  }
2335
2506
 
2507
+ /** Badges d'une cellule `multiselect` + `chip` : un badge coloré par valeur sélectionnée */
2508
+ const multiBadges = (col: QSpreadsheetColumn | undefined, val: any) => {
2509
+ if (col?.type !== "multiselect" || !col.chip) return []
2510
+ return multiValues(val).map((v) => {
2511
+ const opt = col.options?.find((o) => o.value === v)
2512
+ return {
2513
+ label: String(opt?.label ?? v),
2514
+ style: badgeStyle(opt ?? { value: v, label: String(v) }),
2515
+ }
2516
+ })
2517
+ }
2518
+
2336
2519
  // ════════ Autofill (poignée de recopie en bas-droite) ════════
2337
2520
  interface FillRect {
2338
2521
  r0: number
@@ -2642,6 +2825,9 @@ const onFilterSearch = (e: Event) => {
2642
2825
  }
2643
2826
 
2644
2827
  onMounted(() => {
2828
+ // Référence initiale : à partir d'ici, toute différence avec elle = « modifié »
2829
+ trackReady = true
2830
+ acceptChanges()
2645
2831
  window.addEventListener("pointerup", onPointerUpGlobal)
2646
2832
  // Phase de CAPTURE : un `@pointerdown.stop` parent bloque sinon l'événement
2647
2833
  // avant `window` → menu contextuel / filtre jamais fermé au clic extérieur.
@@ -2683,9 +2869,9 @@ const rootClasses = computed(() =>
2683
2869
 
2684
2870
  const cellContent = (row: number, col: QSpreadsheetColumn) => {
2685
2871
  const v = state.value[row]?.[col.name]
2686
- if (col.type === "select") {
2687
- const opt = col.options?.find((o) => o.value === v)
2688
- if (opt) return opt.label
2872
+ if (col.type === "select" || col.type === "multiselect") {
2873
+ const text = choiceText(col, v)
2874
+ if (text !== "") return text
2689
2875
  }
2690
2876
  return cellText(row, col.name)
2691
2877
  }
@@ -2788,6 +2974,19 @@ const loadSheetIntoEngine = (idx: number) => {
2788
2974
  redoStack.length = 0
2789
2975
  if (scrollEl.value) scrollEl.value.scrollTop = 0
2790
2976
  }
2977
+ /**
2978
+ * Forme **canonique** d'un descripteur de colonne — celle que manipule le moteur (type par
2979
+ * défaut, options, largeur résolue). Normaliser dès l'entrée évite qu'une feuille inactive paraisse
2980
+ * « modifiée » à son premier chargement (le moteur normalise en sortant de `localSheets`).
2981
+ */
2982
+ const canonicalColumns = (columns: QSpreadsheetColumn[] | undefined): QSpreadsheetColumn[] =>
2983
+ (columns ?? []).map((c) => ({
2984
+ ...c,
2985
+ type: c.type ?? "text",
2986
+ options: c.options ?? [],
2987
+ width: c.width ?? props.defaultColWidth,
2988
+ }))
2989
+
2791
2990
  watch(
2792
2991
  () => props.sheets,
2793
2992
  (v) => {
@@ -2801,12 +3000,15 @@ watch(
2801
3000
  localSheets.value = v.map((s) => {
2802
3001
  const key = s.key ?? "sheet-" + n
2803
3002
  n++
3003
+ // Clés injectées sur les objets du parent (comme pour `rows`) : chaque feuille est
3004
+ // identifiable avant même d'être ouverte
3005
+ ensureRowKeys(s.rows)
2804
3006
  return {
2805
3007
  ...s,
2806
3008
  key,
2807
3009
  name: s.name ?? sheetName(n),
2808
3010
  rows: (s.rows ?? []).map((r) => ({ ...r })),
2809
- columns: s.columns ? s.columns.map((c) => ({ ...c })) : undefined,
3011
+ columns: s.columns ? canonicalColumns(s.columns) : undefined,
2810
3012
  }
2811
3013
  })
2812
3014
  let idx = localSheets.value.findIndex((s) => s.key === prevKey)
@@ -2815,6 +3017,8 @@ watch(
2815
3017
  switching.value = true
2816
3018
  loadSheetIntoEngine(idx)
2817
3019
  switching.value = false
3020
+ // Classeur **externe** (nouveau document) → nouvelle référence de comparaison
3021
+ if (trackReady) acceptChanges()
2818
3022
  },
2819
3023
  { immediate: true },
2820
3024
  )
@@ -2840,6 +3044,8 @@ const commitSheetRename = () => {
2840
3044
  if (s) {
2841
3045
  s.name = sheetRename.value.val.trim() || s.name
2842
3046
  emit("update:sheets", localSheets.value)
3047
+ // Renommage : mutation « en place » d'une feuille → delta recalculé explicitement
3048
+ recomputeChanges()
2843
3049
  }
2844
3050
  sheetRename.value = null
2845
3051
  }
@@ -2962,10 +3168,12 @@ const loadDocument = (doc: QSpreadsheetDocument | string) => {
2962
3168
  for (const s of d.sheets) {
2963
3169
  const key = s.key ?? "sheet-" + n
2964
3170
  n++
3171
+ // Un document chargé peut venir d'ailleurs : on garantit `_key` et la forme canonique
3172
+ ensureRowKeys(s.rows)
2965
3173
  local.push({
2966
3174
  key,
2967
3175
  name: s.name ?? sheetName(n),
2968
- columns: s.columns ? s.columns.map((c) => ({ ...c })) : undefined,
3176
+ columns: s.columns ? canonicalColumns(s.columns) : undefined,
2969
3177
  rows: (s.rows ?? []).map((r) => ({ ...r })),
2970
3178
  })
2971
3179
  meta[key] = {
@@ -2988,6 +3196,8 @@ const loadDocument = (doc: QSpreadsheetDocument | string) => {
2988
3196
  loadSheetIntoEngine(idx)
2989
3197
  switching.value = false
2990
3198
  emit("update:sheets", localSheets.value)
3199
+ // Un document chargé devient la nouvelle **référence** (rien de « modifié » juste après)
3200
+ if (trackReady) acceptChanges()
2991
3201
  }
2992
3202
  const downloadBlob = (blob: Blob, filename: string) => {
2993
3203
  const url = URL.createObjectURL(blob)
@@ -2997,6 +3207,88 @@ const downloadBlob = (blob: Blob, filename: string) => {
2997
3207
  a.click()
2998
3208
  URL.revokeObjectURL(url)
2999
3209
  }
3210
+
3211
+ // ════════ Suivi des modifications (référence → état courant) ════════
3212
+ // Le delta est **comparé**, jamais journalisé : on photographie le document (`buildDocument()`,
3213
+ // exactement ce que renvoie `toJSON()`) et on le confronte à la référence. Conséquences utiles :
3214
+ // « modifié » veut littéralement dire « le document à enregistrer a changé », et revenir à l'état
3215
+ // de départ (re-saisie à l'identique, ajout puis suppression…) ramène l'indicateur à zéro.
3216
+ const baseline = ref<QSpreadsheetDocument | null>(null)
3217
+ const changes = ref<QSpreadsheetChanges>(diffDocuments(null, null))
3218
+ /** Dernier `dirty` diffusé : évite de réémettre la même valeur */
3219
+ let lastDirty: boolean | null = null
3220
+
3221
+ const recomputeChanges = () => {
3222
+ const next = diffDocuments(baseline.value, buildDocument())
3223
+ changes.value = next
3224
+ emit("update:changes", next)
3225
+ if (lastDirty !== next.dirty) {
3226
+ lastDirty = next.dirty
3227
+ emit("update:dirty", next.dirty)
3228
+ }
3229
+ }
3230
+
3231
+ /** Fige l'état courant comme nouvelle **référence** : l'indicateur repasse à zéro */
3232
+ const acceptChanges = () => {
3233
+ baseline.value = buildDocument()
3234
+ recomputeChanges()
3235
+ }
3236
+
3237
+ /**
3238
+ * Revient à la référence (abandonne tout depuis le dernier `acceptChanges`).
3239
+ * Comme `loadDocument()`, l'opération **réinitialise l'historique** undo/redo.
3240
+ */
3241
+ const revertChanges = () => {
3242
+ const ref0 = baseline.value
3243
+ if (!ref0) return
3244
+ loadDocument(JSON.parse(JSON.stringify(ref0)) as QSpreadsheetDocument)
3245
+ if (!multiMode.value) {
3246
+ // En mode simple, `loadDocument` ne diffuse que `update:sheets` : on prévient aussi le parent
3247
+ pushCols(cols.value)
3248
+ pushRows(state.value)
3249
+ }
3250
+ }
3251
+
3252
+ // Recalcul après chaque mutation. `flush: "post"` : on lit un état stable (la feuille courante a
3253
+ // été persistée dans le classeur) et on ne recalcule **qu'une fois par cycle**, même quand une
3254
+ // action touche des dizaines de cellules (collage, recopie, import, sélection vidée…).
3255
+ watch(
3256
+ [
3257
+ state,
3258
+ cols,
3259
+ localSheets,
3260
+ cellFmt,
3261
+ colWidths,
3262
+ rowHeights,
3263
+ filters,
3264
+ condRules,
3265
+ merges,
3266
+ hiddenRows,
3267
+ hiddenCols,
3268
+ ],
3269
+ () => recomputeChanges(),
3270
+ { flush: "post" },
3271
+ )
3272
+
3273
+ /** Delta courant, à la demande (aussi disponible en `v-model:changes`) */
3274
+ const getChanges = () => changes.value
3275
+
3276
+ const changesLabel = computed(() => fmt("modified", { n: changes.value.count }))
3277
+
3278
+ /** Détail de l'indicateur (info-bulle) : lignes puis feuilles, ajoutées / modifiées / supprimées */
3279
+ const changesTitle = computed(() => {
3280
+ const c = changes.value
3281
+ const parts: string[] = []
3282
+ if (c.rows.added.length) parts.push(fmt("rowsAdded", { n: c.rows.added.length }))
3283
+ if (c.rows.updated.length) parts.push(fmt("rowsUpdated", { n: c.rows.updated.length }))
3284
+ if (c.rows.deleted.length) parts.push(fmt("rowsDeleted", { n: c.rows.deleted.length }))
3285
+ if (c.sheets.added.length) parts.push(fmt("sheetsAdded", { n: c.sheets.added.length }))
3286
+ if (c.sheets.updated.length) parts.push(fmt("sheetsUpdated", { n: c.sheets.updated.length }))
3287
+ if (c.sheets.deleted.length) parts.push(fmt("sheetsDeleted", { n: c.sheets.deleted.length }))
3288
+ if (c.extras.length) parts.push(t("formatted"))
3289
+ return parts.join(" · ")
3290
+ })
3291
+
3000
3292
  const getCsv = (opts: { delimiter?: string; includeHeaders?: boolean } = {}): string => {
3001
3293
  const delim = opts.delimiter ?? ","
3002
3294
  const lines: string[] = []
@@ -3006,13 +3298,9 @@ const getCsv = (opts: { delimiter?: string; includeHeaders?: boolean } = {}): st
3006
3298
  lines.push(
3007
3299
  cols.value
3008
3300
  .map((c) => {
3009
- if (c.type === "select") {
3010
- const opt = c.options?.find((o) => o.value === state.value[r]?.[c.name])
3011
- const raw = state.value[r]?.[c.name]
3012
- return csvEscape(
3013
- opt?.label ?? (raw === null || raw === undefined ? "" : String(raw)),
3014
- delim,
3015
- )
3301
+ if (c.type === "select" || c.type === "multiselect") {
3302
+ // Libellés (une liste = libellés joints) — jamais les `value` stockés
3303
+ return csvEscape(choiceText(c, state.value[r]?.[c.name]), delim)
3016
3304
  }
3017
3305
  return csvEscape(cellText(r, c.name), delim)
3018
3306
  })
@@ -3037,14 +3325,10 @@ const findInputEl = ref<HTMLInputElement | null>(null)
3037
3325
 
3038
3326
  const findTextOf = (r: number, ci: number): string => {
3039
3327
  const col = cols.value[ci]
3040
- const raw = state.value[r]?.[col?.name ?? ""]
3041
3328
  if (!col) return ""
3042
- if (col?.type === "select") {
3043
- const opt = col.options?.find((o) => o.value === raw)
3044
- // `label` peut être un nombre : findScan fait `.toLowerCase()` sur le retour
3045
- if (opt?.label !== undefined) return String(opt.label)
3046
- return raw === null || raw === undefined ? "" : String(raw)
3047
- }
3329
+ const raw = state.value[r]?.[col.name]
3330
+ // `label` peut être un nombre : findScan fait `.toLowerCase()` sur le retour
3331
+ if (col.type === "select" || col.type === "multiselect") return choiceText(col, raw)
3048
3332
  return raw === null || raw === undefined ? "" : String(raw)
3049
3333
  }
3050
3334
  const findScan = () => {
@@ -3592,10 +3876,17 @@ const guardValidation = async (
3592
3876
  }
3593
3877
  const check = async (v: QSpreadsheetValidation | undefined): Promise<string | null> => {
3594
3878
  if (!v) return null
3595
- const blank = value === null || value === undefined || value === ""
3879
+ const multi = Array.isArray(value)
3880
+ const blank =
3881
+ value === null || value === undefined || value === "" || (multi && value.length === 0)
3596
3882
  if (v.required && blank) return v.message ?? "Required"
3597
3883
  if (blank) return null // champ vide autorisé (sauf required)
3598
- if (v.list && !v.list.some((x) => String(x) === String(value))) return v.message ?? "Not in the allowed list"
3884
+ if (v.list) {
3885
+ // Choix multiples : chacune des valeurs doit être dans la liste
3886
+ const values = multi ? value : [value]
3887
+ if (values.some((x) => !v.list!.some((y) => String(y) === String(x))))
3888
+ return v.message ?? "Not in the allowed list"
3889
+ }
3599
3890
  if (v.integer && typeof value === "number" && !Number.isInteger(value))
3600
3891
  return v.message ?? "Integer required"
3601
3892
  if (typeof value === "number") {
@@ -3652,7 +3943,8 @@ const tdTitle = (r: number, col: QSpreadsheetColumn): string => {
3652
3943
  const t = cellTitle(r, col.name)
3653
3944
  if (t) return t
3654
3945
  const raw = cellValue(r, col.name)
3655
- return col.type === "select" && raw !== null && raw !== undefined ? String(raw) : ""
3946
+ if (col.type !== "select" && col.type !== "multiselect") return ""
3947
+ return choiceText(col, raw)
3656
3948
  }
3657
3949
  const extraCellStyle = (r: number, c: number, name: string) => {
3658
3950
  const st: Record<string, string> = {}
@@ -3812,6 +4104,9 @@ const onColDragEnd = () => {
3812
4104
  }
3813
4105
 
3814
4106
  defineExpose({
4107
+ acceptChanges,
4108
+ revertChanges,
4109
+ getChanges,
3815
4110
  select,
3816
4111
  selectRow,
3817
4112
  selectCol,
@@ -4327,6 +4622,19 @@ defineExpose({
4327
4622
  {{ badgeOf(col, cellValue(ri, col.name))!.label }}
4328
4623
  </span>
4329
4624
 
4625
+ <!-- multiselect : une série de badges colorés -->
4626
+ <span
4627
+ v-else-if="col.type === 'multiselect' && multiBadges(col, cellValue(ri, col.name)).length"
4628
+ class="q-spreadsheet__badges"
4629
+ >
4630
+ <span
4631
+ v-for="(badge, bi) in multiBadges(col, cellValue(ri, col.name))"
4632
+ :key="bi"
4633
+ class="q-spreadsheet__badge"
4634
+ :style="badge.style"
4635
+ >{{ badge.label }}</span>
4636
+ </span>
4637
+
4330
4638
  <!-- valeur -->
4331
4639
  <span
4332
4640
  v-else
@@ -4369,7 +4677,8 @@ defineExpose({
4369
4677
  v-if="editing && editingCol"
4370
4678
  class="q-spreadsheet__editor"
4371
4679
  :class="{
4372
- 'q-spreadsheet__editor--select': editingCol.type === 'select',
4680
+ 'q-spreadsheet__editor--select': editingIsChoice,
4681
+ 'q-spreadsheet__editor--multi': editingIsMulti,
4373
4682
  'q-spreadsheet__editor--formula': editingIsFormula,
4374
4683
  }"
4375
4684
  :style="editorStyle"
@@ -4392,11 +4701,11 @@ defineExpose({
4392
4701
  class="q-spreadsheet__editor-input"
4393
4702
  :type="editorInputType"
4394
4703
  :inputmode="editorInputMode"
4395
- :placeholder="editingCol.type === 'select' ? 'Type to filter…' : ''"
4704
+ :placeholder="editingIsSelect ? 'Type to filter…' : editingIsMulti ? 'Filter options…' : ''"
4396
4705
  @keydown="onEditKeydown"
4397
4706
  @blur="commitEdit"
4398
4707
  />
4399
- <div v-if="editingCol.type === 'select' && selectOptions.length" class="q-spreadsheet__options">
4708
+ <div v-if="editingIsChoice && selectOptions.length" class="q-spreadsheet__options">
4400
4709
  <button
4401
4710
  v-for="opt in selectOptions"
4402
4711
  :key="String(opt.value)"
@@ -4404,8 +4713,15 @@ defineExpose({
4404
4713
  class="q-spreadsheet__option"
4405
4714
  :class="{ 'q-spreadsheet__option--active': optionIsActive(opt) }"
4406
4715
  @mousedown.prevent
4407
- @click="pickOption(opt)"
4716
+ @click="editingIsMulti ? toggleMultiOption(opt) : pickOption(opt)"
4408
4717
  >
4718
+ <Icon
4719
+ v-if="editingIsMulti && multiSelected(opt)"
4720
+ :icon="icons.check"
4721
+ class="q-spreadsheet__option-check q-spreadsheet__option-check--on"
4722
+ aria-hidden="true"
4723
+ />
4724
+ <span v-else-if="editingIsMulti" class="q-spreadsheet__option-check" aria-hidden="true" />
4409
4725
  <span class="q-spreadsheet__option-swatch" :style="badgeStyle(opt)" />
4410
4726
  {{ opt.label }}
4411
4727
  </button>
@@ -4490,6 +4806,16 @@ defineExpose({
4490
4806
  <span v-if="hasActiveFilters || hiddenRows.length" class="q-spreadsheet__status-chip">
4491
4807
  {{ statusRowInfo }}
4492
4808
  </span>
4809
+ <span
4810
+ v-if="showChanges && changes.dirty"
4811
+ class="q-spreadsheet__status-changes"
4812
+ role="status"
4813
+ :title="changesTitle"
4814
+ :aria-label="changesTitle"
4815
+ >
4816
+ <span class="q-spreadsheet__changes-dot" aria-hidden="true" />
4817
+ {{ changesLabel }}
4818
+ </span>
4493
4819
  <span class="q-spreadsheet__status-spacer" />
4494
4820
  <span v-if="multiMode" class="q-spreadsheet__status-item">{{ statusSheets }}</span>
4495
4821
  <span class="q-spreadsheet__status-zoom">