@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.
- package/components/QBottomSheet.vue +172 -6
- package/components/QImagePicker.vue +28 -1
- package/components/QInputChat.vue +859 -0
- package/components/QInputCurrency.vue +319 -0
- package/components/QItemLabel.vue +99 -0
- package/components/QMap.vue +410 -0
- package/components/QNumericKeyboard.vue +304 -0
- package/components/QPage.vue +26 -6
- package/components/QPageContainer.vue +32 -0
- package/components/QPopupProxy.vue +378 -0
- package/components/QQrcode.vue +132 -0
- package/components/QSpreadsheet.vue +395 -69
- package/components/QTime.vue +845 -0
- package/components/QTimeline.vue +111 -0
- package/components/QTimelineEntry.vue +271 -0
- package/index.ts +11 -1
- package/lib/bottomSheet.test.ts +121 -0
- package/lib/bottomSheet.ts +115 -0
- package/lib/color.ts +13 -0
- package/lib/currency.test.ts +193 -0
- package/lib/currency.ts +301 -0
- package/lib/fixedLayout.ts +12 -11
- package/lib/icons.ts +3 -20
- package/lib/map.test.ts +365 -0
- package/lib/map.ts +438 -0
- package/lib/mapEngine.ts +158 -0
- package/lib/mapLeaflet.ts +142 -0
- package/lib/mapMaplibre.ts +106 -0
- package/lib/mapMaptiler.ts +93 -0
- package/lib/numericKeyboard.test.ts +179 -0
- package/lib/numericKeyboard.ts +166 -0
- package/lib/pagePadding.test.ts +37 -0
- package/lib/pagePadding.ts +32 -0
- package/lib/qrcode.test.ts +103 -0
- package/lib/qrcode.ts +125 -0
- package/lib/spreadsheetChanges.test.ts +230 -0
- package/lib/spreadsheetChanges.ts +331 -0
- package/package.json +5 -17
- package/shims.d.ts +19 -0
- package/styles/main.css +689 -884
- package/components/QTiptap.vue +0 -996
- package/components/internal/QTiptapTableView.vue +0 -123
- package/components/internal/QTiptapTaskItemView.vue +0 -49
- package/lib/tiptap-table.ts +0 -19
- 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
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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"
|
|
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 (!
|
|
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/
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
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 (
|
|
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 &&
|
|
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
|
|
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
|
|
1939
|
-
|
|
1940
|
-
if (!allowed.includes(
|
|
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
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
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
|
|
2135
|
-
|
|
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
|
|
2688
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
3011
|
-
|
|
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
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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':
|
|
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="
|
|
4704
|
+
:placeholder="editingIsSelect ? 'Type to filter…' : editingIsMulti ? 'Filter options…' : ''"
|
|
4396
4705
|
@keydown="onEditKeydown"
|
|
4397
4706
|
@blur="commitEdit"
|
|
4398
4707
|
/>
|
|
4399
|
-
<div v-if="
|
|
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">
|