@dnax/ui 0.0.40 → 0.0.42
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/QSpreadsheet.vue +91 -40
- package/components/QTable.vue +189 -2
- package/package.json +2 -1
- package/styles/main.css +97 -3
|
@@ -383,12 +383,49 @@ const emit = defineEmits<{
|
|
|
383
383
|
"structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
|
|
384
384
|
}>()
|
|
385
385
|
|
|
386
|
+
// ─── Clés de ligne (`_key`) ───
|
|
387
|
+
/**
|
|
388
|
+
* Propriété injectée automatiquement sur chaque ligne : une clé stable (uuid) qui
|
|
389
|
+
* sert d'identité (suivre une ligne après un tri, `:key` côté parent, diff…).
|
|
390
|
+
* Elle résiste aux copies internes (`{ ...row }`), aux snapshots undo/redo et à
|
|
391
|
+
* `toJSON()` / `loadDocument()`. Le CSV et le presse-papiers ne l'exportent pas
|
|
392
|
+
* (ils n'itèrent que sur les colonnes déclarées).
|
|
393
|
+
*/
|
|
394
|
+
const ROW_KEY = "_key"
|
|
395
|
+
|
|
396
|
+
/** uuid v4 si le contexte le permet, sinon repli horodaté (SSR, http non sécurisé…) */
|
|
397
|
+
const newRowKey = (): string => {
|
|
398
|
+
const c = typeof globalThis !== "undefined" ? (globalThis.crypto as Crypto | undefined) : undefined
|
|
399
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID()
|
|
400
|
+
return `row-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Injecte `_key` (mutatif) uniquement sur les lignes qui n'en ont pas.
|
|
405
|
+
* Une clé DÉJÀ PRÉSENTE est toujours conservée, quelle que soit sa valeur
|
|
406
|
+
* (string, nombre, uuid ou non) : l'injection ne s'applique qu'à
|
|
407
|
+
* `undefined` / `null` / chaîne vide. Aucune réécriture, aucun dédoublonnage.
|
|
408
|
+
*/
|
|
409
|
+
const ensureRowKeys = (rows: Record<string, any>[] | undefined) => {
|
|
410
|
+
if (!Array.isArray(rows)) return
|
|
411
|
+
for (const row of rows) {
|
|
412
|
+
if (!row || typeof row !== "object") continue
|
|
413
|
+
const current = row[ROW_KEY]
|
|
414
|
+
if (current === undefined || current === null || current === "") row[ROW_KEY] = newRowKey()
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
386
418
|
// ─── Données internes (copie éditable des rows) ───
|
|
387
419
|
const state = ref<Record<string, any>[]>([])
|
|
388
420
|
watch(
|
|
389
421
|
() => props.rows,
|
|
390
422
|
(v) => {
|
|
391
|
-
if (v && v !== state.value)
|
|
423
|
+
if (v && v !== state.value) {
|
|
424
|
+
// Les clés sont injectées sur les objets reçus PUIS recopiées : parent et
|
|
425
|
+
// état interne partagent les mêmes `_key`.
|
|
426
|
+
ensureRowKeys(v)
|
|
427
|
+
state.value = v.map((r) => ({ ...r }))
|
|
428
|
+
}
|
|
392
429
|
},
|
|
393
430
|
{ immediate: true },
|
|
394
431
|
)
|
|
@@ -609,7 +646,9 @@ const cellTitle = (row: number, column: string): string | undefined => {
|
|
|
609
646
|
if (isFormulaRaw(v)) return String(v)
|
|
610
647
|
if (col?.type === "select") {
|
|
611
648
|
const opt = col.options?.find((o) => o.value === v)
|
|
612
|
-
|
|
649
|
+
// `label` peut être un nombre → `:title` attend une string
|
|
650
|
+
if (opt?.label !== undefined) return String(opt.label)
|
|
651
|
+
return v === null || v === undefined ? undefined : String(v)
|
|
613
652
|
}
|
|
614
653
|
return undefined
|
|
615
654
|
}
|
|
@@ -654,22 +693,19 @@ const editingIsFormula = computed(() => {
|
|
|
654
693
|
if (!editing.value) return false
|
|
655
694
|
const col = colOf(editing.value.column)
|
|
656
695
|
if (!col || col.type === "boolean" || col.type === "select") return false
|
|
657
|
-
return draft.value.trim().startsWith("=")
|
|
696
|
+
return String(draft.value ?? "").trim().startsWith("=")
|
|
658
697
|
})
|
|
659
698
|
const editingIsSelect = computed(() => {
|
|
660
699
|
const col = editing.value ? colOf(editing.value.column) : undefined
|
|
661
700
|
return col?.type === "select"
|
|
662
701
|
})
|
|
663
702
|
const draftLines = computed(() =>
|
|
664
|
-
Math.min(5, Math.max(1, draft.value.split("\n").length)),
|
|
703
|
+
Math.min(5, Math.max(1, String(draft.value ?? "").split("\n").length)),
|
|
665
704
|
)
|
|
666
705
|
|
|
667
706
|
const editorInputType = computed(() => {
|
|
668
707
|
const col = editing.value ? colOf(editing.value.column) : undefined
|
|
669
708
|
switch (col?.type) {
|
|
670
|
-
case "number":
|
|
671
|
-
case "integer":
|
|
672
|
-
return "number"
|
|
673
709
|
case "email":
|
|
674
710
|
return "email"
|
|
675
711
|
case "url":
|
|
@@ -679,15 +715,20 @@ const editorInputType = computed(() => {
|
|
|
679
715
|
case "datetime":
|
|
680
716
|
return "datetime-local"
|
|
681
717
|
default:
|
|
718
|
+
// PAS de `type="number"` pour number/integer : (1) le navigateur refuse
|
|
719
|
+
// `=` (formule) ou un "-" isolé dans un input number, (2) `v-model` sur
|
|
720
|
+
// input[type=number] **caste la valeur en Number** → le brouillon n'était
|
|
721
|
+
// plus une string et tous les `.trim()` / `.split()` dessus explosaient.
|
|
722
|
+
// Le clavier numérique mobile est assuré par `inputmode` (`editorInputMode`).
|
|
682
723
|
return "text"
|
|
683
724
|
}
|
|
684
725
|
})
|
|
685
726
|
|
|
686
|
-
/**
|
|
687
|
-
const
|
|
727
|
+
/** Équivalent mobile de `type="number"` : clavier décimal / numérique sans cast */
|
|
728
|
+
const editorInputMode = computed(() => {
|
|
688
729
|
const col = editing.value ? colOf(editing.value.column) : undefined
|
|
689
|
-
if (col?.type === "number") return "
|
|
690
|
-
if (col?.type === "integer") return "
|
|
730
|
+
if (col?.type === "number") return "decimal"
|
|
731
|
+
if (col?.type === "integer") return "numeric"
|
|
691
732
|
return undefined
|
|
692
733
|
})
|
|
693
734
|
|
|
@@ -698,11 +739,13 @@ const startEdit = (row: number, column: string, initial?: string) => {
|
|
|
698
739
|
const raw = state.value[row]?.[column]
|
|
699
740
|
if (col?.type === "select") {
|
|
700
741
|
const opt = col.options?.find((o) => o.value === raw)
|
|
701
|
-
|
|
742
|
+
// `label` peut être un nombre (options numériques) : `draft` alimente un
|
|
743
|
+
// <input>/<textarea> et sert à `.trim()` / `.toLowerCase()` → toujours une string.
|
|
744
|
+
draft.value = String(initial ?? opt?.label ?? "")
|
|
702
745
|
} else {
|
|
703
746
|
let rawStr = String(raw ?? "")
|
|
704
747
|
if (rawStr.startsWith("'")) rawStr = rawStr.slice(1)
|
|
705
|
-
draft.value = initial ?? rawStr
|
|
748
|
+
draft.value = String(initial ?? rawStr)
|
|
706
749
|
}
|
|
707
750
|
editing.value = { row, column }
|
|
708
751
|
emit("cell-edit-start", { row, column })
|
|
@@ -722,7 +765,7 @@ const positionEditor = () => {
|
|
|
722
765
|
// Une formule élargit l'éditeur (monospace ≈ 7.6px/car), sans dépasser le bord droit
|
|
723
766
|
const maxW = sc.width - (tr.left - sc.left) - 4
|
|
724
767
|
const ideal = editingIsFormula.value
|
|
725
|
-
? Math.min(480, Math.max(220, draft.value.length * 7.6 + 30))
|
|
768
|
+
? Math.min(480, Math.max(220, String(draft.value ?? "").length * 7.6 + 30))
|
|
726
769
|
: tr.width
|
|
727
770
|
const width = Math.min(maxW, Math.max(tr.width, ideal))
|
|
728
771
|
editorStyle.value = {
|
|
@@ -755,30 +798,32 @@ const cancelEdit = () => {
|
|
|
755
798
|
nextTick(() => focusCell(cell.row, cell.column))
|
|
756
799
|
}
|
|
757
800
|
|
|
758
|
-
const coerceValue = (col: QSpreadsheetColumn | undefined, text:
|
|
759
|
-
|
|
801
|
+
const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: any) => {
|
|
802
|
+
// `text` peut arriver brut (nombre, booléen…) : on normalise une fois pour toutes
|
|
803
|
+
const raw = String(text ?? "")
|
|
804
|
+
const t = raw.trim()
|
|
760
805
|
if (t === "") return null
|
|
761
806
|
// Formule A1 : conservée brute dans la cellule (booléen/select exclus)
|
|
762
807
|
if (t.startsWith("=")) {
|
|
763
808
|
if (col?.type === "boolean" || col?.type === "select") return old
|
|
764
|
-
return
|
|
809
|
+
return raw
|
|
765
810
|
}
|
|
766
811
|
if (col?.type === "number") {
|
|
767
|
-
const n = Number(
|
|
812
|
+
const n = Number(raw)
|
|
768
813
|
return Number.isNaN(n) ? old : n
|
|
769
814
|
}
|
|
770
815
|
if (col?.type === "integer") {
|
|
771
|
-
const n = Number(
|
|
816
|
+
const n = Number(raw)
|
|
772
817
|
return Number.isNaN(n) ? old : Math.trunc(n)
|
|
773
818
|
}
|
|
774
819
|
if (col?.type === "boolean") {
|
|
775
|
-
return
|
|
820
|
+
return raw === "true" ? true : raw === "false" ? false : old
|
|
776
821
|
}
|
|
777
822
|
if (col?.type === "select") {
|
|
778
|
-
const opt = col.options?.find((o) => o.label ===
|
|
823
|
+
const opt = col.options?.find((o) => String(o.label ?? "") === raw || String(o.value) === raw)
|
|
779
824
|
return opt ? opt.value : old
|
|
780
825
|
}
|
|
781
|
-
return
|
|
826
|
+
return raw
|
|
782
827
|
}
|
|
783
828
|
|
|
784
829
|
const commitEdit = async () => {
|
|
@@ -970,7 +1015,8 @@ const redo = () => {
|
|
|
970
1015
|
|
|
971
1016
|
// ─── Lignes & colonnes ───
|
|
972
1017
|
const blankRow = (): Record<string, any> => {
|
|
973
|
-
|
|
1018
|
+
// `_key` posée dès la création : la ligne est identifiable sans attendre un emit
|
|
1019
|
+
const row: Record<string, any> = { [ROW_KEY]: newRowKey() }
|
|
974
1020
|
for (const col of cols.value) row[col.name] = col.type === "boolean" ? false : ""
|
|
975
1021
|
return row
|
|
976
1022
|
}
|
|
@@ -1073,7 +1119,8 @@ const sortByColumn = (column?: string, desc = false) => {
|
|
|
1073
1119
|
if (col?.type === "boolean") return ev ? 1 : 0
|
|
1074
1120
|
if (col?.type === "select") {
|
|
1075
1121
|
const opt = col.options?.find((o) => o.value === raw)
|
|
1076
|
-
|
|
1122
|
+
// `label` peut être numérique → toString avant comparaison
|
|
1123
|
+
return String(opt?.label ?? ev ?? "").toLowerCase()
|
|
1077
1124
|
}
|
|
1078
1125
|
return String(ev).toLowerCase()
|
|
1079
1126
|
}
|
|
@@ -1108,11 +1155,7 @@ const copySelection = async () => {
|
|
|
1108
1155
|
let text: string
|
|
1109
1156
|
if (col?.type === "select") {
|
|
1110
1157
|
const opt = col.options?.find((o) => o.value === raw)
|
|
1111
|
-
text = opt
|
|
1112
|
-
? opt.label
|
|
1113
|
-
: raw === null || raw === undefined || raw === ""
|
|
1114
|
-
? ""
|
|
1115
|
-
: String(raw)
|
|
1158
|
+
text = opt ? String(opt.label ?? "") : raw === null || raw === undefined ? "" : String(raw)
|
|
1116
1159
|
} else {
|
|
1117
1160
|
// Formules : copie la valeur AFFICHÉE (évaluée), comme Excel
|
|
1118
1161
|
text = cellText(r, name)
|
|
@@ -1464,9 +1507,9 @@ const selectOptions = computed<QSpreadsheetCellOption[]>(() => {
|
|
|
1464
1507
|
if (!editing.value) return []
|
|
1465
1508
|
const col = colOf(editing.value.column)
|
|
1466
1509
|
if (col?.type !== "select") return []
|
|
1467
|
-
const q = draft.value.trim().toLowerCase()
|
|
1510
|
+
const q = String(draft.value ?? "").trim().toLowerCase()
|
|
1468
1511
|
return (col.options ?? []).filter(
|
|
1469
|
-
(o) => !q || (o.label ??
|
|
1512
|
+
(o) => !q || String(o.label ?? o.value ?? "").toLowerCase().includes(q),
|
|
1470
1513
|
)
|
|
1471
1514
|
})
|
|
1472
1515
|
watch(selectOptions, () => {
|
|
@@ -1475,7 +1518,7 @@ watch(selectOptions, () => {
|
|
|
1475
1518
|
}
|
|
1476
1519
|
})
|
|
1477
1520
|
const pickOption = (opt: QSpreadsheetCellOption) => {
|
|
1478
|
-
draft.value = opt.label
|
|
1521
|
+
draft.value = String(opt.label ?? "")
|
|
1479
1522
|
commitEdit()
|
|
1480
1523
|
}
|
|
1481
1524
|
|
|
@@ -1611,7 +1654,9 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
1611
1654
|
{ name: "DAY", sig: "date" },
|
|
1612
1655
|
{ name: "EDATE", sig: "date, months" },
|
|
1613
1656
|
]
|
|
1614
|
-
|
|
1657
|
+
// La source alimente des méthodes de chaîne (`.trim()`, `.startsWith()`) :
|
|
1658
|
+
// la valeur brute d'une cellule peut être un nombre / booléen → on coerce.
|
|
1659
|
+
const fxSource = computed(() => String((editing.value ? draft.value : fxDraft.value) ?? ""))
|
|
1615
1660
|
const fxQuery = computed(() => {
|
|
1616
1661
|
const s = fxSource.value
|
|
1617
1662
|
if (!s.trim().startsWith("=")) return ""
|
|
@@ -1652,8 +1697,8 @@ const acceptFx = () => {
|
|
|
1652
1697
|
const f = fxSuggestions.value[fxActive.value]
|
|
1653
1698
|
if (!f) return
|
|
1654
1699
|
const replaceTail = (s: string) => s.replace(/[A-Za-z]+$/, f.name + "(")
|
|
1655
|
-
if (editing.value) draft.value = replaceTail(draft.value)
|
|
1656
|
-
else fxDraft.value = replaceTail(fxDraft.value)
|
|
1700
|
+
if (editing.value) draft.value = replaceTail(String(draft.value ?? ""))
|
|
1701
|
+
else fxDraft.value = replaceTail(String(fxDraft.value ?? ""))
|
|
1657
1702
|
fxActive.value = 0
|
|
1658
1703
|
nextTick(() => {
|
|
1659
1704
|
const el = editing.value
|
|
@@ -2084,7 +2129,8 @@ const filterValueItems = (name: string): FilterValueItem[] => {
|
|
|
2084
2129
|
if (info.blank) label = t("blanks")
|
|
2085
2130
|
else if (col?.type === "select") {
|
|
2086
2131
|
const opt = col.options?.find((o) => o.value === info.raw)
|
|
2087
|
-
label
|
|
2132
|
+
// `label` peut être numérique → normalisé (label.localeCompare plus bas)
|
|
2133
|
+
label = opt?.label === undefined ? String(info.raw) : String(opt.label)
|
|
2088
2134
|
} else label = String(info.raw)
|
|
2089
2135
|
return { key, blank: info.blank, label, count: info.count, active: allowed ? allowed.includes(key) : true }
|
|
2090
2136
|
})
|
|
@@ -2720,6 +2766,7 @@ const loadSheetIntoEngine = (idx: number) => {
|
|
|
2720
2766
|
sheetMeta.value = { ...sheetMeta.value, [key]: ext }
|
|
2721
2767
|
}
|
|
2722
2768
|
cols.value = ext.cols.map((c) => ({ ...c, type: c.type ?? "text", options: c.options ?? [] }))
|
|
2769
|
+
ensureRowKeys(s.rows)
|
|
2723
2770
|
state.value = (s.rows ?? []).map((r) => ({ ...r }))
|
|
2724
2771
|
cellFmt.value = { ...ext.formats }
|
|
2725
2772
|
rowHeights.value = { ...ext.rowHeights }
|
|
@@ -2986,9 +3033,11 @@ const findTextOf = (r: number, ci: number): string => {
|
|
|
2986
3033
|
const col = cols.value[ci]
|
|
2987
3034
|
const raw = state.value[r]?.[col?.name ?? ""]
|
|
2988
3035
|
if (!col) return ""
|
|
2989
|
-
if (col
|
|
3036
|
+
if (col?.type === "select") {
|
|
2990
3037
|
const opt = col.options?.find((o) => o.value === raw)
|
|
2991
|
-
|
|
3038
|
+
// `label` peut être un nombre : findScan fait `.toLowerCase()` sur le retour
|
|
3039
|
+
if (opt?.label !== undefined) return String(opt.label)
|
|
3040
|
+
return raw === null || raw === undefined ? "" : String(raw)
|
|
2992
3041
|
}
|
|
2993
3042
|
return raw === null || raw === undefined ? "" : String(raw)
|
|
2994
3043
|
}
|
|
@@ -3123,7 +3172,7 @@ const importCsv = (text: string, opts: { delimiter?: string; headers?: boolean }
|
|
|
3123
3172
|
data = rows
|
|
3124
3173
|
}
|
|
3125
3174
|
const rowObjs = data.map((row) => {
|
|
3126
|
-
const obj: Record<string, any> = {}
|
|
3175
|
+
const obj: Record<string, any> = { [ROW_KEY]: newRowKey() }
|
|
3127
3176
|
colsArr.forEach((c, ci) => {
|
|
3128
3177
|
obj[c.name] = ci < row.length && row[ci]!.trim() !== "" ? row[ci]! : null
|
|
3129
3178
|
})
|
|
@@ -3895,12 +3944,14 @@ defineExpose({
|
|
|
3895
3944
|
<span class="q-spreadsheet__tb-group">
|
|
3896
3945
|
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add row" aria-label="Add row" @click="addRow()">
|
|
3897
3946
|
<Icon :icon="icons.rows3" aria-hidden="true" />
|
|
3947
|
+
<Icon :icon="icons.plus" class="q-spreadsheet__tool-badge" aria-hidden="true" />
|
|
3898
3948
|
</button>
|
|
3899
3949
|
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
|
|
3900
3950
|
<Icon :icon="icons.minus" aria-hidden="true" />
|
|
3901
3951
|
</button>
|
|
3902
3952
|
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add column" aria-label="Add column" @click="addColumn()">
|
|
3903
3953
|
<Icon :icon="icons.columns3" aria-hidden="true" />
|
|
3954
|
+
<Icon :icon="icons.plus" class="q-spreadsheet__tool-badge" aria-hidden="true" />
|
|
3904
3955
|
</button>
|
|
3905
3956
|
<button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected column(s)" aria-label="Remove columns" @click="removeSelectedColumns">
|
|
3906
3957
|
<Icon :icon="icons.x" aria-hidden="true" />
|
|
@@ -4334,7 +4385,7 @@ defineExpose({
|
|
|
4334
4385
|
v-model="draft"
|
|
4335
4386
|
class="q-spreadsheet__editor-input"
|
|
4336
4387
|
:type="editorInputType"
|
|
4337
|
-
:
|
|
4388
|
+
:inputmode="editorInputMode"
|
|
4338
4389
|
:placeholder="editingCol.type === 'select' ? 'Type to filter…' : ''"
|
|
4339
4390
|
@keydown="onEditKeydown"
|
|
4340
4391
|
@blur="commitEdit"
|
package/components/QTable.vue
CHANGED
|
@@ -49,6 +49,12 @@ interface Props {
|
|
|
49
49
|
selected?: any[]
|
|
50
50
|
/** Type de sélection */
|
|
51
51
|
selection?: "none" | "single" | "multiple"
|
|
52
|
+
/**
|
|
53
|
+
* Réordonnancement des lignes au glisser-déposer : une gouttière apparaît à gauche
|
|
54
|
+
* (poignée à tirer, ou Alt + ↑/↓ au clavier). Chaque déplacement **agit sur
|
|
55
|
+
* `rows`** : émet `update:rows` (donc compatible `v-model:rows`) + `row-reorder`.
|
|
56
|
+
*/
|
|
57
|
+
reorderableRows?: boolean
|
|
52
58
|
/** Hauteur réduite */
|
|
53
59
|
dense?: boolean
|
|
54
60
|
/** Sans ombre */
|
|
@@ -91,6 +97,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
91
97
|
pagination: null,
|
|
92
98
|
selected: undefined,
|
|
93
99
|
selection: "none",
|
|
100
|
+
reorderableRows: false,
|
|
94
101
|
dense: false,
|
|
95
102
|
flat: false,
|
|
96
103
|
bordered: false,
|
|
@@ -115,6 +122,8 @@ const emit = defineEmits<{
|
|
|
115
122
|
"update:pagination": [value: QTablePagination]
|
|
116
123
|
"update:selected": [value: any[]]
|
|
117
124
|
"update:sorting": [value: { column: string; descending: boolean }]
|
|
125
|
+
"update:rows": [value: any[]]
|
|
126
|
+
"row-reorder": [payload: { rows: any[]; row: any; from: number; to: number }]
|
|
118
127
|
request: [payload: { pagination: QTablePagination; getCellValue: (col: QTableColumn, row: any) => any }]
|
|
119
128
|
"row-click": [event: MouseEvent, row: any, index: number]
|
|
120
129
|
}>()
|
|
@@ -269,15 +278,135 @@ const onRowClick = (e: MouseEvent, row: any, index: number) => {
|
|
|
269
278
|
emit("row-click", e, row, index)
|
|
270
279
|
}
|
|
271
280
|
|
|
281
|
+
// ════════ Réordonnancement des lignes (glisser depuis la gouttière) ════════
|
|
282
|
+
/** Largeur de la gouttière de glissement (px) */
|
|
283
|
+
const REORDER_W = 28
|
|
284
|
+
|
|
285
|
+
const reorderEnabled = computed(() => !!props.reorderableRows)
|
|
286
|
+
/** Gouttière affichée (0 = pas de colonne) */
|
|
287
|
+
const gutterW = computed(() => (reorderEnabled.value ? REORDER_W : 0))
|
|
288
|
+
|
|
289
|
+
/** Ligne en cours de glissement + position de dépôt (index VISIBLE) */
|
|
290
|
+
const draggingRow = ref<any>(null)
|
|
291
|
+
const dropAt = ref<{ index: number; after: boolean } | null>(null)
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Index d'une ligne dans `props.rows`. L'identité d'objet suffit : le tri
|
|
295
|
+
* (`[...rows].sort`) et la pagination (`slice`) conservent les mêmes références,
|
|
296
|
+
* et ça marche même sans `rowKey` unique.
|
|
297
|
+
*/
|
|
298
|
+
const sourceIndexOf = (row: any) => props.rows.indexOf(row)
|
|
299
|
+
|
|
300
|
+
/** Déplace `from` juste avant/après `target` dans `rows` et émet le nouvel ordre */
|
|
301
|
+
const moveRowInSource = (from: any, target: any, after: boolean) => {
|
|
302
|
+
const fi = sourceIndexOf(from)
|
|
303
|
+
const ti = sourceIndexOf(target)
|
|
304
|
+
if (fi === -1 || ti === -1 || fi === ti) return
|
|
305
|
+
|
|
306
|
+
const next = [...props.rows]
|
|
307
|
+
const [moved] = next.splice(fi, 1)
|
|
308
|
+
let to = after ? ti + 1 : ti
|
|
309
|
+
if (fi < to) to -= 1
|
|
310
|
+
next.splice(to, 0, moved)
|
|
311
|
+
|
|
312
|
+
emit("update:rows", next)
|
|
313
|
+
emit("row-reorder", { rows: next, row: moved, from: fi, to })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Ligne visée par un point de l'écran + insertion avant/après selon la moitié survolée */
|
|
317
|
+
const dropTargetAt = (x: number, y: number) => {
|
|
318
|
+
const el = typeof document !== "undefined" ? document.elementFromPoint(x, y) : null
|
|
319
|
+
const tr = (el?.closest?.("tr[data-qrow]") ?? null) as HTMLElement | null
|
|
320
|
+
if (!tr) return null
|
|
321
|
+
const index = Number(tr.dataset.qrow)
|
|
322
|
+
if (!Number.isInteger(index) || !pagedRows.value[index]) return null
|
|
323
|
+
const rect = tr.getBoundingClientRect()
|
|
324
|
+
return { index, after: y > rect.top + rect.height / 2 }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const onReorderPointerDown = (e: PointerEvent, row: any) => {
|
|
328
|
+
if (!reorderEnabled.value || e.button !== 0) return
|
|
329
|
+
e.preventDefault() // pas de sélection de texte pendant le glissement
|
|
330
|
+
e.stopPropagation()
|
|
331
|
+
draggingRow.value = row
|
|
332
|
+
dropAt.value = null
|
|
333
|
+
|
|
334
|
+
const onMove = (ev: PointerEvent) => {
|
|
335
|
+
dropAt.value = dropTargetAt(ev.clientX, ev.clientY)
|
|
336
|
+
}
|
|
337
|
+
const onUp = () => {
|
|
338
|
+
window.removeEventListener("pointermove", onMove)
|
|
339
|
+
window.removeEventListener("pointerup", onUp)
|
|
340
|
+
window.removeEventListener("pointercancel", onUp)
|
|
341
|
+
const target = dropAt.value
|
|
342
|
+
const targetRow = target ? pagedRows.value[target.index] : null
|
|
343
|
+
if (target && targetRow) moveRowInSource(row, targetRow, target.after)
|
|
344
|
+
draggingRow.value = null
|
|
345
|
+
dropAt.value = null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
window.addEventListener("pointermove", onMove)
|
|
349
|
+
window.addEventListener("pointerup", onUp)
|
|
350
|
+
window.addEventListener("pointercancel", onUp)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Déplacement clavier (Alt + ↑/↓) depuis la poignée */
|
|
354
|
+
const nudgeRow = (visibleIndex: number, delta: number) => {
|
|
355
|
+
const row = pagedRows.value[visibleIndex]
|
|
356
|
+
const target = pagedRows.value[visibleIndex + delta]
|
|
357
|
+
if (!row || !target) return
|
|
358
|
+
moveRowInSource(row, target, delta > 0)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Classes d'une ligne selon le glissement en cours (source + cible) */
|
|
362
|
+
const reorderRowClass = (visibleIndex: number) => [
|
|
363
|
+
draggingRow.value !== null && draggingRow.value === pagedRows.value[visibleIndex]
|
|
364
|
+
? "q-table__row--dragging"
|
|
365
|
+
: "",
|
|
366
|
+
dropAt.value?.index === visibleIndex
|
|
367
|
+
? dropAt.value.after
|
|
368
|
+
? "q-table__row--drop-after"
|
|
369
|
+
: "q-table__row--drop-before"
|
|
370
|
+
: "",
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
defineExpose({
|
|
374
|
+
/**
|
|
375
|
+
* Déplace une ligne de `rows` (indices dans la source) — émet `update:rows` et
|
|
376
|
+
* `row-reorder`. Utilisable depuis un bouton « monter / descendre » :
|
|
377
|
+
* `tableRef.value?.reorderRows(2, 0)`.
|
|
378
|
+
*/
|
|
379
|
+
reorderRows: (from: number, to: number) => {
|
|
380
|
+
const len = props.rows.length
|
|
381
|
+
if (from === to || from < 0 || to < 0 || from >= len || to >= len) return
|
|
382
|
+
const next = [...props.rows]
|
|
383
|
+
const [moved] = next.splice(from, 1)
|
|
384
|
+
next.splice(to, 0, moved)
|
|
385
|
+
emit("update:rows", next)
|
|
386
|
+
emit("row-reorder", { rows: next, row: moved, from, to })
|
|
387
|
+
},
|
|
388
|
+
})
|
|
389
|
+
|
|
272
390
|
// — Rendu —
|
|
273
391
|
const alignClass = (col: QTableColumn) =>
|
|
274
392
|
col.align && col.align !== "left" ? `q-table__cell--align-${col.align}` : ""
|
|
275
393
|
|
|
276
394
|
// Une colonne épinglee à gauche → la colonne de sélection s'épingle aussi (offset 0)
|
|
277
395
|
const hasPinnedLeft = computed(() => props.columns.some((c) => c.pinned === "left"))
|
|
278
|
-
const pinnedLeftOffset = computed(
|
|
396
|
+
const pinnedLeftOffset = computed(
|
|
397
|
+
() => gutterW.value + (props.selection !== "none" ? 36 : 0),
|
|
398
|
+
)
|
|
399
|
+
/** La colonne de sélection suit la gouttière de glissement quand elle est épinglée */
|
|
400
|
+
const selectionPinnedStyle = computed(() =>
|
|
401
|
+
hasPinnedLeft.value && gutterW.value ? { left: `${gutterW.value}px` } : null,
|
|
402
|
+
)
|
|
279
403
|
|
|
280
|
-
const colspan = computed(
|
|
404
|
+
const colspan = computed(
|
|
405
|
+
() =>
|
|
406
|
+
props.columns.length +
|
|
407
|
+
(props.selection !== "none" ? 1 : 0) +
|
|
408
|
+
(gutterW.value ? 1 : 0),
|
|
409
|
+
)
|
|
281
410
|
|
|
282
411
|
const rangeLabel = computed(() => {
|
|
283
412
|
const total = sortedRows.value.length
|
|
@@ -364,10 +493,17 @@ const virtual = computed(() => {
|
|
|
364
493
|
<thead v-if="!hideHeader">
|
|
365
494
|
<slot name="header">
|
|
366
495
|
<tr>
|
|
496
|
+
<th
|
|
497
|
+
v-if="reorderEnabled"
|
|
498
|
+
class="q-table__reorder-col"
|
|
499
|
+
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
500
|
+
aria-hidden="true"
|
|
501
|
+
></th>
|
|
367
502
|
<th
|
|
368
503
|
v-if="selection === 'multiple'"
|
|
369
504
|
class="q-table__selection-col"
|
|
370
505
|
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
506
|
+
:style="selectionPinnedStyle"
|
|
371
507
|
>
|
|
372
508
|
<q-checkbox
|
|
373
509
|
:model-value="allSelected ? true : someSelected ? 'mixed' : false"
|
|
@@ -377,6 +513,13 @@ const virtual = computed(() => {
|
|
|
377
513
|
@update:model-value="toggleAll"
|
|
378
514
|
/>
|
|
379
515
|
</th>
|
|
516
|
+
<th
|
|
517
|
+
v-else-if="selection === 'single'"
|
|
518
|
+
class="q-table__selection-col"
|
|
519
|
+
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
520
|
+
:style="selectionPinnedStyle"
|
|
521
|
+
aria-hidden="true"
|
|
522
|
+
></th>
|
|
380
523
|
<th
|
|
381
524
|
v-for="col in columns"
|
|
382
525
|
:key="colName(col)"
|
|
@@ -417,12 +560,33 @@ const virtual = computed(() => {
|
|
|
417
560
|
<tr
|
|
418
561
|
v-for="(row, vi) in virtual.rows"
|
|
419
562
|
:key="String(getRowKey(row, virtual.start + vi))"
|
|
563
|
+
:data-qrow="virtual.start + vi"
|
|
564
|
+
:class="reorderRowClass(virtual.start + vi)"
|
|
420
565
|
@click="(e: MouseEvent) => onRowClick(e, row, virtual.start + vi)"
|
|
421
566
|
>
|
|
567
|
+
<td
|
|
568
|
+
v-if="reorderEnabled"
|
|
569
|
+
class="q-table__reorder-col"
|
|
570
|
+
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
571
|
+
@click.stop
|
|
572
|
+
>
|
|
573
|
+
<button
|
|
574
|
+
type="button"
|
|
575
|
+
class="q-table__drag-handle"
|
|
576
|
+
title="Drag to reorder (Alt + ↑/↓)"
|
|
577
|
+
:aria-label="'Reorder row ' + (virtual.start + vi + 1)"
|
|
578
|
+
@pointerdown="onReorderPointerDown($event, row)"
|
|
579
|
+
@keydown.alt.up.prevent.stop="nudgeRow(virtual.start + vi, -1)"
|
|
580
|
+
@keydown.alt.down.prevent.stop="nudgeRow(virtual.start + vi, 1)"
|
|
581
|
+
>
|
|
582
|
+
<Icon :icon="icons.gripVertical" aria-hidden="true" />
|
|
583
|
+
</button>
|
|
584
|
+
</td>
|
|
422
585
|
<td
|
|
423
586
|
v-if="selection === 'multiple'"
|
|
424
587
|
class="q-table__selection-col"
|
|
425
588
|
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
589
|
+
:style="selectionPinnedStyle"
|
|
426
590
|
@click.stop
|
|
427
591
|
>
|
|
428
592
|
<q-checkbox
|
|
@@ -436,6 +600,7 @@ const virtual = computed(() => {
|
|
|
436
600
|
v-else-if="selection === 'single'"
|
|
437
601
|
class="q-table__selection-col"
|
|
438
602
|
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
603
|
+
:style="selectionPinnedStyle"
|
|
439
604
|
@click.stop
|
|
440
605
|
>
|
|
441
606
|
<q-radio
|
|
@@ -476,12 +641,33 @@ const virtual = computed(() => {
|
|
|
476
641
|
<tr
|
|
477
642
|
v-for="(row, rIdx) in pagedRows"
|
|
478
643
|
:key="String(getRowKey(row, rIdx))"
|
|
644
|
+
:data-qrow="rIdx"
|
|
645
|
+
:class="reorderRowClass(rIdx)"
|
|
479
646
|
@click="(e: MouseEvent) => onRowClick(e, row, rIdx)"
|
|
480
647
|
>
|
|
648
|
+
<td
|
|
649
|
+
v-if="reorderEnabled"
|
|
650
|
+
class="q-table__reorder-col"
|
|
651
|
+
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
652
|
+
@click.stop
|
|
653
|
+
>
|
|
654
|
+
<button
|
|
655
|
+
type="button"
|
|
656
|
+
class="q-table__drag-handle"
|
|
657
|
+
title="Drag to reorder (Alt + ↑/↓)"
|
|
658
|
+
:aria-label="'Reorder row ' + (rIdx + 1)"
|
|
659
|
+
@pointerdown="onReorderPointerDown($event, row)"
|
|
660
|
+
@keydown.alt.up.prevent.stop="nudgeRow(rIdx, -1)"
|
|
661
|
+
@keydown.alt.down.prevent.stop="nudgeRow(rIdx, 1)"
|
|
662
|
+
>
|
|
663
|
+
<Icon :icon="icons.gripVertical" aria-hidden="true" />
|
|
664
|
+
</button>
|
|
665
|
+
</td>
|
|
481
666
|
<td
|
|
482
667
|
v-if="selection === 'multiple'"
|
|
483
668
|
class="q-table__selection-col"
|
|
484
669
|
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
670
|
+
:style="selectionPinnedStyle"
|
|
485
671
|
@click.stop
|
|
486
672
|
>
|
|
487
673
|
<q-checkbox
|
|
@@ -495,6 +681,7 @@ const virtual = computed(() => {
|
|
|
495
681
|
v-else-if="selection === 'single'"
|
|
496
682
|
class="q-table__selection-col"
|
|
497
683
|
:class="{ 'q-table__cell--pinned-left': hasPinnedLeft }"
|
|
684
|
+
:style="selectionPinnedStyle"
|
|
498
685
|
@click.stop
|
|
499
686
|
>
|
|
500
687
|
<q-radio
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dnax/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"description": "UI design System",
|
|
6
6
|
"type": "module",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"swiper": "^14.2.0",
|
|
62
62
|
"tailwind-merge": "^3.6.0",
|
|
63
63
|
"tw-animate-css": "^1.4.0",
|
|
64
|
+
"uuid": "^14.0.2",
|
|
64
65
|
"vue-router": "^5.3.0",
|
|
65
66
|
"vue-sonner": "^2.0.9"
|
|
66
67
|
}
|
package/styles/main.css
CHANGED
|
@@ -29,6 +29,23 @@ body {
|
|
|
29
29
|
--q-radius-lg: 16px;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/* ===== Échelle des z-index =====
|
|
33
|
+
Règle : un menu superposé ne doit JAMAIS passer sous un overlay. Les menus
|
|
34
|
+
téléportés dans <body> sont donc au-dessus de la couche modale.
|
|
35
|
+
1000 popups en flux (`position: absolute` dans leur champ : select,
|
|
36
|
+
autocomplete, nav-menu…) → pas concernés par les overlays
|
|
37
|
+
1999/2000 sidebar offcanvas + backdrop
|
|
38
|
+
3000 overlays modaux : dialog, bottom sheet, action sheet,
|
|
39
|
+
date-picker, country-picker, select sheet
|
|
40
|
+
3200 menus téléportés dans <body> : panneau QBtnActions, menu
|
|
41
|
+
contextuel / filtre / suggestions du tableur → --q-z-menu
|
|
42
|
+
4000 palettes tiptap · 7000 loading global · 9999 tooltip ·
|
|
43
|
+
10000 image preview
|
|
44
|
+
Surchargez --q-z-menu si votre application a un overlay plus haut que 3200. */
|
|
45
|
+
:root {
|
|
46
|
+
--q-z-menu: 3200;
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
/* ===== Safe-area insets (encoche & barre d'accueil) =====
|
|
33
50
|
constant() : iOS 11.0–11.2 ; env() : iOS 11.2+ et navigateurs modernes.
|
|
34
51
|
Sur desktop (Windows, macOS) les insets valent 0 (pas d'encoche). */
|
|
@@ -2505,7 +2522,7 @@ html.dark body {
|
|
|
2505
2522
|
|
|
2506
2523
|
.q-btn-actions__panel {
|
|
2507
2524
|
position: fixed;
|
|
2508
|
-
z-index:
|
|
2525
|
+
z-index: var(--q-z-menu, 3200);
|
|
2509
2526
|
padding: 4px;
|
|
2510
2527
|
background-color: #fff;
|
|
2511
2528
|
border: 1px solid rgb(0 0 0 / 0.12);
|
|
@@ -7710,6 +7727,65 @@ a.q-breadcrumbs__el:hover {
|
|
|
7710
7727
|
width: 36px;
|
|
7711
7728
|
text-align: center;
|
|
7712
7729
|
}
|
|
7730
|
+
|
|
7731
|
+
/* ─── Réordonnancement des lignes (glisser depuis la gouttière) ─── */
|
|
7732
|
+
.q-table__table .q-table__reorder-col {
|
|
7733
|
+
width: 28px;
|
|
7734
|
+
padding: 0 2px;
|
|
7735
|
+
text-align: center;
|
|
7736
|
+
}
|
|
7737
|
+
.q-table__drag-handle {
|
|
7738
|
+
display: inline-flex;
|
|
7739
|
+
align-items: center;
|
|
7740
|
+
justify-content: center;
|
|
7741
|
+
padding: 2px;
|
|
7742
|
+
border: 0;
|
|
7743
|
+
border-radius: 4px;
|
|
7744
|
+
background: transparent;
|
|
7745
|
+
color: rgb(0 0 0 / 0.35);
|
|
7746
|
+
cursor: grab;
|
|
7747
|
+
user-select: none;
|
|
7748
|
+
touch-action: none; /* le glissement ne doit pas partir en scroll tactile */
|
|
7749
|
+
}
|
|
7750
|
+
.q-table__drag-handle:hover {
|
|
7751
|
+
color: var(--primary);
|
|
7752
|
+
background: rgb(0 0 0 / 0.06);
|
|
7753
|
+
}
|
|
7754
|
+
.q-table__drag-handle:focus-visible {
|
|
7755
|
+
outline: 2px solid var(--primary);
|
|
7756
|
+
outline-offset: 1px;
|
|
7757
|
+
}
|
|
7758
|
+
.q-table__drag-handle:active {
|
|
7759
|
+
cursor: grabbing;
|
|
7760
|
+
}
|
|
7761
|
+
.q-table__drag-handle svg {
|
|
7762
|
+
width: 14px;
|
|
7763
|
+
height: 14px;
|
|
7764
|
+
}
|
|
7765
|
+
.q-table__row--dragging {
|
|
7766
|
+
opacity: 0.45;
|
|
7767
|
+
}
|
|
7768
|
+
/* Ligne d'insertion : au-dessus ou en dessous de la ligne survolée */
|
|
7769
|
+
.q-table__row--drop-before > th,
|
|
7770
|
+
.q-table__row--drop-before > td {
|
|
7771
|
+
box-shadow: inset 0 2px 0 0 var(--primary);
|
|
7772
|
+
}
|
|
7773
|
+
.q-table__row--drop-after > th,
|
|
7774
|
+
.q-table__row--drop-after > td {
|
|
7775
|
+
box-shadow: inset 0 -2px 0 0 var(--primary);
|
|
7776
|
+
}
|
|
7777
|
+
.dark .q-table__drag-handle {
|
|
7778
|
+
color: rgb(255 255 255 / 0.4);
|
|
7779
|
+
}
|
|
7780
|
+
.dark .q-table__drag-handle:hover {
|
|
7781
|
+
background: rgb(255 255 255 / 0.08);
|
|
7782
|
+
}
|
|
7783
|
+
.q-table--dark .q-table__drag-handle {
|
|
7784
|
+
color: rgb(255 255 255 / 0.4);
|
|
7785
|
+
}
|
|
7786
|
+
.q-table--dark .q-table__drag-handle:hover {
|
|
7787
|
+
background: rgb(255 255 255 / 0.08);
|
|
7788
|
+
}
|
|
7713
7789
|
.q-table--dark { --q-table-pinned-bg: #1d1d1d; }
|
|
7714
7790
|
|
|
7715
7791
|
/* ===== QInfiniteScroll ===== */
|
|
@@ -9579,6 +9655,7 @@ a.q-breadcrumbs__el:hover {
|
|
|
9579
9655
|
border-left: 1px solid var(--q-spreadsheet-border);
|
|
9580
9656
|
}
|
|
9581
9657
|
.q-spreadsheet__tool {
|
|
9658
|
+
position: relative; /* support du badge « + » des boutons d'ajout */
|
|
9582
9659
|
display: inline-flex;
|
|
9583
9660
|
align-items: center;
|
|
9584
9661
|
justify-content: center;
|
|
@@ -9603,6 +9680,22 @@ a.q-breadcrumbs__el:hover {
|
|
|
9603
9680
|
.q-spreadsheet__tool .iconify {
|
|
9604
9681
|
font-size: 17px;
|
|
9605
9682
|
}
|
|
9683
|
+
|
|
9684
|
+
/* Badge « + » des boutons d'ajout de ligne / colonne : le glyphe de fond dit
|
|
9685
|
+
l'axe (rows / columns), le + dit l'action d'ajout — sans lui, l'icône seule se
|
|
9686
|
+
lit comme « des lignes existantes », pas comme « ajouter une ligne ».
|
|
9687
|
+
(Après `.iconify` à spécificité égale → c'est cette taille qui gagne.) */
|
|
9688
|
+
.q-spreadsheet__tool .q-spreadsheet__tool-badge {
|
|
9689
|
+
position: absolute;
|
|
9690
|
+
right: 1px;
|
|
9691
|
+
bottom: 1px;
|
|
9692
|
+
padding: 1px;
|
|
9693
|
+
border-radius: 50%;
|
|
9694
|
+
font-size: 11px;
|
|
9695
|
+
background: var(--q-spreadsheet-bg, #fff);
|
|
9696
|
+
color: var(--primary, #1976d2);
|
|
9697
|
+
box-shadow: 0 0 0 1px var(--q-spreadsheet-border);
|
|
9698
|
+
}
|
|
9606
9699
|
.q-spreadsheet--dense .q-spreadsheet__tool {
|
|
9607
9700
|
width: 24px;
|
|
9608
9701
|
height: 24px;
|
|
@@ -10064,7 +10157,8 @@ a.q-breadcrumbs__el:hover {
|
|
|
10064
10157
|
/* ─── Menu contextuel / popup filtre (téléportés body) ─── */
|
|
10065
10158
|
.q-spreadsheet__ctx,
|
|
10066
10159
|
.q-spreadsheet__fpop {
|
|
10067
|
-
z-index
|
|
10160
|
+
/* téléportés : au-dessus des overlays modaux (cf. échelle des z-index) */
|
|
10161
|
+
z-index: var(--q-z-menu, 3200);
|
|
10068
10162
|
min-width: 214px;
|
|
10069
10163
|
max-width: 300px;
|
|
10070
10164
|
padding: 5px;
|
|
@@ -10328,7 +10422,7 @@ a.q-breadcrumbs__el:hover {
|
|
|
10328
10422
|
}
|
|
10329
10423
|
.q-spreadsheet__fnsug--fx {
|
|
10330
10424
|
position: fixed;
|
|
10331
|
-
z-index:
|
|
10425
|
+
z-index: calc(var(--q-z-menu, 3200) + 30);
|
|
10332
10426
|
border: 1px solid var(--border, rgb(0 0 0 / 0.12));
|
|
10333
10427
|
border-radius: 8px;
|
|
10334
10428
|
box-shadow: 0 10px 26px rgb(0 0 0 / 0.16);
|