@dnax/ui 0.0.56 → 0.0.59

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.
@@ -8,6 +8,7 @@
8
8
  // Suivi des modifications : `v-model:dirty` (état) + `v-model:changes` (delta lignes / feuilles
9
9
  // ajoutées, modifiées, supprimées), calculé en comparant le document à une **référence**
10
10
  // (`lib/spreadsheetChanges.ts`).
11
+ import type { QSpreadsheetFill, QSpreadsheetLock } from "../lib/spreadsheetZones"
11
12
  import type {
12
13
  QSpreadsheetCellOption,
13
14
  QSpreadsheetOptionAccessor,
@@ -17,6 +18,13 @@ import type {
17
18
  QSpreadsheetValidationRule,
18
19
  } from "../lib/spreadsheetValidation"
19
20
 
21
+ export type {
22
+ QSpreadsheetFill,
23
+ QSpreadsheetFillContext,
24
+ QSpreadsheetFillFormula,
25
+ QSpreadsheetLock,
26
+ QSpreadsheetLockPoint,
27
+ } from "../lib/spreadsheetZones"
20
28
  export type {
21
29
  QSpreadsheetCellOption,
22
30
  QSpreadsheetOptionAccessor,
@@ -84,6 +92,14 @@ export interface QSpreadsheetColumn {
84
92
  cellClass?: (val: any, row: Record<string, any>) => string | undefined
85
93
  /** Couleur de fond de la cellule (plan) */
86
94
  cellBackground?: (val: any, row: Record<string, any>) => string | undefined
95
+ /**
96
+ * Cellule **non modifiable** : édition, effacement, bascule booléenne, collage, remplissage et
97
+ * barre de formule sont refusés pour les lignes où le prédicat est vrai. Reçoit la valeur
98
+ * courante, la ligne et son **index** — donc verrouille aussi une cellule **vide**
99
+ * (`(v) => v !== null`, `(_, row) => row.locked`, `(_, __, i) => i === 0`…).
100
+ * Pour désigner un bloc par ses coordonnées, préférer `lockedRanges`.
101
+ */
102
+ cellReadonly?: (val: any, row: Record<string, any>, rowIndex: number) => boolean
87
103
  headerClass?: string
88
104
  headerStyle?: string
89
105
  }
@@ -92,6 +108,10 @@ export interface QSpreadsheetColumn {
92
108
  // `lib/spreadsheetValidation.ts` (source unique, testée hors navigateur) et sont
93
109
  // ré-exportées en tête de ce bloc.
94
110
 
111
+ // Les verrous (`QSpreadsheetLock`) et le préremplissage (`QSpreadsheetFill`) vivent dans
112
+ // `lib/spreadsheetZones.ts` — forme **lisible** (noms de colonnes) traduite en rectangles
113
+ // d'index par des fonctions pures, testées hors navigateur.
114
+
95
115
  /** Validation par plage de cellules (rows/cols 0-based) */
96
116
  export interface QSpreadsheetRangeValidator {
97
117
  r0: number
@@ -172,6 +192,8 @@ import {
172
192
  } from "../lib/spreadsheet"
173
193
  import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
174
194
  import { normalizeCellOptions } from "../lib/spreadsheetOptions"
195
+ import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
196
+ import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
175
197
  import {
176
198
  DEFAULT_ROW_KEY,
177
199
  diffDocuments,
@@ -207,6 +229,63 @@ interface Props {
207
229
  lang?: "en" | "fr"
208
230
  /** Validations par plage : [{ r0,c0,r1,c1, validation }] — s'ajoutent aux colonnes */
209
231
  validators?: QSpreadsheetRangeValidator[]
232
+ /**
233
+ * Cellules **verrouillées**, écrites avec des **noms** (jamais d'index) :
234
+ * - `{ row: 2, column: "total" }` → la cellule « total » de la ligne 2 ;
235
+ * - `{ row: 0 }` → toute la ligne 0 ;
236
+ * - `{ column: "total" }` → toute la colonne « total » ;
237
+ * - `{ from: { row, column }, to: { row, column } }` → le bloc entre les deux coins.
238
+ *
239
+ * S'applique **même à une cellule vide**. S'ajoute à `columns[].cellReadonly` et aux props
240
+ * globales `readonly` / `disable`.
241
+ */
242
+ lockedRanges?: QSpreadsheetLock[]
243
+ /**
244
+ * **Préremplit** des zones, écrites avec des **noms** de colonnes (jamais d'index) —
245
+ * même vocabulaire que `lockedRanges`, plus une `value` :
246
+ * - `[{ column: "status", value: "todo" }]` → toute la colonne « status » ;
247
+ * - `[{ row: 0, value: "—" }]` → toute la ligne 0 ;
248
+ * - `[{ row: 2, column: "total", value: 0 }]` → la cellule (ligne 2, colonne « total ») ;
249
+ * - `[{ from: { row: 0, column: "total" }, to: { row: 2, column: "status" }, value: false }]`
250
+ * → le bloc entre les deux coins (bornes **incluses**).
251
+ *
252
+ * `value` est une **constante** *ou* une **fonction** `(ctx) => any` (ctx : `rowIndex`,
253
+ * `row`, `column`, `letter`, `rowCount`, `letterOf`) — pour une valeur **dynamique**.
254
+ *
255
+ * `fx` porte une **formule**, **prioritaire sur `value`** : une chaîne (`"SUM(B1:B2)"` — le `=`
256
+ * initial est ajouté s'il manque) ou une fonction `(ctx) => string`. C'est la façon explicite
257
+ * d'écrire une formule, p. ex. la somme d'une colonne :
258
+ * `{ row: 3, column: "net", fx: (c) => `SUM(${c.letterOf("effectif")}1:${c.letterOf("effectif")}${c.rowCount})` }`.
259
+ * (Une chaîne de `value` commençant par `"="` reste stockée comme formule, mais `fx` est
260
+ * préféré : il documente l'intention et n'exige pas le préfixe.)
261
+ *
262
+ * Une ligne **neuve** (bouton « + », insertion) naît déjà préremplie. Les données **déjà
263
+ * chargées** ne sont **jamais** modifiées automatiquement : appeler `applyPrefill()`
264
+ * (méthode exposée) pour combler les cellules **vides** — le dernier `prefill` gagne,
265
+ * les noms de colonnes inconnus sont ignorés.
266
+ */
267
+ prefill?: QSpreadsheetFill[]
268
+ /**
269
+ * Applique `prefill` **automatiquement** aux cellules **vides** dès que le document est prêt
270
+ * (chargement, remplacement de `rows` / `columns`) — sans avoir à appeler `applyPrefill()`.
271
+ * Le résultat est **écrit dans `rows`** (`update:rows`) : utiliser `v-model:rows` (ou écouter
272
+ * `@update:rows`) pour le recevoir. Sans historique ni marquage « modifié ». Une ligne
273
+ * **neuve** naît de toute façon préremplie. `false` → application sur appel explicite
274
+ * uniquement. (Défaut : `true`.)
275
+ */
276
+ autoPrefill?: boolean
277
+ /**
278
+ * Nombre **minimum** de lignes : impossible de descendre en dessous (la suppression des lignes
279
+ * sélectionnées est refusée si elle passerait sous le plancher). Combiné avec `maxRows` à la
280
+ * **même** valeur, le nombre de lignes est **figé**. (Défaut : aucune limite.)
281
+ */
282
+ minRows?: number
283
+ /**
284
+ * Nombre **maximum** de lignes : le bouton « + », l'insertion au curseur et l'import CSV
285
+ * s'arrêtent à cette limite (`:max-rows="1"` → feuille à **une seule** ligne). Les chargements
286
+ * programmatiques (`loadDocument`) ne sont pas bornés. (Défaut : aucune limite.)
287
+ */
288
+ maxRows?: number
210
289
  /** Affiche le numéro de ligne (colonne de gauche) */
211
290
  showRowNumbers?: boolean
212
291
  /** Affiche l'en-tête de colonne (lettre + label) */
@@ -442,6 +521,7 @@ const emit = defineEmits<{
442
521
  "cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
443
522
  "selection-change": [value: QSpreadsheetSelection | null]
444
523
  "structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
524
+ "prefill": [payload: { count: number; mode: "auto" | "manual" }]
445
525
  "update:dirty": [value: boolean]
446
526
  "update:changes": [value: QSpreadsheetChanges]
447
527
  }>()
@@ -485,6 +565,8 @@ const ensureRowKeys = (rows: Record<string, any>[] | undefined) => {
485
565
  }
486
566
 
487
567
  // ─── Données internes (copie éditable des rows) ───
568
+ // Préremplissage : tant que `false`, toute application auto est ignorée (avant montage).
569
+ let prefillReady = false
488
570
  const state = ref<Record<string, any>[]>([])
489
571
  watch(
490
572
  () => props.rows,
@@ -496,6 +578,8 @@ watch(
496
578
  state.value = v.map((r) => ({ ...r }))
497
579
  // Lignes **externes** (nouveau document) → nouvelle référence de comparaison
498
580
  if (trackReady) acceptChanges()
581
+ // Nouveau document → applique les défauts sur les cellules vides.
582
+ autoPrefill()
499
583
  }
500
584
  },
501
585
  { immediate: true },
@@ -514,6 +598,8 @@ watch(
514
598
  cols.value = v.map((c) => ({ ...c }))
515
599
  // Colonnes **externes** (nouveau schéma) → nouvelle référence de comparaison
516
600
  if (trackReady) acceptChanges()
601
+ // Les zones de `prefill` dépendent des noms de colonnes → (re)applique.
602
+ autoPrefill()
517
603
  }
518
604
  },
519
605
  { immediate: true },
@@ -523,6 +609,12 @@ const pushCols = (next: QSpreadsheetColumn[]) => {
523
609
  emit("update:columns", next)
524
610
  }
525
611
 
612
+ // ─── Limites de lignes (`minRows` / `maxRows`) ───
613
+ /** Peut-on encore ajouter une ligne ? (bouton « + », insertion au curseur) */
614
+ const canAddRow = computed(() => canAddRows(state.value.length, 1, props.maxRows))
615
+ /** Peut-on retirer au moins une ligne ? (plancher `minRows`) */
616
+ const canRemoveRow = computed(() => canRemoveRows(state.value.length, 1, props.minRows))
617
+
526
618
  const colIndex = (name: string) => cols.value.findIndex((c) => c.name === name)
527
619
  const colNameAt = (i: number) => cols.value[i]?.name ?? ""
528
620
  const colOf = (name: string) => cols.value.find((c) => c.name === name)
@@ -791,6 +883,27 @@ const cellTitle = (row: number, column: string): string | undefined => {
791
883
  return undefined
792
884
  }
793
885
 
886
+ /** Verrous de `lockedRanges`, traduits en rectangles d'index (noms de colonnes résolus). */
887
+ const lockRectsResolved = computed(() => lockRects(props.lockedRanges, colIndex))
888
+
889
+ /**
890
+ * La cellule est-elle **verrouillée** ? Une seule porte pour toutes les mutations : props
891
+ * globales (`readonly` / `disable`), colonne `editable: false`, prédicat `cellReadonly(val, row)`
892
+ * et verrous `lockedRanges`. S'applique **même à une cellule vide** (il n'y a rien à effacer,
893
+ * mais on ne peut pas y écrire) — verrouiller n'est pas effacer.
894
+ */
895
+ const isCellLocked = (row: number, column: string): boolean => {
896
+ if (props.readonly || props.disable) return true
897
+ const col = colOf(column)
898
+ if (!col) return false
899
+ if (col.editable === false) return true
900
+ const rowData = state.value[row] ?? {}
901
+ if (col.cellReadonly?.(rowData[column], rowData, row)) return true
902
+ const ci = colIndex(column)
903
+ if (ci === -1) return false
904
+ return lockRectsResolved.value.some((z) => inLockRect(z, row, ci))
905
+ }
906
+
794
907
  const cellClasses = (row: number, column: string) => {
795
908
  const col = colOf(column)
796
909
  const v = state.value[row]?.[column]
@@ -806,6 +919,9 @@ const cellClasses = (row: number, column: string) => {
806
919
  isActive(row, column) && "q-spreadsheet__cell--active",
807
920
  editing.value?.row === row && editing.value?.column === column && "q-spreadsheet__cell--editing",
808
921
  props.readonly && "q-spreadsheet__cell--readonly",
922
+ // Verrou **ciblé** (colonne, prédicat, plage) : affordance visuelle. Le verrou global
923
+ // (`readonly` / `disable`) n'ajoute rien — hacher toute la grille serait illisible.
924
+ isCellLocked(row, column) && !props.readonly && !props.disable && "q-spreadsheet__cell--locked",
809
925
  )
810
926
  }
811
927
 
@@ -880,9 +996,8 @@ const editorInputMode = computed(() => {
880
996
  })
881
997
 
882
998
  const startEdit = (row: number, column: string, initial?: string) => {
883
- if (props.readonly || props.disable) return
884
999
  const col = colOf(column)
885
- if (col?.editable === false || col?.type === "boolean") return
1000
+ if (isCellLocked(row, column) || col?.type === "boolean") return
886
1001
  const raw = state.value[row]?.[column]
887
1002
  if (col?.type === "select") {
888
1003
  const opt = cellOptionsOf(col).find((o) => o.value === raw)
@@ -1018,17 +1133,14 @@ const setCellValue = (row: number, column: string, old: any, next: any) => {
1018
1133
  }
1019
1134
 
1020
1135
  const clearCell = (row: number, column: string) => {
1021
- const col = colOf(column)
1022
- if (col?.editable === false || props.readonly || props.disable) return
1136
+ if (isCellLocked(row, column)) return
1023
1137
  const old = state.value[row]?.[column]
1024
1138
  if (isBlankValue(old) || (Array.isArray(old) && old.length === 0)) return
1025
1139
  setCellValue(row, column, old, null)
1026
1140
  }
1027
1141
 
1028
1142
  const toggleBoolean = (row: number, column: string) => {
1029
- if (props.readonly || props.disable) return
1030
- const col = colOf(column)
1031
- if (col?.editable === false) return
1143
+ if (isCellLocked(row, column)) return
1032
1144
  const old = !!state.value[row]?.[column]
1033
1145
  setCellValue(row, column, old, !old)
1034
1146
  }
@@ -1040,7 +1152,7 @@ const clearSelection = () => {
1040
1152
  for (let r = rect.r0; r <= rect.r1; r++) {
1041
1153
  for (let c = rect.c0; c <= rect.c1; c++) {
1042
1154
  const col = cols.value[c]
1043
- if (!col || col.editable === false || props.readonly || props.disable) continue
1155
+ if (!col || isCellLocked(r, col.name)) continue
1044
1156
  const v = state.value[r]?.[col.name]
1045
1157
  if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
1046
1158
  changed.push({ row: r, column: col.name })
@@ -1185,19 +1297,77 @@ const redo = () => {
1185
1297
  }
1186
1298
 
1187
1299
  // ─── Lignes & colonnes ───
1188
- const blankRow = (): Record<string, any> => {
1300
+ const blankRow = (at = state.value.length): Record<string, any> => {
1189
1301
  // `_key` posée dès la création : la ligne est identifiable sans attendre un emit
1190
1302
  const row: Record<string, any> = { [ROW_KEY]: newRowKey() }
1191
1303
  for (const col of cols.value) row[col.name] = col.type === "boolean" ? false : ""
1304
+ // Une ligne neuve naît préremplie par les zones `prefill` qui la visent.
1305
+ // `rowCount` compte la ligne en cours de création (le document l'aura).
1306
+ Object.assign(
1307
+ row,
1308
+ prefillForRow(props.prefill, at, cols.value.map((c) => c.name), {
1309
+ row,
1310
+ rowCount: state.value.length + 1,
1311
+ }),
1312
+ )
1192
1313
  return row
1193
1314
  }
1194
1315
 
1316
+ /**
1317
+ * Cœur du préremplissage : ne remplit que les cellules **vides** et renvoie le nombre rempli.
1318
+ * `mode` distingue l'application **automatique** (silencieuse) de l'appel **explicite**
1319
+ * (avec historique). Émet `update:rows` (si quelque chose a changé) puis `prefill`.
1320
+ */
1321
+ function runPrefill(mode: "auto" | "manual"): number {
1322
+ const fills = props.prefill
1323
+ if (!fills?.length || props.disable) return 0
1324
+ const names = cols.value.map((c) => c.name)
1325
+ let count = 0
1326
+ const next = state.value.map((row, r) => {
1327
+ const patch = prefillForRow(fills, r, names, { row, rowCount: state.value.length })
1328
+ let out = row
1329
+ for (const [name, value] of Object.entries(patch)) {
1330
+ if (!isBlankValue(row[name])) continue
1331
+ if (out === row) out = { ...row }
1332
+ out[name] = value
1333
+ count++
1334
+ }
1335
+ return out
1336
+ })
1337
+ if (!count) return 0
1338
+ if (mode === "manual") pushHistory()
1339
+ state.value = next
1340
+ emit("update:rows", next)
1341
+ emit("prefill", { count, mode })
1342
+ return count
1343
+ }
1344
+
1345
+ /**
1346
+ * Comble les cellules **vides** d'après `prefill` (jamais une valeur existante) et renvoie le
1347
+ * nombre de cellules remplies. Appel **explicite** (avec historique) — l'application au
1348
+ * chargement est automatique (cf. `autoPrefill`).
1349
+ */
1350
+ function applyPrefill(): number {
1351
+ return runPrefill("manual")
1352
+ }
1353
+
1354
+ /**
1355
+ * Application **automatique** (document prêt, nouveau document) : silencieuse — sans
1356
+ * historique, pour ne pas polluer l'annulation ni marquer la grille « modifiée ». **Écrit le
1357
+ * résultat dans `rows`** (`update:rows`). Désactivable via `:auto-prefill="false"`.
1358
+ */
1359
+ function autoPrefill(): void {
1360
+ if (!prefillReady || props.autoPrefill === false) return
1361
+ runPrefill("auto")
1362
+ }
1363
+
1195
1364
  const addRow = (at?: number) => {
1196
1365
  if (props.readonly || props.disable) return
1366
+ if (!canAddRow.value) return
1197
1367
  pushHistory()
1198
1368
  const idx = at ?? state.value.length
1199
1369
  const next = [...state.value]
1200
- next.splice(idx, 0, blankRow())
1370
+ next.splice(idx, 0, blankRow(idx))
1201
1371
  state.value = next
1202
1372
  emit("update:rows", next)
1203
1373
  emit("structure-change", { rows: next, columns: cols.value, reason: "add-row" })
@@ -1206,12 +1376,14 @@ const addRow = (at?: number) => {
1206
1376
 
1207
1377
  const removeSelectedRows = () => {
1208
1378
  if (props.readonly || props.disable) return
1209
- pushHistory()
1210
- purgeMergesAndRules()
1211
1379
  const rect = selRect.value
1212
1380
  if (!rect || !state.value.length) return
1213
1381
  const indexes = new Set<number>()
1214
1382
  for (let r = rect.r0; r <= rect.r1; r++) indexes.add(r)
1383
+ // Plancher `minRows` : on refuse si la suppression descendrait en dessous.
1384
+ if (!canRemoveRows(state.value.length, indexes.size, props.minRows)) return
1385
+ pushHistory()
1386
+ purgeMergesAndRules()
1215
1387
  const sorted = [...indexes].sort((a, b) => b - a)
1216
1388
  const next = [...state.value]
1217
1389
  for (const i of sorted) next.splice(i, 1)
@@ -1363,7 +1535,7 @@ const pasteClip = async () => {
1363
1535
  const c = startCol + j
1364
1536
  if (r >= state.value.length || c >= cols.value.length) return
1365
1537
  const col = cols.value[c]!
1366
- if (col.editable === false) return
1538
+ if (isCellLocked(r, col.name)) return
1367
1539
  const old = state.value[r]?.[col.name]
1368
1540
  const next = coerceValue(col, text, old)
1369
1541
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -1458,10 +1630,11 @@ const fillDownKey = () => {
1458
1630
  for (let c = rect.c0; c <= rect.c1; c++) {
1459
1631
  const name = colNameAt(c)
1460
1632
  const col = colOf(name)
1461
- if (!col || col.editable === false) continue
1633
+ if (!col) continue
1462
1634
  const src = rect.r0 - 1
1463
1635
  const sv = state.value[src]?.[name]
1464
1636
  for (let r = rect.r0; r <= rect.r1; r++) {
1637
+ if (isCellLocked(r, name)) continue
1465
1638
  const old = state.value[r]?.[name]
1466
1639
  if (old !== sv) setCellValue(r, name, old, sv)
1467
1640
  }
@@ -1477,10 +1650,11 @@ const fillRightKey = () => {
1477
1650
  const src = rect.c0 - 1
1478
1651
  const sname = colNameAt(src)
1479
1652
  const col = colOf(sname)
1480
- if (!col || col.editable === false) continue
1653
+ if (!col) continue
1481
1654
  const sv = state.value[r]?.[sname]
1482
1655
  for (let c = rect.c0; c <= rect.c1; c++) {
1483
1656
  const name = colNameAt(c)
1657
+ if (isCellLocked(r, name)) continue
1484
1658
  const old = state.value[r]?.[name]
1485
1659
  if (old !== sv) setCellValue(r, name, old, sv)
1486
1660
  }
@@ -1596,7 +1770,7 @@ const onKeydown = (e: KeyboardEvent) => {
1596
1770
  // Frappe directe : remplace le contenu (comportement Excel)
1597
1771
  if (e.key.length === 1 && !mod && !e.altKey) {
1598
1772
  const col = colOf(sel.value.column)
1599
- if (col && col.editable !== false && col.type !== "boolean") {
1773
+ if (col && col.type !== "boolean" && !isCellLocked(sel.value.row, sel.value.column)) {
1600
1774
  e.preventDefault()
1601
1775
  startEdit(sel.value.row, sel.value.column, e.key)
1602
1776
  }
@@ -1714,8 +1888,7 @@ const multiSelected = (opt: QSpreadsheetCellOption) =>
1714
1888
  const toggleMultiOption = (opt: QSpreadsheetCellOption) => {
1715
1889
  if (!editing.value) return
1716
1890
  const { row, column } = editing.value
1717
- const col = colOf(column)
1718
- if (col?.editable === false || props.readonly || props.disable) return
1891
+ if (isCellLocked(row, column)) return
1719
1892
  const old = state.value[row]?.[column]
1720
1893
  const current = multiValues(old)
1721
1894
  const next = multiSelected(opt)
@@ -1739,11 +1912,12 @@ const syncFx = () => {
1739
1912
  watch([sel, state], syncFx)
1740
1913
 
1741
1914
  const fxCanEdit = computed(() => {
1742
- if (props.readonly || props.disable || !sel.value) return false
1743
- const col = colOf(sel.value.column)
1915
+ if (!sel.value) return false
1916
+ const { row, column } = sel.value
1917
+ if (isCellLocked(row, column)) return false
1918
+ const col = colOf(column)
1744
1919
  return (
1745
1920
  !!col &&
1746
- col.editable !== false &&
1747
1921
  col.type !== "boolean" &&
1748
1922
  col.type !== "select" &&
1749
1923
  col.type !== "multiselect"
@@ -2475,6 +2649,7 @@ const doSortBy = (desc = false) => {
2475
2649
  // ─── Insertion au curseur (lignes / colonnes) ───
2476
2650
  const insertRowAt = (pos: "above" | "below") => {
2477
2651
  if (!canEdit.value) return
2652
+ if (!canAddRow.value) return
2478
2653
  pushHistory()
2479
2654
  purgeMergesAndRules()
2480
2655
  if (!state.value.length) {
@@ -2495,7 +2670,7 @@ const insertRowAt = (pos: "above" | "below") => {
2495
2670
  : state.value.length
2496
2671
  const idx = Math.max(0, Math.min(state.value.length, at))
2497
2672
  const next = [...state.value]
2498
- next.splice(idx, 0, blankRow())
2673
+ next.splice(idx, 0, blankRow(idx))
2499
2674
  state.value = next
2500
2675
  shiftRowKeys(idx, 1)
2501
2676
  emit("update:rows", next)
@@ -2688,7 +2863,7 @@ const applyFill = () => {
2688
2863
  if (rr < 0 || rr >= state.value.length || cc < 0 || cc >= cols.value.length) continue
2689
2864
  const name = colNameAt(cc)
2690
2865
  const col = colOf(name)
2691
- if (!col || col.editable === false) continue
2866
+ if (!col || isCellLocked(rr, name)) continue
2692
2867
 
2693
2868
  let val: any
2694
2869
  // Série numérique / dates (2 graines) vers le bas ou la droite (Ctrl = copie)
@@ -2870,6 +3045,10 @@ const onFilterSearch = (e: Event) => {
2870
3045
  }
2871
3046
 
2872
3047
  onMounted(() => {
3048
+ // Préremplissage automatique en premier : la référence initiale ci-dessous doit
3049
+ // l'inclure, sinon les défauts compteraient comme des « modifications ».
3050
+ prefillReady = true
3051
+ autoPrefill()
2873
3052
  // Référence initiale : à partir d'ici, toute différence avec elle = « modifié »
2874
3053
  trackReady = true
2875
3054
  acceptChanges()
@@ -3421,8 +3600,7 @@ const findMarkClass = (r: number, ci: number) =>
3421
3600
  )
3422
3601
  const findReplaceCurrent = () => {
3423
3602
  const m = findMatches.value[findIdx.value]
3424
- if (!m || props.readonly || props.disable) return
3425
- const col = cols.value[m.ci]
3603
+ if (!m || isCellLocked(m.r, m.name)) return
3426
3604
  const raw = state.value[m.r]?.[m.name]
3427
3605
  if (typeof raw !== "string" || !findQuery.value) return
3428
3606
  const next = findCase.value
@@ -3437,6 +3615,7 @@ const findReplaceAll = () => {
3437
3615
  pushHistory()
3438
3616
  const changed: { r: number; name: string; old: string; next: string }[] = []
3439
3617
  for (const m of findMatches.value) {
3618
+ if (isCellLocked(m.r, m.name)) continue
3440
3619
  const raw = state.value[m.r]?.[m.name]
3441
3620
  if (typeof raw !== "string") continue
3442
3621
  const next = findCase.value
@@ -3506,6 +3685,8 @@ const importCsv = (text: string, opts: { delimiter?: string; headers?: boolean }
3506
3685
  }))
3507
3686
  data = rows
3508
3687
  }
3688
+ // Plafond `maxRows` : l'import ne crée pas plus de lignes que la limite.
3689
+ data = clampRows(data, props.maxRows)
3509
3690
  const rowObjs = data.map((row) => {
3510
3691
  const obj: Record<string, any> = { [ROW_KEY]: newRowKey() }
3511
3692
  colsArr.forEach((c, ci) => {
@@ -3575,7 +3756,7 @@ const pasteValues = async () => {
3575
3756
  const c = c0 + j
3576
3757
  if (r >= state.value.length || c >= cols.value.length) continue
3577
3758
  const col = cols.value[c]!
3578
- if (col.editable === false) continue
3759
+ if (isCellLocked(r, col.name)) continue
3579
3760
  const old = state.value[r]?.[col.name]
3580
3761
  const next = coerceLiteral(col, lines[i]![j] ?? "", old)
3581
3762
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -3636,7 +3817,7 @@ const pasteTransposed = async () => {
3636
3817
  const c = c0 + i
3637
3818
  if (r >= state.value.length || c >= cols.value.length) continue
3638
3819
  const col = cols.value[c]!
3639
- if (col.editable === false) continue
3820
+ if (isCellLocked(r, col.name)) continue
3640
3821
  const old = state.value[r]?.[col.name]
3641
3822
  const next = coerceValue(col, lines[i]![j] ?? "", old)
3642
3823
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -4151,6 +4332,7 @@ defineExpose({
4151
4332
  commitEdit,
4152
4333
  cancelEdit,
4153
4334
  addRow,
4335
+ applyPrefill,
4154
4336
  insertRowAt,
4155
4337
  removeSelectedRows,
4156
4338
  addColumn,
@@ -4278,11 +4460,11 @@ defineExpose({
4278
4460
  </span>
4279
4461
 
4280
4462
  <span class="q-spreadsheet__tb-group">
4281
- <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add row" aria-label="Add row" @click="addRow()">
4463
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !canAddRow" title="Add row" aria-label="Add row" @click="addRow()">
4282
4464
  <Icon :icon="icons.rows3" aria-hidden="true" />
4283
4465
  <Icon :icon="icons.plus" class="q-spreadsheet__tool-badge" aria-hidden="true" />
4284
4466
  </button>
4285
- <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
4467
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect || !canRemoveRow" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
4286
4468
  <Icon :icon="icons.minus" aria-hidden="true" />
4287
4469
  </button>
4288
4470
  <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add column" aria-label="Add column" @click="addColumn()">
@@ -4641,7 +4823,7 @@ defineExpose({
4641
4823
  type="button"
4642
4824
  class="q-spreadsheet__checkbox"
4643
4825
  :class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
4644
- :disabled="readonly || disable || col.editable === false"
4826
+ :disabled="isCellLocked(ri, col.name)"
4645
4827
  aria-label="Toggle"
4646
4828
  @click.stop="toggleBoolean(ri, col.name)"
4647
4829
  >
@@ -4895,13 +5077,13 @@ defineExpose({
4895
5077
  </button>
4896
5078
 
4897
5079
  <div class="q-spreadsheet__sep" />
4898
- <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('above')">
5080
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('above')">
4899
5081
  <Icon :icon="icons.arrowUp" aria-hidden="true" /> {{ t('insertRowAbove') }}
4900
5082
  </button>
4901
- <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('below')">
5083
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('below')">
4902
5084
  <Icon :icon="icons.arrowDown" aria-hidden="true" /> {{ t('insertRowBelow') }}
4903
5085
  </button>
4904
- <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect" @click="removeSelectedRows">
5086
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect || !canRemoveRow" @click="removeSelectedRows">
4905
5087
  <Icon :icon="icons.trash2" aria-hidden="true" /> {{ t('deleteRows') }}
4906
5088
  </button>
4907
5089
  <div class="q-spreadsheet__sep" />
@@ -0,0 +1,53 @@
1
+ // Tests unitaires des **limites de lignes** (`lib/spreadsheetRows.ts`) : `maxRows` / `minRows`.
2
+ import { describe, expect, it } from "bun:test"
3
+ import { canAddRows, canRemoveRows, clampRows, removalsAllowed, rowRoom } from "./spreadsheetRows"
4
+
5
+ describe("rowRoom / canAddRows", () => {
6
+ it("sans max : illimité", () => {
7
+ expect(rowRoom(3, undefined)).toBe(Number.POSITIVE_INFINITY)
8
+ expect(canAddRows(3, 99, undefined)).toBe(true)
9
+ })
10
+
11
+ it("borne par le max", () => {
12
+ expect(rowRoom(0, 1)).toBe(1)
13
+ expect(rowRoom(1, 1)).toBe(0)
14
+ expect(rowRoom(5, 1)).toBe(0)
15
+ expect(canAddRows(0, 1, 1)).toBe(true)
16
+ expect(canAddRows(1, 1, 1)).toBe(false)
17
+ expect(canAddRows(0, 2, 1)).toBe(false)
18
+ })
19
+
20
+ it("ignore un max non fini (Infinity / NaN)", () => {
21
+ expect(rowRoom(4, Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY)
22
+ expect(rowRoom(4, Number.NaN)).toBe(Number.POSITIVE_INFINITY)
23
+ })
24
+ })
25
+
26
+ describe("removalsAllowed / canRemoveRows", () => {
27
+ it("sans min : on peut tout retirer", () => {
28
+ expect(removalsAllowed(3, undefined)).toBe(3)
29
+ expect(canRemoveRows(3, 3, undefined)).toBe(true)
30
+ })
31
+
32
+ it("plancher par le min", () => {
33
+ expect(removalsAllowed(3, 1)).toBe(2)
34
+ expect(removalsAllowed(1, 1)).toBe(0)
35
+ expect(removalsAllowed(0, 1)).toBe(0)
36
+ expect(canRemoveRows(3, 2, 1)).toBe(true)
37
+ expect(canRemoveRows(3, 3, 1)).toBe(false)
38
+ })
39
+ })
40
+
41
+ describe("clampRows", () => {
42
+ it("tronque au-delà du max", () => {
43
+ expect(clampRows([1, 2, 3], 2)).toEqual([1, 2])
44
+ expect(clampRows([1], 5)).toEqual([1])
45
+ })
46
+
47
+ it("renvoie la **même** référence si rien à couper (ou sans max)", () => {
48
+ const rows = [1, 2]
49
+ expect(clampRows(rows, 2)).toBe(rows)
50
+ expect(clampRows(rows, 5)).toBe(rows)
51
+ expect(clampRows(rows, undefined)).toBe(rows)
52
+ })
53
+ })
@@ -0,0 +1,34 @@
1
+ // **Limites du nombre de lignes** de QSpreadsheet (`<q-spreadsheet>`) : props `maxRows` /
2
+ // `minRows`. Elles bornent les opérations **utilisateur** (ajout / insertion / suppression de
3
+ // lignes, import CSV) — jamais les chargements programmatiques (`loadDocument`), comme
4
+ // `readonly` / `disable` bornent les éditions.
5
+ //
6
+ // Logique **pure**, testable hors navigateur — comme `spreadsheetZones.ts`.
7
+
8
+ /** Nombre de lignes qu'on peut encore **ajouter** avant `max` (`Infinity` si pas de limite). */
9
+ export function rowRoom(current: number, max?: number): number {
10
+ if (typeof max !== "number" || !Number.isFinite(max)) return Number.POSITIVE_INFINITY
11
+ return Math.max(0, max - current)
12
+ }
13
+
14
+ /** Peut-on ajouter `count` lignes à un document de `current` lignes ? */
15
+ export function canAddRows(current: number, count = 1, max?: number): boolean {
16
+ return count <= rowRoom(current, max)
17
+ }
18
+
19
+ /** Nombre de lignes qu'on peut **retirer** au maximum avant d'atteindre `min` (plancher 0). */
20
+ export function removalsAllowed(current: number, min?: number): number {
21
+ if (typeof min !== "number" || !Number.isFinite(min)) return Math.max(0, current)
22
+ return Math.max(0, current - Math.max(0, min))
23
+ }
24
+
25
+ /** Peut-on retirer `count` lignes d'un document de `current` lignes (il en reste ≥ `min`) ? */
26
+ export function canRemoveRows(current: number, count = 1, min?: number): boolean {
27
+ return count <= removalsAllowed(current, min)
28
+ }
29
+
30
+ /** Plafonne une liste de lignes à `max` (renvoie la **même** référence si rien à couper). */
31
+ export function clampRows<T>(rows: T[], max?: number): T[] {
32
+ if (typeof max !== "number" || !Number.isFinite(max) || rows.length <= max) return rows
33
+ return rows.slice(0, max)
34
+ }
@@ -0,0 +1,239 @@
1
+ // Tests unitaires des **zones** de QSpreadsheet (`lib/spreadsheetZones.ts`) : la traduction de la
2
+ // forme **lisible** (noms de colonnes) vers les rectangles d'index, et le préremplissage.
3
+ import { describe, expect, it } from "bun:test"
4
+ import { inLockRect, lockRect, lockRects, prefillForRow } from "./spreadsheetZones"
5
+
6
+ /** Colonnes : task(0) · total(1) · status(2) */
7
+ const cols = ["task", "total", "status"]
8
+ const colIndex = (name: string) => cols.indexOf(name)
9
+
10
+ describe("lockRect", () => {
11
+ it("une cellule : `{ row, column }`", () => {
12
+ expect(lockRect({ row: 2, column: "total" }, colIndex)).toEqual({
13
+ r0: 2,
14
+ c0: 1,
15
+ r1: 2,
16
+ c1: 1,
17
+ })
18
+ })
19
+
20
+ it("toute une ligne : `{ row }` (colonnes non bornées)", () => {
21
+ expect(lockRect({ row: 0 }, colIndex)).toEqual({ r0: 0, c0: null, r1: 0, c1: null })
22
+ })
23
+
24
+ it("toute une colonne : `{ column }` (lignes non bornées)", () => {
25
+ expect(lockRect({ column: "total" }, colIndex)).toEqual({
26
+ r0: null,
27
+ c0: 1,
28
+ r1: null,
29
+ c1: 1,
30
+ })
31
+ })
32
+
33
+ it("un bloc : `{ from, to }` (bornes incluses)", () => {
34
+ expect(
35
+ lockRect(
36
+ { from: { row: 0, column: "task" }, to: { row: 2, column: "status" } },
37
+ colIndex,
38
+ ),
39
+ ).toEqual({ r0: 0, c0: 0, r1: 2, c1: 2 })
40
+ })
41
+
42
+ it("un bloc `from`/`to` dans n'importe quel ordre", () => {
43
+ expect(
44
+ lockRect(
45
+ { from: { row: 3, column: "status" }, to: { row: 1, column: "task" } },
46
+ colIndex,
47
+ ),
48
+ ).toEqual({ r0: 1, c0: 0, r1: 3, c1: 2 })
49
+ })
50
+
51
+ it("ignore ce qui ne désigne rien (objet vide, colonne inconnue, from sans to)", () => {
52
+ expect(lockRect({}, colIndex)).toBeNull()
53
+ expect(lockRect({ row: 1, column: "nope" }, colIndex)).toBeNull()
54
+ expect(lockRect({ from: { row: 0, column: "task" } }, colIndex)).toBeNull()
55
+ expect(lockRect(undefined, colIndex)).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe("lockRects", () => {
60
+ it("résout la liste et ignore les verrous invalides", () => {
61
+ expect(lockRects([{ row: 0 }, { column: "nope" }, { row: 1, column: "task" }], colIndex)).toEqual(
62
+ [
63
+ { r0: 0, c0: null, r1: 0, c1: null },
64
+ { r0: 1, c0: 0, r1: 1, c1: 0 },
65
+ ],
66
+ )
67
+ })
68
+
69
+ it("tolère une entrée non-tableau", () => {
70
+ expect(lockRects(undefined, colIndex)).toEqual([])
71
+ })
72
+ })
73
+
74
+ describe("inLockRect", () => {
75
+ const rect = { r0: 1, c0: 1, r1: 2, c1: 1 }
76
+
77
+ it("borne inclusivement", () => {
78
+ expect(inLockRect(rect, 1, 1)).toBe(true)
79
+ expect(inLockRect(rect, 2, 1)).toBe(true)
80
+ expect(inLockRect(rect, 0, 1)).toBe(false)
81
+ expect(inLockRect(rect, 3, 1)).toBe(false)
82
+ expect(inLockRect(rect, 1, 0)).toBe(false)
83
+ expect(inLockRect(rect, 1, 2)).toBe(false)
84
+ })
85
+
86
+ it("une borne `null` est non bornée", () => {
87
+ expect(inLockRect({ r0: null, c0: 1, r1: null, c1: 1 }, 999, 1)).toBe(true)
88
+ expect(inLockRect({ r0: 0, c0: null, r1: 0, c1: null }, 0, 999)).toBe(true)
89
+ })
90
+ })
91
+
92
+ describe("prefillForRow", () => {
93
+ const names = ["task", "total", "status"]
94
+
95
+ it("toute la colonne : la valeur est posée sur chaque ligne", () => {
96
+ const fills = [{ column: "status", value: "todo" }]
97
+ expect(prefillForRow(fills, 0, names)).toEqual({ status: "todo" })
98
+ expect(prefillForRow(fills, 7, names)).toEqual({ status: "todo" })
99
+ })
100
+
101
+ it("une cellule : seulement la ligne visée", () => {
102
+ const fills = [{ row: 2, column: "total", value: 0 }]
103
+ expect(prefillForRow(fills, 2, names)).toEqual({ total: 0 })
104
+ expect(prefillForRow(fills, 1, names)).toEqual({})
105
+ })
106
+
107
+ it("toute une ligne : toutes les colonnes", () => {
108
+ expect(prefillForRow([{ row: 1, value: "—" }], 1, names)).toEqual({
109
+ task: "—",
110
+ total: "—",
111
+ status: "—",
112
+ })
113
+ })
114
+
115
+ it("un bloc : les colonnes et lignes couvertes", () => {
116
+ const fills = [
117
+ { from: { row: 0, column: "total" }, to: { row: 1, column: "status" }, value: false },
118
+ ]
119
+ expect(prefillForRow(fills, 0, names)).toEqual({ total: false, status: false })
120
+ expect(prefillForRow(fills, 1, names)).toEqual({ total: false, status: false })
121
+ expect(prefillForRow(fills, 2, names)).toEqual({})
122
+ })
123
+
124
+ it("cumule plusieurs zones (la dernière gagne) et ignore l'inconnu", () => {
125
+ expect(
126
+ prefillForRow(
127
+ [
128
+ { column: "status", value: "todo" },
129
+ { row: 0, column: "status", value: "urgent" },
130
+ { column: "nope", value: 1 },
131
+ ],
132
+ 0,
133
+ names,
134
+ ),
135
+ ).toEqual({ status: "urgent" })
136
+ })
137
+
138
+ it("tolère une entrée vide ou non-tableau", () => {
139
+ expect(prefillForRow(undefined, 0, names)).toEqual({})
140
+ expect(prefillForRow([], 0, names)).toEqual({})
141
+ expect(prefillForRow([{ column: "task", value: 1 }], 0, [])).toEqual({})
142
+ })
143
+ })
144
+
145
+ describe("prefillForRow — value fonction (valeurs dynamiques)", () => {
146
+ const names = ["task", "effectif", "net"]
147
+
148
+ it("appelle la fonction avec le contexte (lettre, index, taille du document)", () => {
149
+ const seen: any[] = []
150
+ const fills = [
151
+ {
152
+ row: 1,
153
+ column: "net",
154
+ value: (ctx: any) => {
155
+ seen.push(ctx)
156
+ const L = ctx.letterOf("effectif")
157
+ return `=SUM(${L}1:${L}${ctx.rowCount})`
158
+ },
159
+ },
160
+ ]
161
+ expect(prefillForRow(fills, 1, names, { rowCount: 4 })).toEqual({ net: "=SUM(B1:B4)" })
162
+ expect(seen[0]).toMatchObject({
163
+ rowIndex: 1,
164
+ column: "net",
165
+ columnIndex: 2,
166
+ letter: "C",
167
+ rowCount: 4,
168
+ })
169
+ expect(seen[0].letterOf("effectif")).toBe("B")
170
+ })
171
+
172
+ it("reçoit la ligne ciblée (agrégat calculé, pas une formule)", () => {
173
+ const fills = [{ row: 0, column: "net", value: (ctx: any) => Number(ctx.row.effectif) * 2 }]
174
+ expect(prefillForRow(fills, 0, names, { row: { effectif: 21 } })).toEqual({ net: 42 })
175
+ })
176
+
177
+ it("une chaîne de formule constante passe telle quelle", () => {
178
+ expect(prefillForRow([{ row: 0, column: "net", value: "=B1*2" }], 0, names)).toEqual({
179
+ net: "=B1*2",
180
+ })
181
+ })
182
+
183
+ it("une value **fonction** peut renvoyer une formule", () => {
184
+ const fills = [
185
+ {
186
+ row: 0,
187
+ column: "net",
188
+ value: (ctx: any) => `=SUM(${ctx.letterOf("effectif")}1:${ctx.letterOf("effectif")}${ctx.rowCount})`,
189
+ },
190
+ ]
191
+ expect(prefillForRow(fills, 0, names, { rowCount: 3 })).toEqual({ net: "=SUM(B1:B3)" })
192
+ })
193
+
194
+ it("value reste littéral (pas de normalisation du préfixe `=`)", () => {
195
+ expect(prefillForRow([{ row: 0, column: "net", value: "SUM(B1:B2)" }], 0, names)).toEqual({
196
+ net: "SUM(B1:B2)",
197
+ })
198
+ })
199
+ })
200
+
201
+ describe("prefillForRow — fx (formule prioritaire)", () => {
202
+ const names = ["task", "effectif", "net"]
203
+
204
+ it("fx accepte une formule sans préfixe `=` (normalisée)", () => {
205
+ expect(prefillForRow([{ row: 0, column: "net", fx: "SUM(B1:B2)" }], 0, names)).toEqual({
206
+ net: "=SUM(B1:B2)",
207
+ })
208
+ expect(prefillForRow([{ row: 0, column: "net", fx: "=B1*2" }], 0, names)).toEqual({
209
+ net: "=B1*2",
210
+ })
211
+ })
212
+
213
+ it("fx est prioritaire sur value", () => {
214
+ expect(
215
+ prefillForRow([{ row: 0, column: "net", value: 42, fx: "B1*2" }], 0, names),
216
+ ).toEqual({ net: "=B1*2" })
217
+ })
218
+
219
+ it("fx fonction reçoit le contexte et peut être dynamique", () => {
220
+ const fills = [
221
+ {
222
+ row: 0,
223
+ column: "net",
224
+ fx: (ctx: any) => {
225
+ const L = ctx.letterOf("effectif")
226
+ return `SUM(${L}1:${L}${ctx.rowCount - 1})`
227
+ },
228
+ },
229
+ ]
230
+ expect(prefillForRow(fills, 0, names, { rowCount: 3 })).toEqual({ net: "=SUM(B1:B2)" })
231
+ })
232
+
233
+ it("un fx vide retombe sur value ; sinon rien n'est posé", () => {
234
+ expect(prefillForRow([{ row: 0, column: "net", fx: " ", value: 7 }], 0, names)).toEqual({
235
+ net: 7,
236
+ })
237
+ expect(prefillForRow([{ row: 0, column: "net" }], 0, names)).toEqual({})
238
+ })
239
+ })
@@ -0,0 +1,253 @@
1
+ // **Zones** de QSpreadsheet (`<q-spreadsheet>`) désignées par des **noms** — servent à deux
2
+ // choses : verrouiller des cellules (`lockedRanges`) et les **préremplir** (`prefill`).
3
+ //
4
+ // On désigne les cellules par **nom de colonne** (la clé dans `rows`), jamais par index :
5
+ //
6
+ // { row: 2, column: "total" } → la cellule « total » de la ligne 2
7
+ // { row: 0 } → toute la ligne 0
8
+ // { column: "total" } → toute la colonne « total »
9
+ // { from: {...}, to: {...} } → un bloc (bornes incluses, dans n'importe quel ordre)
10
+ //
11
+ // `lockRect()` traduit cette forme en rectangle d'**index** (une borne `null` = non bornée) ;
12
+ // `prefillForRow()` en déduit les valeurs à poser dans une ligne neuve. Logique **pure**,
13
+ // testable hors navigateur — comme `spreadsheetValidation.ts`.
14
+ import { colLetter } from "./spreadsheet"
15
+
16
+ /** Un coin, en clair : ligne (0-based) + **nom** de colonne. */
17
+ export interface QSpreadsheetLockPoint {
18
+ /** Ligne, 0-based (0 = première ligne de données) */
19
+ row: number
20
+ /** Nom de la colonne — la clé utilisée dans `rows` (ex. `"total"`) */
21
+ column: string
22
+ }
23
+
24
+ /**
25
+ * Un verrou, écrit avec des **noms** :
26
+ * - `{ row: 2, column: "total" }` → une cellule ;
27
+ * - `{ row: 0 }` → toute la ligne 0 ;
28
+ * - `{ column: "total" }` → toute la colonne ;
29
+ * - `{ from, to }` → le bloc entre les deux coins (bornes incluses).
30
+ */
31
+ export interface QSpreadsheetLock {
32
+ /** Ligne (0-based). Omise → **toutes** les lignes. */
33
+ row?: number
34
+ /** Nom de colonne. Omise → **toutes** les colonnes. */
35
+ column?: string
36
+ /** Premier coin d'un **bloc** (avec `to`) — prioritaire sur `row` / `column`. */
37
+ from?: QSpreadsheetLockPoint
38
+ /** Coin opposé du bloc (avec `from`). */
39
+ to?: QSpreadsheetLockPoint
40
+ }
41
+
42
+ /** Rectangle d'index. Une borne `null` = **non bornée** (toutes les lignes / colonnes). */
43
+ export interface LockRect {
44
+ r0: number | null
45
+ c0: number | null
46
+ r1: number | null
47
+ c1: number | null
48
+ }
49
+
50
+ /**
51
+ * Traduit un verrou lisible en rectangle d'index.
52
+ * `null` quand il ne désigne rien : objet vide, `from`/`to` incomplets, ou nom de colonne
53
+ * inconnu (un verrou mal orthographié est **ignoré** plutôt que de tout verrouiller).
54
+ */
55
+ export function lockRect(
56
+ lock: QSpreadsheetLock | undefined,
57
+ columnIndex: (name: string) => number,
58
+ ): LockRect | null {
59
+ if (!lock) return null
60
+
61
+ if (lock.from && lock.to) {
62
+ const a = columnIndex(lock.from.column)
63
+ const b = columnIndex(lock.to.column)
64
+ if (a === -1 || b === -1) return null
65
+ return {
66
+ r0: Math.min(lock.from.row, lock.to.row),
67
+ r1: Math.max(lock.from.row, lock.to.row),
68
+ c0: Math.min(a, b),
69
+ c1: Math.max(a, b),
70
+ }
71
+ }
72
+
73
+ const hasRow = typeof lock.row === "number"
74
+ const hasColumn = typeof lock.column === "string"
75
+ if (!hasRow && !hasColumn) return null
76
+
77
+ let c0: number | null = null
78
+ let c1: number | null = null
79
+ if (hasColumn) {
80
+ const ci = columnIndex(lock.column as string)
81
+ if (ci === -1) return null
82
+ c0 = ci
83
+ c1 = ci
84
+ }
85
+
86
+ const r = hasRow ? (lock.row as number) : null
87
+ return { r0: r, r1: r, c0, c1 }
88
+ }
89
+
90
+ /** Traduit une liste de verrous (les entrées non résolvables sont ignorées). */
91
+ export function lockRects(
92
+ locks: QSpreadsheetLock[] | undefined,
93
+ columnIndex: (name: string) => number,
94
+ ): LockRect[] {
95
+ if (!Array.isArray(locks)) return []
96
+ const out: LockRect[] = []
97
+ for (const lock of locks) {
98
+ const rect = lockRect(lock, columnIndex)
99
+ if (rect) out.push(rect)
100
+ }
101
+ return out
102
+ }
103
+
104
+ /** La cellule `(row, col)` tombe-t-elle dans le rectangle ? (bornes `null` = non bornées) */
105
+ export const inLockRect = (rect: LockRect, row: number, col: number): boolean =>
106
+ (rect.r0 === null || row >= rect.r0) &&
107
+ (rect.r1 === null || row <= rect.r1) &&
108
+ (rect.c0 === null || col >= rect.c0) &&
109
+ (rect.c1 === null || col <= rect.c1)
110
+
111
+ // ─────────────────────────── Préremplissage ───────────────────────────
112
+
113
+ /**
114
+ * Une **formule** de préremplissage : une chaîne (`"SUM(B1:B2)"` ou `"=SUM(B1:B2)"` — le `=`
115
+ * initial est ajouté s'il manque) ou une **fonction** du contexte qui renvoie cette chaîne.
116
+ */
117
+ export type QSpreadsheetFillFormula = string | ((ctx: QSpreadsheetFillContext) => string)
118
+
119
+ /** Zone **préremplie** : mêmes coordonnées qu'un verrou, plus une `value` **et/ou** une `fx`. */
120
+ export interface QSpreadsheetFill extends QSpreadsheetLock {
121
+ /**
122
+ * Valeur posée dans les cellules **vides** de la zone. Une **constante** (`0`, `"todo"`, …)
123
+ * **ou** une **fonction** du contexte — pour une valeur **dynamique** (agrégat calculé…).
124
+ * Une chaîne commençant par `"="` est stockée comme **formule** et évaluée par le tableur.
125
+ *
126
+ * Si `fx` est **aussi** fourni, c'est `fx` qui gagne.
127
+ */
128
+ value?: any | ((ctx: QSpreadsheetFillContext) => any)
129
+ /**
130
+ * **Formule** de la zone, **prioritaire sur `value`** : une chaîne (`"SUM(B1:B2)"` — le `=`
131
+ * initial est ajouté s'il manque) ou une fonction qui la renvoie. Écrit explicitement « c'est
132
+ * une formule », sans se soucier du préfixe :
133
+ *
134
+ * ```ts
135
+ * { row: 2, column: "total", fx: ({ letterOf, rowCount }) =>
136
+ * `SUM(${letterOf("effectif")}1:${letterOf("effectif")}${rowCount - 1})` }
137
+ * ```
138
+ */
139
+ fx?: QSpreadsheetFillFormula
140
+ }
141
+
142
+ /**
143
+ * Contexte remis à une `value` **fonction**, décrit la cellule remplie et le document
144
+ * au moment du remplissage. Permet d'écrire des formules dont la référence dépend de la
145
+ * position ou de la taille des données :
146
+ *
147
+ * ```ts
148
+ * // somme de TOUTE la colonne « effectif » (autant de lignes qu'il y en a)
149
+ * { row: 3, column: "net", fx: ({ letterOf, rowCount }) =>
150
+ * `SUM(${letterOf("effectif")}1:${letterOf("effectif")}${rowCount})` }
151
+ * ```
152
+ */
153
+ export interface QSpreadsheetFillContext {
154
+ /** Index (0-based) de la ligne remplie */
155
+ rowIndex: number
156
+ /** La ligne ciblée (référence live sur `rows`) */
157
+ row: Record<string, any>
158
+ /** Nom de la colonne remplie */
159
+ column: string
160
+ /** Index (0-based) de la colonne remplie */
161
+ columnIndex: number
162
+ /** Lettre A1 de la colonne remplie (ex. `"C"`) */
163
+ letter: string
164
+ /** Nombre de lignes du document au moment du remplissage */
165
+ rowCount: number
166
+ /** Lettre A1 d'une colonne par son **nom** (ex. `letterOf("effectif")` → `"C"`) */
167
+ letterOf: (column: string) => string
168
+ }
169
+
170
+ /** Contexte minimal fourni par le composant (ligne ciblée + taille du document). */
171
+ export interface QSpreadsheetFillEnv {
172
+ /** La ligne ciblée (référence live sur `rows`) */
173
+ row?: Record<string, any>
174
+ /** Nombre de lignes du document au moment du remplissage */
175
+ rowCount?: number
176
+ }
177
+
178
+ /**
179
+ * Valeurs à poser dans une ligne d'index `rowIndex`, d'après les zones `prefill` :
180
+ * chaque zone qui couvre cette ligne fournit sa valeur pour toutes ses colonnes — une `fx`
181
+ * (formule, **prioritaire**) sinon une `value` ; les deux peuvent être des fonctions appelées
182
+ * avec le `QSpreadsheetFillContext`. Renvoie `{}` s'il n'y a rien à poser. Pur → testable sans
183
+ * navigateur.
184
+ */
185
+ export function prefillForRow(
186
+ fills: QSpreadsheetFill[] | undefined,
187
+ rowIndex: number,
188
+ columnNames: string[],
189
+ env: QSpreadsheetFillEnv = {},
190
+ ): Record<string, any> {
191
+ if (!Array.isArray(fills) || !fills.length || !columnNames.length) return {}
192
+
193
+ const columnIndex = (name: string) => columnNames.indexOf(name)
194
+ const letterAt = (i: number) => colLetter(i)
195
+ const letterOf = (name: string) => letterAt(columnIndex(name))
196
+ const rowCount = env.rowCount ?? 0
197
+ const out: Record<string, any> = {}
198
+
199
+ for (const fill of fills) {
200
+ const rect = lockRect(fill, columnIndex)
201
+ if (!rect) continue
202
+ if (rect.r0 !== null && rowIndex < rect.r0) continue
203
+ if (rect.r1 !== null && rowIndex > rect.r1) continue
204
+
205
+ const from = rect.c0 ?? 0
206
+ const to = rect.c1 ?? columnNames.length - 1
207
+ for (let c = from; c <= to; c++) {
208
+ const name = columnNames[c]
209
+ if (name === undefined) continue
210
+
211
+ const ctx: QSpreadsheetFillContext = {
212
+ rowIndex,
213
+ row: env.row ?? {},
214
+ column: name,
215
+ columnIndex: c,
216
+ letter: letterAt(c),
217
+ rowCount,
218
+ letterOf,
219
+ }
220
+
221
+ // `fx` (formule) est prioritaire ; `value` ne sert que si aucune formule n'est fournie.
222
+ const formula = fillFormula(fill, ctx)
223
+ if (formula !== undefined) {
224
+ out[name] = formula
225
+ continue
226
+ }
227
+ if (fill.value !== undefined) {
228
+ out[name] = typeof fill.value === "function" ? fill.value(ctx) : fill.value
229
+ }
230
+ // ni `fx` ni `value` → rien à poser
231
+ }
232
+ }
233
+
234
+ return out
235
+ }
236
+
237
+ /** Formule résolue d'une zone (normalisée avec un `=` initial), ou `undefined`. */
238
+ function fillFormula(
239
+ fill: QSpreadsheetFill,
240
+ ctx: QSpreadsheetFillContext,
241
+ ): string | undefined {
242
+ if (fill.fx === undefined || fill.fx === null) return undefined
243
+ const raw = typeof fill.fx === "function" ? fill.fx(ctx) : fill.fx
244
+ return normalizeFormula(raw)
245
+ }
246
+
247
+ /** `"SUM(B1:B2)"` → `"=SUM(B1:B2)"` ; `""` / non-chaîne → `undefined`. */
248
+ function normalizeFormula(raw: unknown): string | undefined {
249
+ if (typeof raw !== "string") return undefined
250
+ const s = raw.trim()
251
+ if (!s) return undefined
252
+ return s.startsWith("=") ? s : "=" + s
253
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.56",
3
+ "version": "0.0.59",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
package/styles/main.css CHANGED
@@ -9493,6 +9493,26 @@ a.q-breadcrumbs__el:hover {
9493
9493
  .q-spreadsheet__cell--readonly {
9494
9494
  cursor: default;
9495
9495
  }
9496
+
9497
+ /* Cellule verrouillée (`cellReadonly` / `lockedRanges`) : pas d'édition + affordance discrète.
9498
+ Seule la `background-image` est posée — la `background-color` (formatage conditionnel,
9499
+ `cellBackground`) reste intacte. */
9500
+ .q-spreadsheet__cell--locked {
9501
+ cursor: default;
9502
+ background-image: repeating-linear-gradient(
9503
+ 135deg,
9504
+ rgb(0 0 0 / 0.05) 0 2px,
9505
+ transparent 2px 6px
9506
+ );
9507
+ }
9508
+
9509
+ .dark .q-spreadsheet__cell--locked {
9510
+ background-image: repeating-linear-gradient(
9511
+ 135deg,
9512
+ rgb(255 255 255 / 0.07) 0 2px,
9513
+ transparent 2px 6px
9514
+ );
9515
+ }
9496
9516
  .q-spreadsheet__cell--bool {
9497
9517
  text-align: center;
9498
9518
  }