@dnax/ui 0.0.58 → 0.0.60

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.
@@ -193,6 +193,7 @@ import {
193
193
  import { checkValidation, type ArkCheck } from "../lib/spreadsheetValidation"
194
194
  import { normalizeCellOptions } from "../lib/spreadsheetOptions"
195
195
  import { inLockRect, lockRects, prefillForRow } from "../lib/spreadsheetZones"
196
+ import { canAddRows, canRemoveRows, clampRows } from "../lib/spreadsheetRows"
196
197
  import {
197
198
  DEFAULT_ROW_KEY,
198
199
  diffDocuments,
@@ -264,6 +265,27 @@ interface Props {
264
265
  * les noms de colonnes inconnus sont ignorés.
265
266
  */
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
267
289
  /** Affiche le numéro de ligne (colonne de gauche) */
268
290
  showRowNumbers?: boolean
269
291
  /** Affiche l'en-tête de colonne (lettre + label) */
@@ -499,6 +521,7 @@ const emit = defineEmits<{
499
521
  "cell-edit-end": [payload: { row: number; column: string; canceled: boolean }]
500
522
  "selection-change": [value: QSpreadsheetSelection | null]
501
523
  "structure-change": [payload: { rows: Record<string, any>[]; columns: QSpreadsheetColumn[]; reason: string }]
524
+ "prefill": [payload: { count: number; mode: "auto" | "manual" }]
502
525
  "update:dirty": [value: boolean]
503
526
  "update:changes": [value: QSpreadsheetChanges]
504
527
  }>()
@@ -542,6 +565,8 @@ const ensureRowKeys = (rows: Record<string, any>[] | undefined) => {
542
565
  }
543
566
 
544
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
545
570
  const state = ref<Record<string, any>[]>([])
546
571
  watch(
547
572
  () => props.rows,
@@ -553,6 +578,8 @@ watch(
553
578
  state.value = v.map((r) => ({ ...r }))
554
579
  // Lignes **externes** (nouveau document) → nouvelle référence de comparaison
555
580
  if (trackReady) acceptChanges()
581
+ // Nouveau document → applique les défauts sur les cellules vides.
582
+ autoPrefill()
556
583
  }
557
584
  },
558
585
  { immediate: true },
@@ -571,6 +598,8 @@ watch(
571
598
  cols.value = v.map((c) => ({ ...c }))
572
599
  // Colonnes **externes** (nouveau schéma) → nouvelle référence de comparaison
573
600
  if (trackReady) acceptChanges()
601
+ // Les zones de `prefill` dépendent des noms de colonnes → (re)applique.
602
+ autoPrefill()
574
603
  }
575
604
  },
576
605
  { immediate: true },
@@ -580,6 +609,12 @@ const pushCols = (next: QSpreadsheetColumn[]) => {
580
609
  emit("update:columns", next)
581
610
  }
582
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
+
583
618
  const colIndex = (name: string) => cols.value.findIndex((c) => c.name === name)
584
619
  const colNameAt = (i: number) => cols.value[i]?.name ?? ""
585
620
  const colOf = (name: string) => cols.value.find((c) => c.name === name)
@@ -1279,11 +1314,11 @@ const blankRow = (at = state.value.length): Record<string, any> => {
1279
1314
  }
1280
1315
 
1281
1316
  /**
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.
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`.
1285
1320
  */
1286
- const applyPrefill = (): number => {
1321
+ function runPrefill(mode: "auto" | "manual"): number {
1287
1322
  const fills = props.prefill
1288
1323
  if (!fills?.length || props.disable) return 0
1289
1324
  const names = cols.value.map((c) => c.name)
@@ -1300,14 +1335,35 @@ const applyPrefill = (): number => {
1300
1335
  return out
1301
1336
  })
1302
1337
  if (!count) return 0
1303
- pushHistory()
1338
+ if (mode === "manual") pushHistory()
1304
1339
  state.value = next
1305
1340
  emit("update:rows", next)
1341
+ emit("prefill", { count, mode })
1306
1342
  return count
1307
1343
  }
1308
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
+
1309
1364
  const addRow = (at?: number) => {
1310
1365
  if (props.readonly || props.disable) return
1366
+ if (!canAddRow.value) return
1311
1367
  pushHistory()
1312
1368
  const idx = at ?? state.value.length
1313
1369
  const next = [...state.value]
@@ -1320,12 +1376,14 @@ const addRow = (at?: number) => {
1320
1376
 
1321
1377
  const removeSelectedRows = () => {
1322
1378
  if (props.readonly || props.disable) return
1323
- pushHistory()
1324
- purgeMergesAndRules()
1325
1379
  const rect = selRect.value
1326
1380
  if (!rect || !state.value.length) return
1327
1381
  const indexes = new Set<number>()
1328
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()
1329
1387
  const sorted = [...indexes].sort((a, b) => b - a)
1330
1388
  const next = [...state.value]
1331
1389
  for (const i of sorted) next.splice(i, 1)
@@ -2591,6 +2649,7 @@ const doSortBy = (desc = false) => {
2591
2649
  // ─── Insertion au curseur (lignes / colonnes) ───
2592
2650
  const insertRowAt = (pos: "above" | "below") => {
2593
2651
  if (!canEdit.value) return
2652
+ if (!canAddRow.value) return
2594
2653
  pushHistory()
2595
2654
  purgeMergesAndRules()
2596
2655
  if (!state.value.length) {
@@ -2986,6 +3045,10 @@ const onFilterSearch = (e: Event) => {
2986
3045
  }
2987
3046
 
2988
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()
2989
3052
  // Référence initiale : à partir d'ici, toute différence avec elle = « modifié »
2990
3053
  trackReady = true
2991
3054
  acceptChanges()
@@ -3159,15 +3222,16 @@ watch(
3159
3222
  const prevKey = localSheets.value[sheetIdx.value]?.key
3160
3223
  let n = 1
3161
3224
  localSheets.value = v.map((s) => {
3162
- const key = s.key ?? "sheet-" + n
3163
- n++
3225
+ // `num` capturé AVANT l'incrément : clé **et** nom par défaut partagent le même numéro
3226
+ // (sinon le 1er onglet s'appelait « Sheet 2 »).
3227
+ const num = n++
3164
3228
  // Clés injectées sur les objets du parent (comme pour `rows`) : chaque feuille est
3165
3229
  // identifiable avant même d'être ouverte
3166
3230
  ensureRowKeys(s.rows)
3167
3231
  return {
3168
3232
  ...s,
3169
- key,
3170
- name: s.name ?? sheetName(n),
3233
+ key: s.key ?? "sheet-" + num,
3234
+ name: s.name ?? sheetName(num),
3171
3235
  rows: (s.rows ?? []).map((r) => ({ ...r })),
3172
3236
  columns: s.columns ? canonicalColumns(s.columns) : undefined,
3173
3237
  }
@@ -3192,7 +3256,7 @@ watch([state, cols], () => {
3192
3256
 
3193
3257
  const activeSheetName = computed(() => {
3194
3258
  const s = localSheets.value[sheetIdx.value]
3195
- if (s) return s.name ?? "Sheet " + (sheetIdx.value + 1)
3259
+ if (s) return s.name ?? sheetName(sheetIdx.value + 1)
3196
3260
  return props.sheets?.[0]?.name ?? sheetName(1)
3197
3261
  })
3198
3262
  const sheetRename = ref<{ idx: number; val: string } | null>(null)
@@ -3225,7 +3289,7 @@ const addSheet = () => {
3225
3289
  // premier onglet (propriété sheets vide) — devient multi
3226
3290
  const first: QSpreadsheetSheet = {
3227
3291
  key: "sheet-1",
3228
- name: "Sheet 1",
3292
+ name: sheetName(1),
3229
3293
  columns: cols.value.map((c) => ({ ...c })),
3230
3294
  rows: state.value.map((r) => ({ ...r })),
3231
3295
  }
@@ -3622,6 +3686,8 @@ const importCsv = (text: string, opts: { delimiter?: string; headers?: boolean }
3622
3686
  }))
3623
3687
  data = rows
3624
3688
  }
3689
+ // Plafond `maxRows` : l'import ne crée pas plus de lignes que la limite.
3690
+ data = clampRows(data, props.maxRows)
3625
3691
  const rowObjs = data.map((row) => {
3626
3692
  const obj: Record<string, any> = { [ROW_KEY]: newRowKey() }
3627
3693
  colsArr.forEach((c, ci) => {
@@ -4395,11 +4461,11 @@ defineExpose({
4395
4461
  </span>
4396
4462
 
4397
4463
  <span class="q-spreadsheet__tb-group">
4398
- <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add row" aria-label="Add row" @click="addRow()">
4464
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !canAddRow" title="Add row" aria-label="Add row" @click="addRow()">
4399
4465
  <Icon :icon="icons.rows3" aria-hidden="true" />
4400
4466
  <Icon :icon="icons.plus" class="q-spreadsheet__tool-badge" aria-hidden="true" />
4401
4467
  </button>
4402
- <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
4468
+ <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable || !selRect || !canRemoveRow" title="Remove selected row(s)" aria-label="Remove rows" @click="removeSelectedRows">
4403
4469
  <Icon :icon="icons.minus" aria-hidden="true" />
4404
4470
  </button>
4405
4471
  <button class="q-spreadsheet__tool" type="button" :disabled="readonly || disable" title="Add column" aria-label="Add column" @click="addColumn()">
@@ -5012,13 +5078,13 @@ defineExpose({
5012
5078
  </button>
5013
5079
 
5014
5080
  <div class="q-spreadsheet__sep" />
5015
- <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('above')">
5081
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('above')">
5016
5082
  <Icon :icon="icons.arrowUp" aria-hidden="true" /> {{ t('insertRowAbove') }}
5017
5083
  </button>
5018
- <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit" @click="insertRowAt('below')">
5084
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !canAddRow" @click="insertRowAt('below')">
5019
5085
  <Icon :icon="icons.arrowDown" aria-hidden="true" /> {{ t('insertRowBelow') }}
5020
5086
  </button>
5021
- <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect" @click="removeSelectedRows">
5087
+ <button type="button" class="q-spreadsheet__mi" :disabled="!canEdit || !selRect || !canRemoveRow" @click="removeSelectedRows">
5022
5088
  <Icon :icon="icons.trash2" aria-hidden="true" /> {{ t('deleteRows') }}
5023
5089
  </button>
5024
5090
  <div class="q-spreadsheet__sep" />
@@ -0,0 +1,127 @@
1
+ // Tests du **placement des noms d'axes** de `<q-chart>` : `chartToECharts()` produit bien des
2
+ // noms dans le cadre. Vérifié en rendant l'option en SVG via l'**SSR d'ECharts** — du JS pur,
3
+ // aucun navigateur (cf. règle projet).
4
+ //
5
+ // Rappel du bug corrigé : sans `nameLocation`, le moteur place le nom **à la fin** de l'axe —
6
+ // le nom X s'écrivait vers la droite (hors cadre) et le nom Y, centré sur la ligne d'axe,
7
+ // débordait à gauche (rogné) dès que le libellé était large.
8
+ import { describe, expect, it } from "bun:test"
9
+ import * as echarts from "echarts"
10
+ import { chartToECharts } from "./chart"
11
+
12
+ const THEME = { text: "#000000", muted: "#666666", grid: "#dddddd" }
13
+ /** Largeur approximative d'un caractère à 12px (mesure faite sur le rendu ECharts). */
14
+ const CHAR_W = 7
15
+ const FONT_H = 12
16
+
17
+ interface Box {
18
+ x0: number
19
+ x1: number
20
+ y0: number
21
+ y1: number
22
+ }
23
+
24
+ function renderSvg(config: Record<string, any>, width: number, height: number): string {
25
+ const chart = echarts.init(null as any, null, {
26
+ renderer: "svg",
27
+ ssr: true,
28
+ width,
29
+ height,
30
+ })
31
+ chart.setOption(chartToECharts({ theme: THEME, ...config }))
32
+ const svg = chart.renderToSVGString()
33
+ chart.dispose()
34
+ return svg
35
+ }
36
+
37
+ /** Boîte approximative du `<text>` d'un nom d'axe (via `translate` + `text-anchor`). */
38
+ function nameBox(svg: string, name: string): Box | undefined {
39
+ for (const line of svg.split("\n")) {
40
+ if (!line.includes("<text")) continue
41
+ const text = (line.match(/>([^<]*)<\/text>/) ?? [, ""])[1]
42
+ if (text !== name) continue
43
+ const m = line.match(/translate\(([-\d.]+) ([-\d.]+)\)/)
44
+ if (!m) continue
45
+ const x = Number(m[1])
46
+ const y = Number(m[2])
47
+ const anchor = (line.match(/text-anchor="([^"]+)"/) ?? [, "middle"])[1]
48
+ const w = text.length * CHAR_W
49
+ const x0 = anchor === "start" ? x : anchor === "end" ? x - w : x - w / 2
50
+ return { x0, x1: x0 + w, y0: y - FONT_H / 2, y1: y + FONT_H / 2 }
51
+ }
52
+ return undefined
53
+ }
54
+
55
+ const inside = (b: Box, w: number, h: number) =>
56
+ b.x0 >= 0 && b.x1 <= w && b.y0 >= 0 && b.y1 <= h
57
+
58
+ const verticalConfig = {
59
+ marks: [
60
+ {
61
+ type: "line",
62
+ data: [
63
+ { month: "Jan", value: 1_200_000 },
64
+ { month: "Feb", value: 3_400_000 },
65
+ { month: "Mar", value: 2_600_000 },
66
+ ],
67
+ x: "month",
68
+ y: "value",
69
+ },
70
+ ],
71
+ // libellés volontairement larges (le cas qui rognait le nom Y)
72
+ x: { label: "Month of the year" },
73
+ y: { label: "Revenue in thousands" },
74
+ }
75
+
76
+ const horizontalConfig = {
77
+ marks: [
78
+ {
79
+ type: "bar",
80
+ orientation: "horizontal",
81
+ data: [
82
+ { dept: "Engineering", value: 34 },
83
+ { dept: "Sales", value: 26 },
84
+ ],
85
+ x: "dept",
86
+ y: "value",
87
+ },
88
+ ],
89
+ x: { label: "Headcount" },
90
+ y: { label: "Department" },
91
+ }
92
+
93
+ describe("noms d'axes — placement dans le cadre", () => {
94
+ it("X et Y restent dans le cadre (vertical, horizontal, petit canevas)", () => {
95
+ const cases: [Record<string, any>, number, number][] = [
96
+ [verticalConfig, 520, 280],
97
+ [horizontalConfig, 520, 280],
98
+ [verticalConfig, 300, 160],
99
+ ]
100
+ for (const [config, w, h] of cases) {
101
+ const svg = renderSvg(config, w, h)
102
+ const bx = nameBox(svg, config.x.label)
103
+ const by = nameBox(svg, config.y.label)
104
+ expect(bx).toBeDefined()
105
+ expect(by).toBeDefined()
106
+ expect(inside(bx!, w, h)).toBe(true)
107
+ expect(inside(by!, w, h)).toBe(true)
108
+ }
109
+ })
110
+
111
+ it("le nom X est centré sous l'axe, pas collé au bord droit", () => {
112
+ const svg = renderSvg(verticalConfig, 520, 280)
113
+ const bx = nameBox(svg, verticalConfig.x.label)!
114
+ // centré : commence bien après le bord gauche et finit bien avant le bord droit
115
+ expect(bx.x0).toBeGreaterThan(60)
116
+ expect(bx.x1).toBeLessThan(520 - 60)
117
+ // sous la zone de tracé
118
+ expect(bx.y0).toBeGreaterThan(200)
119
+ })
120
+
121
+ it("le nom Y est ancré à gauche sur la ligne d'axe (jamais rogné), en haut", () => {
122
+ const svg = renderSvg(verticalConfig, 520, 280)
123
+ const by = nameBox(svg, verticalConfig.y.label)!
124
+ expect(by.x0).toBeGreaterThan(0)
125
+ expect(by.y1).toBeLessThan(160)
126
+ })
127
+ })
package/lib/chart.ts CHANGED
@@ -1180,7 +1180,7 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1180
1180
  const scaleBand = visualMaps.some((vm) => vm.show !== false) ? 30 : 0
1181
1181
  /** Bandeau du **nom d'axe** (nom de l'axe y au-dessus, nom de l'axe x en dessous) */
1182
1182
  const axisNameTop = config.y?.label ? 16 : 0
1183
- const axisNameBottom = config.x?.label ? 16 : 0
1183
+ const axisNameBottom = config.x?.label ? 22 : 0
1184
1184
  const legendOption = !legendOn
1185
1185
  ? { show: false }
1186
1186
  : {
@@ -1241,22 +1241,36 @@ export function chartToECharts(config: QChartConfig): Record<string, any> {
1241
1241
  splitLine: { show: axis?.grid ?? true, lineStyle: { color: theme.grid } },
1242
1242
  })
1243
1243
 
1244
+ // ─── Nom d'axe : placement **toujours dans le cadre** ───
1245
+ // Axe **horizontal** : centré sous l'axe (`middle`). À la fin de l'axe (`end`, le défaut
1246
+ // ECharts) le nom s'écrivait vers la droite, hors du cadre.
1247
+ const axisNameX = { nameLocation: "middle" as const, nameGap: 32 }
1248
+ // Axe **vertical** : ancré à **gauche** (`align: left`) sur la ligne d'axe, en haut.
1249
+ // Un `nameGap` fixe ne peut pas convenir : un nom centré sur l'axe déborde à gauche, un nom
1250
+ // rotaté au milieu recouvre/est rogné par les nombres (largeur variable : `1,000` vs
1251
+ // `1,000,000`). Ancré à gauche, il s'étend vers la droite → jamais rogné, jamais superposé.
1252
+ const axisNameY = {
1253
+ nameLocation: "end" as const,
1254
+ nameTextStyle: { ...axisLabel, align: "left" as const },
1255
+ }
1244
1256
  // Orientation horizontale : catégories sur l'axe Y
1245
1257
  const hasBar = series.some((s) => s.type === "bar")
1246
- const xAxis = hasHeatmap
1258
+ const xAxisBase = hasHeatmap
1247
1259
  ? categoryAxis(config.x, categories, true)
1248
1260
  : horizontal
1249
1261
  ? valueAxis(config.x, hasBar)
1250
1262
  : axisType === "category"
1251
1263
  ? categoryAxis(config.x)
1252
1264
  : valueAxis(config.x, false)
1253
- const yAxis = hasHeatmap
1265
+ const yAxisBase = hasHeatmap
1254
1266
  ? categoryAxis(config.y, rowCategories, true)
1255
1267
  : horizontal
1256
1268
  ? axisType === "category"
1257
1269
  ? categoryAxis(config.y)
1258
1270
  : valueAxis(config.y, false)
1259
1271
  : valueAxis(config.y, hasBar)
1272
+ const xAxis = config.x?.label ? { ...xAxisBase, ...axisNameX } : xAxisBase
1273
+ const yAxis = config.y?.label ? { ...yAxisBase, ...axisNameY } : yAxisBase
1260
1274
 
1261
1275
  // Familles **hors axes** (`pie` seul) : ni grid ni xAxis/yAxis — sinon ECharts laisse
1262
1276
  // un cadre fantôme et décale le titre.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.58",
3
+ "version": "0.0.60",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",