@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.
@@ -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) state.value = v.map((r) => ({ ...r }))
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
- return opt?.label ?? (v === null || v === undefined ? undefined : String(v))
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
- /** step natif : "any" (décimales) pour number, "1" pour integer */
687
- const editorStep = computed(() => {
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 "any"
690
- if (col?.type === "integer") return "1"
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
- draft.value = initial ?? opt?.label ?? ""
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: string, old: any) => {
759
- const t = text.trim()
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 text
809
+ return raw
765
810
  }
766
811
  if (col?.type === "number") {
767
- const n = Number(text)
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(text)
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 text === "true" ? true : text === "false" ? false : old
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 === text || String(o.value) === text)
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 text
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
- const row: Record<string, any> = {}
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
- return (opt?.label ?? String(ev)).toLowerCase()
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 ?? String(o.value)).toLowerCase().includes(q),
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
- const fxSource = computed(() => (editing.value ? draft.value : fxDraft.value))
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 = opt?.label ?? String(info.raw)
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.type === "select") {
3036
+ if (col?.type === "select") {
2990
3037
  const opt = col.options?.find((o) => o.value === raw)
2991
- return opt?.label ?? (raw === null || raw === undefined ? "" : String(raw))
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
- :step="editorStep"
4388
+ :inputmode="editorInputMode"
4338
4389
  :placeholder="editingCol.type === 'select' ? 'Type to filter…' : ''"
4339
4390
  @keydown="onEditKeydown"
4340
4391
  @blur="commitEdit"
@@ -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(() => (props.selection !== "none" ? 36 : 0))
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(() => props.columns.length + (props.selection !== "none" ? 1 : 0))
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.40",
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: 3000;
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: 1300;
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: 1320;
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);