@dnax/ui 0.0.56 → 0.0.58

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,7 @@ 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"
175
196
  import {
176
197
  DEFAULT_ROW_KEY,
177
198
  diffDocuments,
@@ -207,6 +228,42 @@ interface Props {
207
228
  lang?: "en" | "fr"
208
229
  /** Validations par plage : [{ r0,c0,r1,c1, validation }] — s'ajoutent aux colonnes */
209
230
  validators?: QSpreadsheetRangeValidator[]
231
+ /**
232
+ * Cellules **verrouillées**, écrites avec des **noms** (jamais d'index) :
233
+ * - `{ row: 2, column: "total" }` → la cellule « total » de la ligne 2 ;
234
+ * - `{ row: 0 }` → toute la ligne 0 ;
235
+ * - `{ column: "total" }` → toute la colonne « total » ;
236
+ * - `{ from: { row, column }, to: { row, column } }` → le bloc entre les deux coins.
237
+ *
238
+ * S'applique **même à une cellule vide**. S'ajoute à `columns[].cellReadonly` et aux props
239
+ * globales `readonly` / `disable`.
240
+ */
241
+ lockedRanges?: QSpreadsheetLock[]
242
+ /**
243
+ * **Préremplit** des zones, écrites avec des **noms** de colonnes (jamais d'index) —
244
+ * même vocabulaire que `lockedRanges`, plus une `value` :
245
+ * - `[{ column: "status", value: "todo" }]` → toute la colonne « status » ;
246
+ * - `[{ row: 0, value: "—" }]` → toute la ligne 0 ;
247
+ * - `[{ row: 2, column: "total", value: 0 }]` → la cellule (ligne 2, colonne « total ») ;
248
+ * - `[{ from: { row: 0, column: "total" }, to: { row: 2, column: "status" }, value: false }]`
249
+ * → le bloc entre les deux coins (bornes **incluses**).
250
+ *
251
+ * `value` est une **constante** *ou* une **fonction** `(ctx) => any` (ctx : `rowIndex`,
252
+ * `row`, `column`, `letter`, `rowCount`, `letterOf`) — pour une valeur **dynamique**.
253
+ *
254
+ * `fx` porte une **formule**, **prioritaire sur `value`** : une chaîne (`"SUM(B1:B2)"` — le `=`
255
+ * initial est ajouté s'il manque) ou une fonction `(ctx) => string`. C'est la façon explicite
256
+ * d'écrire une formule, p. ex. la somme d'une colonne :
257
+ * `{ row: 3, column: "net", fx: (c) => `SUM(${c.letterOf("effectif")}1:${c.letterOf("effectif")}${c.rowCount})` }`.
258
+ * (Une chaîne de `value` commençant par `"="` reste stockée comme formule, mais `fx` est
259
+ * préféré : il documente l'intention et n'exige pas le préfixe.)
260
+ *
261
+ * Une ligne **neuve** (bouton « + », insertion) naît déjà préremplie. Les données **déjà
262
+ * chargées** ne sont **jamais** modifiées automatiquement : appeler `applyPrefill()`
263
+ * (méthode exposée) pour combler les cellules **vides** — le dernier `prefill` gagne,
264
+ * les noms de colonnes inconnus sont ignorés.
265
+ */
266
+ prefill?: QSpreadsheetFill[]
210
267
  /** Affiche le numéro de ligne (colonne de gauche) */
211
268
  showRowNumbers?: boolean
212
269
  /** Affiche l'en-tête de colonne (lettre + label) */
@@ -791,6 +848,27 @@ const cellTitle = (row: number, column: string): string | undefined => {
791
848
  return undefined
792
849
  }
793
850
 
851
+ /** Verrous de `lockedRanges`, traduits en rectangles d'index (noms de colonnes résolus). */
852
+ const lockRectsResolved = computed(() => lockRects(props.lockedRanges, colIndex))
853
+
854
+ /**
855
+ * La cellule est-elle **verrouillée** ? Une seule porte pour toutes les mutations : props
856
+ * globales (`readonly` / `disable`), colonne `editable: false`, prédicat `cellReadonly(val, row)`
857
+ * et verrous `lockedRanges`. S'applique **même à une cellule vide** (il n'y a rien à effacer,
858
+ * mais on ne peut pas y écrire) — verrouiller n'est pas effacer.
859
+ */
860
+ const isCellLocked = (row: number, column: string): boolean => {
861
+ if (props.readonly || props.disable) return true
862
+ const col = colOf(column)
863
+ if (!col) return false
864
+ if (col.editable === false) return true
865
+ const rowData = state.value[row] ?? {}
866
+ if (col.cellReadonly?.(rowData[column], rowData, row)) return true
867
+ const ci = colIndex(column)
868
+ if (ci === -1) return false
869
+ return lockRectsResolved.value.some((z) => inLockRect(z, row, ci))
870
+ }
871
+
794
872
  const cellClasses = (row: number, column: string) => {
795
873
  const col = colOf(column)
796
874
  const v = state.value[row]?.[column]
@@ -806,6 +884,9 @@ const cellClasses = (row: number, column: string) => {
806
884
  isActive(row, column) && "q-spreadsheet__cell--active",
807
885
  editing.value?.row === row && editing.value?.column === column && "q-spreadsheet__cell--editing",
808
886
  props.readonly && "q-spreadsheet__cell--readonly",
887
+ // Verrou **ciblé** (colonne, prédicat, plage) : affordance visuelle. Le verrou global
888
+ // (`readonly` / `disable`) n'ajoute rien — hacher toute la grille serait illisible.
889
+ isCellLocked(row, column) && !props.readonly && !props.disable && "q-spreadsheet__cell--locked",
809
890
  )
810
891
  }
811
892
 
@@ -880,9 +961,8 @@ const editorInputMode = computed(() => {
880
961
  })
881
962
 
882
963
  const startEdit = (row: number, column: string, initial?: string) => {
883
- if (props.readonly || props.disable) return
884
964
  const col = colOf(column)
885
- if (col?.editable === false || col?.type === "boolean") return
965
+ if (isCellLocked(row, column) || col?.type === "boolean") return
886
966
  const raw = state.value[row]?.[column]
887
967
  if (col?.type === "select") {
888
968
  const opt = cellOptionsOf(col).find((o) => o.value === raw)
@@ -1018,17 +1098,14 @@ const setCellValue = (row: number, column: string, old: any, next: any) => {
1018
1098
  }
1019
1099
 
1020
1100
  const clearCell = (row: number, column: string) => {
1021
- const col = colOf(column)
1022
- if (col?.editable === false || props.readonly || props.disable) return
1101
+ if (isCellLocked(row, column)) return
1023
1102
  const old = state.value[row]?.[column]
1024
1103
  if (isBlankValue(old) || (Array.isArray(old) && old.length === 0)) return
1025
1104
  setCellValue(row, column, old, null)
1026
1105
  }
1027
1106
 
1028
1107
  const toggleBoolean = (row: number, column: string) => {
1029
- if (props.readonly || props.disable) return
1030
- const col = colOf(column)
1031
- if (col?.editable === false) return
1108
+ if (isCellLocked(row, column)) return
1032
1109
  const old = !!state.value[row]?.[column]
1033
1110
  setCellValue(row, column, old, !old)
1034
1111
  }
@@ -1040,7 +1117,7 @@ const clearSelection = () => {
1040
1117
  for (let r = rect.r0; r <= rect.r1; r++) {
1041
1118
  for (let c = rect.c0; c <= rect.c1; c++) {
1042
1119
  const col = cols.value[c]
1043
- if (!col || col.editable === false || props.readonly || props.disable) continue
1120
+ if (!col || isCellLocked(r, col.name)) continue
1044
1121
  const v = state.value[r]?.[col.name]
1045
1122
  if (!isBlankValue(v) && !(Array.isArray(v) && v.length === 0))
1046
1123
  changed.push({ row: r, column: col.name })
@@ -1185,19 +1262,56 @@ const redo = () => {
1185
1262
  }
1186
1263
 
1187
1264
  // ─── Lignes & colonnes ───
1188
- const blankRow = (): Record<string, any> => {
1265
+ const blankRow = (at = state.value.length): Record<string, any> => {
1189
1266
  // `_key` posée dès la création : la ligne est identifiable sans attendre un emit
1190
1267
  const row: Record<string, any> = { [ROW_KEY]: newRowKey() }
1191
1268
  for (const col of cols.value) row[col.name] = col.type === "boolean" ? false : ""
1269
+ // Une ligne neuve naît préremplie par les zones `prefill` qui la visent.
1270
+ // `rowCount` compte la ligne en cours de création (le document l'aura).
1271
+ Object.assign(
1272
+ row,
1273
+ prefillForRow(props.prefill, at, cols.value.map((c) => c.name), {
1274
+ row,
1275
+ rowCount: state.value.length + 1,
1276
+ }),
1277
+ )
1192
1278
  return row
1193
1279
  }
1194
1280
 
1281
+ /**
1282
+ * Comble les cellules **vides** d'après `prefill` (sans jamais écraser une valeur
1283
+ * existante) et renvoie le nombre de cellules remplies. Utile après avoir chargé
1284
+ * `rows` : les données déjà présentes ne sont pas touchées à l'initialisation.
1285
+ */
1286
+ const applyPrefill = (): number => {
1287
+ const fills = props.prefill
1288
+ if (!fills?.length || props.disable) return 0
1289
+ const names = cols.value.map((c) => c.name)
1290
+ let count = 0
1291
+ const next = state.value.map((row, r) => {
1292
+ const patch = prefillForRow(fills, r, names, { row, rowCount: state.value.length })
1293
+ let out = row
1294
+ for (const [name, value] of Object.entries(patch)) {
1295
+ if (!isBlankValue(row[name])) continue
1296
+ if (out === row) out = { ...row }
1297
+ out[name] = value
1298
+ count++
1299
+ }
1300
+ return out
1301
+ })
1302
+ if (!count) return 0
1303
+ pushHistory()
1304
+ state.value = next
1305
+ emit("update:rows", next)
1306
+ return count
1307
+ }
1308
+
1195
1309
  const addRow = (at?: number) => {
1196
1310
  if (props.readonly || props.disable) return
1197
1311
  pushHistory()
1198
1312
  const idx = at ?? state.value.length
1199
1313
  const next = [...state.value]
1200
- next.splice(idx, 0, blankRow())
1314
+ next.splice(idx, 0, blankRow(idx))
1201
1315
  state.value = next
1202
1316
  emit("update:rows", next)
1203
1317
  emit("structure-change", { rows: next, columns: cols.value, reason: "add-row" })
@@ -1363,7 +1477,7 @@ const pasteClip = async () => {
1363
1477
  const c = startCol + j
1364
1478
  if (r >= state.value.length || c >= cols.value.length) return
1365
1479
  const col = cols.value[c]!
1366
- if (col.editable === false) return
1480
+ if (isCellLocked(r, col.name)) return
1367
1481
  const old = state.value[r]?.[col.name]
1368
1482
  const next = coerceValue(col, text, old)
1369
1483
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -1458,10 +1572,11 @@ const fillDownKey = () => {
1458
1572
  for (let c = rect.c0; c <= rect.c1; c++) {
1459
1573
  const name = colNameAt(c)
1460
1574
  const col = colOf(name)
1461
- if (!col || col.editable === false) continue
1575
+ if (!col) continue
1462
1576
  const src = rect.r0 - 1
1463
1577
  const sv = state.value[src]?.[name]
1464
1578
  for (let r = rect.r0; r <= rect.r1; r++) {
1579
+ if (isCellLocked(r, name)) continue
1465
1580
  const old = state.value[r]?.[name]
1466
1581
  if (old !== sv) setCellValue(r, name, old, sv)
1467
1582
  }
@@ -1477,10 +1592,11 @@ const fillRightKey = () => {
1477
1592
  const src = rect.c0 - 1
1478
1593
  const sname = colNameAt(src)
1479
1594
  const col = colOf(sname)
1480
- if (!col || col.editable === false) continue
1595
+ if (!col) continue
1481
1596
  const sv = state.value[r]?.[sname]
1482
1597
  for (let c = rect.c0; c <= rect.c1; c++) {
1483
1598
  const name = colNameAt(c)
1599
+ if (isCellLocked(r, name)) continue
1484
1600
  const old = state.value[r]?.[name]
1485
1601
  if (old !== sv) setCellValue(r, name, old, sv)
1486
1602
  }
@@ -1596,7 +1712,7 @@ const onKeydown = (e: KeyboardEvent) => {
1596
1712
  // Frappe directe : remplace le contenu (comportement Excel)
1597
1713
  if (e.key.length === 1 && !mod && !e.altKey) {
1598
1714
  const col = colOf(sel.value.column)
1599
- if (col && col.editable !== false && col.type !== "boolean") {
1715
+ if (col && col.type !== "boolean" && !isCellLocked(sel.value.row, sel.value.column)) {
1600
1716
  e.preventDefault()
1601
1717
  startEdit(sel.value.row, sel.value.column, e.key)
1602
1718
  }
@@ -1714,8 +1830,7 @@ const multiSelected = (opt: QSpreadsheetCellOption) =>
1714
1830
  const toggleMultiOption = (opt: QSpreadsheetCellOption) => {
1715
1831
  if (!editing.value) return
1716
1832
  const { row, column } = editing.value
1717
- const col = colOf(column)
1718
- if (col?.editable === false || props.readonly || props.disable) return
1833
+ if (isCellLocked(row, column)) return
1719
1834
  const old = state.value[row]?.[column]
1720
1835
  const current = multiValues(old)
1721
1836
  const next = multiSelected(opt)
@@ -1739,11 +1854,12 @@ const syncFx = () => {
1739
1854
  watch([sel, state], syncFx)
1740
1855
 
1741
1856
  const fxCanEdit = computed(() => {
1742
- if (props.readonly || props.disable || !sel.value) return false
1743
- const col = colOf(sel.value.column)
1857
+ if (!sel.value) return false
1858
+ const { row, column } = sel.value
1859
+ if (isCellLocked(row, column)) return false
1860
+ const col = colOf(column)
1744
1861
  return (
1745
1862
  !!col &&
1746
- col.editable !== false &&
1747
1863
  col.type !== "boolean" &&
1748
1864
  col.type !== "select" &&
1749
1865
  col.type !== "multiselect"
@@ -2495,7 +2611,7 @@ const insertRowAt = (pos: "above" | "below") => {
2495
2611
  : state.value.length
2496
2612
  const idx = Math.max(0, Math.min(state.value.length, at))
2497
2613
  const next = [...state.value]
2498
- next.splice(idx, 0, blankRow())
2614
+ next.splice(idx, 0, blankRow(idx))
2499
2615
  state.value = next
2500
2616
  shiftRowKeys(idx, 1)
2501
2617
  emit("update:rows", next)
@@ -2688,7 +2804,7 @@ const applyFill = () => {
2688
2804
  if (rr < 0 || rr >= state.value.length || cc < 0 || cc >= cols.value.length) continue
2689
2805
  const name = colNameAt(cc)
2690
2806
  const col = colOf(name)
2691
- if (!col || col.editable === false) continue
2807
+ if (!col || isCellLocked(rr, name)) continue
2692
2808
 
2693
2809
  let val: any
2694
2810
  // Série numérique / dates (2 graines) vers le bas ou la droite (Ctrl = copie)
@@ -3421,8 +3537,7 @@ const findMarkClass = (r: number, ci: number) =>
3421
3537
  )
3422
3538
  const findReplaceCurrent = () => {
3423
3539
  const m = findMatches.value[findIdx.value]
3424
- if (!m || props.readonly || props.disable) return
3425
- const col = cols.value[m.ci]
3540
+ if (!m || isCellLocked(m.r, m.name)) return
3426
3541
  const raw = state.value[m.r]?.[m.name]
3427
3542
  if (typeof raw !== "string" || !findQuery.value) return
3428
3543
  const next = findCase.value
@@ -3437,6 +3552,7 @@ const findReplaceAll = () => {
3437
3552
  pushHistory()
3438
3553
  const changed: { r: number; name: string; old: string; next: string }[] = []
3439
3554
  for (const m of findMatches.value) {
3555
+ if (isCellLocked(m.r, m.name)) continue
3440
3556
  const raw = state.value[m.r]?.[m.name]
3441
3557
  if (typeof raw !== "string") continue
3442
3558
  const next = findCase.value
@@ -3575,7 +3691,7 @@ const pasteValues = async () => {
3575
3691
  const c = c0 + j
3576
3692
  if (r >= state.value.length || c >= cols.value.length) continue
3577
3693
  const col = cols.value[c]!
3578
- if (col.editable === false) continue
3694
+ if (isCellLocked(r, col.name)) continue
3579
3695
  const old = state.value[r]?.[col.name]
3580
3696
  const next = coerceLiteral(col, lines[i]![j] ?? "", old)
3581
3697
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -3636,7 +3752,7 @@ const pasteTransposed = async () => {
3636
3752
  const c = c0 + i
3637
3753
  if (r >= state.value.length || c >= cols.value.length) continue
3638
3754
  const col = cols.value[c]!
3639
- if (col.editable === false) continue
3755
+ if (isCellLocked(r, col.name)) continue
3640
3756
  const old = state.value[r]?.[col.name]
3641
3757
  const next = coerceValue(col, lines[i]![j] ?? "", old)
3642
3758
  if (next !== old) setCellValue(r, col.name, old, next)
@@ -4151,6 +4267,7 @@ defineExpose({
4151
4267
  commitEdit,
4152
4268
  cancelEdit,
4153
4269
  addRow,
4270
+ applyPrefill,
4154
4271
  insertRowAt,
4155
4272
  removeSelectedRows,
4156
4273
  addColumn,
@@ -4641,7 +4758,7 @@ defineExpose({
4641
4758
  type="button"
4642
4759
  class="q-spreadsheet__checkbox"
4643
4760
  :class="{ 'q-spreadsheet__checkbox--on': !!cellValue(ri, col.name) }"
4644
- :disabled="readonly || disable || col.editable === false"
4761
+ :disabled="isCellLocked(ri, col.name)"
4645
4762
  aria-label="Toggle"
4646
4763
  @click.stop="toggleBoolean(ri, col.name)"
4647
4764
  >
@@ -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.58",
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
  }