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