@dnax/ui 0.0.66 → 0.0.67

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.
@@ -186,6 +186,10 @@ import {
186
186
  ISO_DATE,
187
187
  isoAddDays,
188
188
  shiftFormulaRefs,
189
+ functionQueryAt,
190
+ rankSuggestions,
191
+ functionCallAt,
192
+ signatureArgs,
189
193
  csvEscape,
190
194
  deriveCols,
191
195
  parseCsv,
@@ -349,6 +353,9 @@ const I18N = {
349
353
  sheet: "Sheet",
350
354
  fxPlaceholder: "Value or formula (=SUM(A1:A3)…)",
351
355
  fxNoSel: "Select a cell…",
356
+ fxSyntax: "Syntax error",
357
+ fxEmpty: "empty",
358
+ fxColumn: "column",
352
359
  cut: "Cut",
353
360
  copy: "Copy",
354
361
  paste: "Paste",
@@ -427,6 +434,9 @@ const I18N = {
427
434
  sheet: "Feuille",
428
435
  fxPlaceholder: "Valeur ou formule (=SOMME(A1:A3)…)",
429
436
  fxNoSel: "Sélectionnez une cellule…",
437
+ fxSyntax: "Erreur de syntaxe",
438
+ fxEmpty: "vide",
439
+ fxColumn: "colonne",
430
440
  cut: "Couper",
431
441
  copy: "Copier",
432
442
  paste: "Coller",
@@ -1069,11 +1079,13 @@ const coerceValue = (col: QSpreadsheetColumn | undefined, text: unknown, old: an
1069
1079
  const raw = String(text ?? "")
1070
1080
  const t = raw.trim()
1071
1081
  if (t === "") return null
1072
- // Formule A1 : conservée brute dans la cellule (booléen/choix exclus)
1082
+ // Formule A1 : conservée brute dans la cellule (booléen/choix exclus) —
1083
+ // `t` (déjà trimé) évite qu'un `" =…"` ne soit traité comme une formule par
1084
+ // `isFormulaRaw` tout en échouant au parsing du moteur.
1073
1085
  if (t.startsWith("=")) {
1074
1086
  if (col?.type === "boolean" || col?.type === "select" || col?.type === "multiselect")
1075
1087
  return old
1076
- return raw
1088
+ return t
1077
1089
  }
1078
1090
  if (col?.type === "number") {
1079
1091
  const n = Number(raw)
@@ -1924,6 +1936,41 @@ const fxCanEdit = computed(() => {
1924
1936
  )
1925
1937
  })
1926
1938
 
1939
+ /**
1940
+ * Aperçu **live** du résultat de la formule en cours de saisie dans la barre fx.
1941
+ * Évaluée dans le contexte de la ligne sélectionnée (références par nom de colonne
1942
+ * et A1 résolues sur la grille mémorisée). `null` si la saisie n'est pas une formule.
1943
+ */
1944
+ const fxPreview = computed<{ text: string; error: boolean; hint: string } | null>(() => {
1945
+ // Pendant l'édition en cellule, la grille affiche déjà le résultat.
1946
+ if (editing.value || !sel.value || !fxCanEdit.value) return null
1947
+ const source = fxDraft.value
1948
+ if (!isFormulaRaw(source)) return null
1949
+ const row = sel.value.row
1950
+ const value = evaluateFormula(source, {
1951
+ row,
1952
+ resolveField: (name) => {
1953
+ const i = colIndex(name)
1954
+ return i === -1 ? undefined : evaluated.value[row]?.[i]
1955
+ },
1956
+ resolveCell: (r, c) => evaluated.value[r]?.[c],
1957
+ })
1958
+ if (isError(value)) {
1959
+ return {
1960
+ text: value.code,
1961
+ error: true,
1962
+ hint: value.code === "#ERROR!" ? t("fxSyntax") : value.code,
1963
+ }
1964
+ }
1965
+ if (value === null || value === undefined || value === "") {
1966
+ return { text: t("fxEmpty"), error: false, hint: "" }
1967
+ }
1968
+ const text = typeof value === "number"
1969
+ ? new Intl.NumberFormat(lang.value, { maximumFractionDigits: 6 }).format(value)
1970
+ : String(value)
1971
+ return { text: text.length > 60 ? text.slice(0, 59) + "…" : text, error: false, hint: "" }
1972
+ })
1973
+
1927
1974
  const commitFx = async () => {
1928
1975
  if (!sel.value) return
1929
1976
  if (!fxCanEdit.value) {
@@ -1939,7 +1986,21 @@ const commitFx = async () => {
1939
1986
  }
1940
1987
 
1941
1988
  const onFxInput = (e: Event) => {
1942
- fxDraft.value = (e.target as HTMLInputElement).value
1989
+ const el = e.target as HTMLInputElement
1990
+ fxDraft.value = el.value
1991
+ fxCaret.value = el.selectionStart
1992
+ }
1993
+
1994
+ /** Position du curseur depuis un événement (`null` si indisponible). */
1995
+ const caretFrom = (e: Event): number | null => {
1996
+ const el = e.target as HTMLInputElement | HTMLTextAreaElement | null
1997
+ return el && typeof el.selectionStart === "number" ? el.selectionStart : null
1998
+ }
1999
+ const syncFxCaret = (e: Event) => {
2000
+ fxCaret.value = caretFrom(e)
2001
+ }
2002
+ const syncEditCaret = (e: Event) => {
2003
+ editCaret.value = caretFrom(e)
1943
2004
  }
1944
2005
 
1945
2006
  const onFxKeydown = (e: KeyboardEvent) => {
@@ -2084,16 +2145,38 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
2084
2145
  // La source alimente des méthodes de chaîne (`.trim()`, `.startsWith()`) :
2085
2146
  // la valeur brute d'une cellule peut être un nombre / booléen → on coerce.
2086
2147
  const fxSource = computed(() => String((editing.value ? draft.value : fxDraft.value) ?? ""))
2087
- const fxQuery = computed(() => {
2088
- const s = fxSource.value
2089
- if (!s.trim().startsWith("=")) return ""
2090
- const m = s.match(/[A-Za-z]{1,}$/)
2091
- return m ? m[0].toLowerCase() : ""
2092
- })
2148
+ /** Position du curseur (fx bar / éditeur de cellule) — pilote les suggestions. */
2149
+ const fxCaret = ref<number | null>(null)
2150
+ const editCaret = ref<number | null>(null)
2151
+ const fxQuery = computed(() =>
2152
+ functionQueryAt(
2153
+ fxSource.value,
2154
+ (editing.value ? editCaret.value : fxCaret.value) ?? undefined,
2155
+ ),
2156
+ )
2093
2157
  const fxSuggestions = computed(() => {
2094
2158
  const q = fxQuery.value
2095
2159
  if (!q) return []
2096
- return FORMULA_FNS.filter((f) => f.name.toLowerCase().startsWith(q)).slice(0, 12)
2160
+ const fns = rankSuggestions(FORMULA_FNS, q, 8).map((f) => ({
2161
+ kind: "fn" as const,
2162
+ name: f.name,
2163
+ detail: f.sig,
2164
+ insert: f.name + "(",
2165
+ }))
2166
+ // Colonnes de la feuille : les formules acceptent `=price * qty`
2167
+ const fields = rankSuggestions(
2168
+ cols.value.map((col) => ({
2169
+ name: col.name,
2170
+ detail:
2171
+ col.label !== undefined && String(col.label) !== col.name
2172
+ ? String(col.label)
2173
+ : t("fxColumn"),
2174
+ insert: col.name,
2175
+ })),
2176
+ q,
2177
+ 4,
2178
+ ).map((f) => ({ kind: "field" as const, ...f }))
2179
+ return [...fns, ...fields]
2097
2180
  })
2098
2181
  const fxHide = ref(false)
2099
2182
  const fxActive = ref(0)
@@ -2102,6 +2185,23 @@ watch(fxQuery, () => {
2102
2185
  fxActive.value = 0
2103
2186
  fxHide.value = false
2104
2187
  })
2188
+ /** Signature de la fonction au curseur (argument courant surligné). */
2189
+ const fxHint = computed<{ name: string; args: string[]; active: number } | null>(() => {
2190
+ const source = fxSource.value
2191
+ if (!isFormulaRaw(source)) return null
2192
+ const caret = Math.min(
2193
+ (editing.value ? editCaret.value : fxCaret.value) ?? source.length,
2194
+ source.length,
2195
+ )
2196
+ const call = functionCallAt(source, caret, FORMULA_FNS.map((f) => f.name))
2197
+ if (!call) return null
2198
+ const fn = FORMULA_FNS.find((f) => f.name.toLowerCase() === call.name)
2199
+ if (!fn) return null
2200
+ const args = signatureArgs(fn.sig)
2201
+ if (!args.length) return null
2202
+ return { name: fn.name, args, active: Math.min(call.index, args.length - 1) }
2203
+ })
2204
+
2105
2205
  const fxPopStyle = ref<Record<string, string>>({})
2106
2206
  const refreshFxPop = () => {
2107
2207
  if (!fxOpen.value || !fxInputEl.value) return
@@ -2123,9 +2223,18 @@ const stepFx = (d: number) => {
2123
2223
  const acceptFx = () => {
2124
2224
  const f = fxSuggestions.value[fxActive.value]
2125
2225
  if (!f) return
2126
- const replaceTail = (s: string) => s.replace(/[A-Za-z]+$/, f.name + "(")
2127
- if (editing.value) draft.value = replaceTail(String(draft.value ?? ""))
2128
- else fxDraft.value = replaceTail(String(fxDraft.value ?? ""))
2226
+ const source = editing.value ? String(draft.value ?? "") : String(fxDraft.value ?? "")
2227
+ const caret = Math.min(
2228
+ (editing.value ? editCaret.value : fxCaret.value) ?? source.length,
2229
+ source.length,
2230
+ )
2231
+ const query = functionQueryAt(source, caret)
2232
+ if (!query) return
2233
+ const start = caret - query.length
2234
+ const inserted = f.insert
2235
+ const next = source.slice(0, start) + inserted + source.slice(caret)
2236
+ if (editing.value) draft.value = next
2237
+ else fxDraft.value = next
2129
2238
  fxActive.value = 0
2130
2239
  nextTick(() => {
2131
2240
  const el = editing.value
@@ -2133,7 +2242,12 @@ const acceptFx = () => {
2133
2242
  ? editAreaEl.value
2134
2243
  : editInputEl.value
2135
2244
  : fxInputEl.value
2136
- el?.focus({ preventScroll: true })
2245
+ if (!el) return
2246
+ el.focus({ preventScroll: true })
2247
+ const pos = start + inserted.length
2248
+ el.setSelectionRange(pos, pos)
2249
+ if (editing.value) editCaret.value = pos
2250
+ else fxCaret.value = pos
2137
2251
  })
2138
2252
  }
2139
2253
  const acceptFxAt = (i: number) => {
@@ -2960,8 +3074,8 @@ const applyFill = () => {
2960
3074
  const sr = b.r0 + (((rr - b.r0) % h) + h) % h
2961
3075
  const sc = b.c0 + (((cc - b.c0) % w) + w) % w
2962
3076
  let raw = state.value[sr]?.[colNameAt(sc)]
2963
- if (typeof raw === "string" && raw.startsWith("=")) {
2964
- raw = shiftFormulaRefs(raw, rr - sr, cc - sc)
3077
+ if (typeof raw === "string" && raw.trim().startsWith("=")) {
3078
+ raw = shiftFormulaRefs(raw.trim(), rr - sr, cc - sc)
2965
3079
  }
2966
3080
  val = raw
2967
3081
  }
@@ -4572,9 +4686,23 @@ defineExpose({
4572
4686
  :placeholder="fxCanEdit ? t('fxPlaceholder') : t('fxNoSel')"
4573
4687
  spellcheck="false"
4574
4688
  @input="onFxInput"
4689
+ @keyup="syncFxCaret"
4690
+ @click="syncFxCaret"
4575
4691
  @keydown="onFxKeydown"
4576
4692
  @blur="commitFx"
4577
4693
  />
4694
+ <span v-if="fxHint && !fxOpen && !editing" class="q-spreadsheet__fxhint">
4695
+ <strong>{{ fxHint.name }}</strong>(<template v-for="(arg, i) in fxHint.args" :key="i"><span
4696
+ class="q-spreadsheet__fxhint-arg"
4697
+ :class="{ 'q-spreadsheet__fxhint-arg--on': i === fxHint.active }"
4698
+ >{{ arg }}</span><span v-if="i < fxHint.args.length - 1">, </span></template>)
4699
+ </span>
4700
+ <span
4701
+ v-if="fxPreview"
4702
+ class="q-spreadsheet__fx-preview"
4703
+ :class="{ 'q-spreadsheet__fx-preview--error': fxPreview.error }"
4704
+ :title="fxPreview.hint"
4705
+ ><span class="q-spreadsheet__fx-preview-eq" aria-hidden="true">=</span>{{ fxPreview.text }}</span>
4578
4706
  </div>
4579
4707
 
4580
4708
  <!-- ═══════ Barre Find & Replace ═══════ -->
@@ -4954,6 +5082,9 @@ defineExpose({
4954
5082
  class="q-spreadsheet__editor-input q-spreadsheet__editor-area"
4955
5083
  :rows="draftLines"
4956
5084
  spellcheck="false"
5085
+ @input="syncEditCaret"
5086
+ @keyup="syncEditCaret"
5087
+ @click="syncEditCaret"
4957
5088
  @keydown="onEditKeydown"
4958
5089
  @blur="commitEdit"
4959
5090
  />
@@ -4968,6 +5099,12 @@ defineExpose({
4968
5099
  @keydown="onEditKeydown"
4969
5100
  @blur="commitEdit"
4970
5101
  />
5102
+ <div v-if="editingIsFormula && fxHint && !fxOpen" class="q-spreadsheet__fxhint q-spreadsheet__fxhint--editor">
5103
+ <strong>{{ fxHint.name }}</strong>(<template v-for="(arg, i) in fxHint.args" :key="i"><span
5104
+ class="q-spreadsheet__fxhint-arg"
5105
+ :class="{ 'q-spreadsheet__fxhint-arg--on': i === fxHint.active }"
5106
+ >{{ arg }}</span><span v-if="i < fxHint.args.length - 1">, </span></template>)
5107
+ </div>
4971
5108
  <div v-if="editingIsChoice && selectOptions.length" class="q-spreadsheet__options">
4972
5109
  <button
4973
5110
  v-for="opt in selectOptions"
@@ -4992,7 +5129,7 @@ defineExpose({
4992
5129
  <div v-if="editingIsFormula && fxOpen" class="q-spreadsheet__fnsug">
4993
5130
  <button
4994
5131
  v-for="(f, fi) in fxSuggestions"
4995
- :key="f.name"
5132
+ :key="`${f.kind}:${f.name}`"
4996
5133
  type="button"
4997
5134
  class="q-spreadsheet__fnsug-item"
4998
5135
  :class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
@@ -5000,7 +5137,8 @@ defineExpose({
5000
5137
  @click="acceptFxAt(fi)"
5001
5138
  >
5002
5139
  <code>{{ f.name }}</code>
5003
- <span>({{ f.sig }})</span>
5140
+ <span v-if="f.kind === 'fn'">({{ f.detail }})</span>
5141
+ <span v-else class="q-spreadsheet__fnsug-kind">{{ f.detail }}</span>
5004
5142
  </button>
5005
5143
  </div>
5006
5144
  </div>
@@ -5297,7 +5435,7 @@ defineExpose({
5297
5435
  >
5298
5436
  <button
5299
5437
  v-for="(f, fi) in fxSuggestions"
5300
- :key="f.name"
5438
+ :key="`${f.kind}:${f.name}`"
5301
5439
  type="button"
5302
5440
  class="q-spreadsheet__fnsug-item"
5303
5441
  :class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
@@ -5305,7 +5443,8 @@ defineExpose({
5305
5443
  @click="acceptFxAt(fi)"
5306
5444
  >
5307
5445
  <code>{{ f.name }}</code>
5308
- <span>({{ f.sig }})</span>
5446
+ <span v-if="f.kind === 'fn'">({{ f.detail }})</span>
5447
+ <span v-else class="q-spreadsheet__fnsug-kind">{{ f.detail }}</span>
5309
5448
  </button>
5310
5449
  </div>
5311
5450
  </Teleport>
@@ -184,6 +184,14 @@ describe("fonctions dates", () => {
184
184
  })
185
185
  })
186
186
 
187
+ describe("espaces autour de la formule", () => {
188
+ const e = harness([[]])
189
+ it("accepte des espaces avant le `=` (isFormulaRaw les tolère)", () => {
190
+ expect(e(" =1+2")).toBe(3)
191
+ expect(e(" =SUM(1,2) ")).toBe(3)
192
+ })
193
+ })
194
+
187
195
  describe("math & erreurs de type", () => {
188
196
  const e = harness([[]])
189
197
  it("ABS / ROUND / INT / MOD / SQRT / POWER", () => {
package/lib/formula.ts CHANGED
@@ -1349,9 +1349,11 @@ export const FORMULA_FUNCTION_NAMES: string[] = [
1349
1349
  ]),
1350
1350
  ].sort()
1351
1351
 
1352
- /** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
1352
+ /** Évalue une formule ("=…" optionnel, espaces ignorés). Retourne une valeur ou une FormulaError. */
1353
1353
  export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
1354
- const src = source.startsWith("=") ? source.slice(1) : source
1354
+ // `isFormulaRaw` accepte des espaces avant le `=` : le moteur doit en faire autant.
1355
+ const trimmed = source.trim()
1356
+ const src = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed
1355
1357
  try {
1356
1358
  const parser = new Parser(src)
1357
1359
  const node = parser.parseExpr()
@@ -6,6 +6,10 @@ import {
6
6
  isBlankValue,
7
7
  isoAddDays,
8
8
  shiftFormulaRefs,
9
+ functionQueryAt,
10
+ rankSuggestions,
11
+ functionCallAt,
12
+ signatureArgs,
9
13
  csvEscape,
10
14
  csvSplitLine,
11
15
  parseCsv,
@@ -44,6 +48,112 @@ describe("shiftFormulaRefs (autofill)", () => {
44
48
  it("laisse les valeurs sans lettres intactes", () => {
45
49
  expect(shiftFormulaRefs("=1+2", 1, 1)).toBe("=1+2")
46
50
  })
51
+ it("ne touche pas aux littéraux de chaîne", () => {
52
+ expect(shiftFormulaRefs('=IF(A1>0,"A1","x")', 1, 0)).toBe('=IF(A2>0,"A1","x")')
53
+ expect(shiftFormulaRefs("=CONCAT('B2',A1)", 0, 1)).toBe("=CONCAT('B2',B1)")
54
+ })
55
+ it("ne confond pas un identifiant avec une référence", () => {
56
+ expect(shiftFormulaRefs("=LOG10(A1)", 1, 0)).toBe("=LOG10(A2)")
57
+ expect(shiftFormulaRefs("=price1+A1", 1, 0)).toBe("=price1+A2")
58
+ expect(shiftFormulaRefs("=A1:price2", 1, 0)).toBe("=A2:price2")
59
+ })
60
+ })
61
+
62
+ describe("functionQueryAt (autocomplétion fx)", () => {
63
+ it("rend l'identifiant en cours de frappe, en minuscules", () => {
64
+ expect(functionQueryAt("=SU")).toBe("su")
65
+ expect(functionQueryAt("=sum(")).toBe("")
66
+ expect(functionQueryAt("=SUM(A1)+IF")).toBe("if")
67
+ })
68
+
69
+ it("ne suggère rien hors d'une formule ou dans une chaîne", () => {
70
+ expect(functionQueryAt("SUM")).toBe("")
71
+ expect(functionQueryAt('="hello wor')).toBe("")
72
+ expect(functionQueryAt("='hello wor")).toBe("")
73
+ })
74
+
75
+ it("ne suggère pas au milieu d'une référence ou d'une plage", () => {
76
+ expect(functionQueryAt("=A1", 2)).toBe("")
77
+ expect(functionQueryAt("=A1:B")).toBe("")
78
+ expect(functionQueryAt("=$AB")).toBe("")
79
+ expect(functionQueryAt("=A1+B")).toBe("b")
80
+ expect(functionQueryAt("=SUM(A")).toBe("a")
81
+ })
82
+
83
+ it("suit le curseur, pas seulement la fin du texte", () => {
84
+ expect(functionQueryAt("=SU+1", 3)).toBe("su")
85
+ expect(functionQueryAt("=SU+1", 5)).toBe("")
86
+ })
87
+ })
88
+
89
+ describe("rankSuggestions (autocomplétion)", () => {
90
+ const items = [
91
+ { name: "SUM" },
92
+ { name: "SUMIF" },
93
+ { name: "SUMPRODUCT" },
94
+ { name: "MID" },
95
+ { name: "MEDIAN" },
96
+ { name: "IF" },
97
+ { name: "IFS" },
98
+ ]
99
+
100
+ it("classe égalité > préfixe, puis nom le plus court", () => {
101
+ expect(rankSuggestions(items, "sum").map((i) => i.name)).toEqual([
102
+ "SUM",
103
+ "SUMIF",
104
+ "SUMPRODUCT",
105
+ ])
106
+ expect(rankSuggestions(items, "if").map((i) => i.name)).toEqual(["IF", "IFS", "SUMIF"])
107
+ })
108
+
109
+ it("trouve une sous-chaîne", () => {
110
+ expect(rankSuggestions(items, "edian").map((i) => i.name)).toEqual(["MEDIAN"])
111
+ expect(rankSuggestions(items, "ia").map((i) => i.name)).toEqual(["MEDIAN"])
112
+ })
113
+
114
+ it("trouve une sous-séquence (fuzzy)", () => {
115
+ expect(rankSuggestions(items, "sm").map((i) => i.name)).toEqual([
116
+ "SUM",
117
+ "SUMIF",
118
+ "SUMPRODUCT",
119
+ ])
120
+ })
121
+
122
+ it("respecte la limite et ignore une requête vide", () => {
123
+ expect(rankSuggestions(items, "s", 1)).toHaveLength(1)
124
+ expect(rankSuggestions(items, "")).toEqual([])
125
+ expect(rankSuggestions(items, "zzz")).toEqual([])
126
+ })
127
+ })
128
+
129
+ describe("functionCallAt / signatureArgs (aide à l'argument)", () => {
130
+ const known = ["SUM", "IF", "MAX"]
131
+
132
+ it("détecte la fonction et l'index de l'argument courant", () => {
133
+ expect(functionCallAt("=SUM(", 5, known)).toEqual({ name: "sum", index: 0 })
134
+ expect(functionCallAt("=SUM(A1,", 8, known)).toEqual({ name: "sum", index: 1 })
135
+ expect(functionCallAt("=IF(A1>0,", 9, known)).toEqual({ name: "if", index: 1 })
136
+ })
137
+
138
+ it("prend la fonction la plus interne", () => {
139
+ expect(functionCallAt("=SUM(IF(A1>0,", 13, known)).toEqual({ name: "if", index: 1 })
140
+ })
141
+
142
+ it("ignore les virgules des chaînes et les appels fermés", () => {
143
+ expect(functionCallAt('=IF(A1,"x,y",', 7, known)).toEqual({ name: "if", index: 1 })
144
+ expect(functionCallAt('=IF(A1,"x,y",', 13, known)).toEqual({ name: "if", index: 2 })
145
+ expect(functionCallAt("=A1+B1", 6, known)).toBeNull()
146
+ expect(functionCallAt("=SUM(A1)", 9, known)).toBeNull()
147
+ })
148
+
149
+ it("signatureArgs découpe les paramètres", () => {
150
+ expect(signatureArgs("range, criteria, [sum_range]")).toEqual([
151
+ "range",
152
+ "criteria",
153
+ "[sum_range]",
154
+ ])
155
+ expect(signatureArgs("")).toEqual([])
156
+ })
47
157
  })
48
158
 
49
159
  describe("dates ISO", () => {
@@ -26,14 +26,213 @@ export const isBlankValue = (v: any): boolean =>
26
26
  /**
27
27
  * Décale les références A1 non absolues d'une formule de (dr, dc) —
28
28
  * plages incluses, `$` préservés (copie / autofill).
29
+ *
30
+ * Les **littéraux de chaîne** sont laissés intacts (`"A1"` reste `"A1"`) et les
31
+ * **identifiants** contenant des chiffres ne sont pas pris pour des références
32
+ * (`LOG10(...)`, `price1`…).
29
33
  */
30
34
  export const shiftFormulaRefs = (src: string, dr: number, dc: number): string =>
31
- src.replace(
35
+ splitFormulaSegments(src)
36
+ .map((segment) => (segment.code ? shiftCodeRefs(segment.text, dr, dc) : segment.text))
37
+ .join("")
38
+
39
+ /**
40
+ * Requête de suggestion de **fonction** à la position du curseur.
41
+ *
42
+ * Ne suggère que si le curseur est en train de taper un identifiant de fonction :
43
+ * hors littéral de chaîne, commençant un token (`=`, `(`, `,`, opérateur, espace…)
44
+ * et **pas** au milieu d'une référence de cellule (`A` devant `1`). Retourne `""`
45
+ * quand il n'y a rien à suggérer (ex. `=SUM(`).
46
+ */
47
+ export function functionQueryAt(text: string, caret: number = text.length): string {
48
+ if (!text.trim().startsWith("=")) return ""
49
+ const end = Math.max(0, Math.min(caret, text.length))
50
+
51
+ // Curseur dans une chaîne littérale → aucune suggestion
52
+ let quote = ""
53
+ for (let i = 0; i < end; i++) {
54
+ const ch = text[i]!
55
+ if (quote) {
56
+ if (ch === quote) {
57
+ if (text[i + 1] === quote) {
58
+ i++
59
+ continue
60
+ }
61
+ quote = ""
62
+ }
63
+ continue
64
+ }
65
+ if (ch === '"' || ch === "'") quote = ch
66
+ }
67
+ if (quote) return ""
68
+
69
+ let start = end
70
+ while (start > 0 && /[A-Za-z]/.test(text[start - 1]!)) start--
71
+ if (start === end) return ""
72
+ // « A » devant « 1 » : référence de cellule en cours de frappe, pas une fonction
73
+ if (/[0-9]/.test(text[end] ?? "")) return ""
74
+ // Doit commencer un token (évite `A1:B`, `$AB`, `sheet!SUM`…)
75
+ const before = start > 0 ? text[start - 1]! : ""
76
+ if (before && !/[=(,;\s+\-*/^%&<>]/.test(before)) return ""
77
+ return text.slice(start, end).toLowerCase()
78
+ }
79
+
80
+ /** Pose d'une sous-séquence : les lettres de `query` apparaissent dans l'ordre. */
81
+ const isSubsequence = (query: string, name: string): boolean => {
82
+ let i = 0
83
+ for (const ch of name) {
84
+ if (ch === query[i]) i++
85
+ if (i === query.length) return true
86
+ }
87
+ return query.length === 0
88
+ }
89
+
90
+ /**
91
+ * Classe des suggestions par pertinence :
92
+ * égalité > préfixe > sous-chaîne > sous-séquence, puis nom le plus court,
93
+ * puis ordre d'origine. `query` vide → aucun résultat.
94
+ */
95
+ export function rankSuggestions<T extends { name: string }>(
96
+ items: T[],
97
+ query: string,
98
+ limit = 8,
99
+ ): T[] {
100
+ const q = query.trim().toLowerCase()
101
+ if (!q) return []
102
+ return items
103
+ .map((item, index) => {
104
+ const name = item.name.toLowerCase()
105
+ const score = name === q
106
+ ? 0
107
+ : name.startsWith(q)
108
+ ? 1
109
+ : name.includes(q)
110
+ ? 2
111
+ : isSubsequence(q, name)
112
+ ? 3
113
+ : -1
114
+ return { item, index, score, length: name.length }
115
+ })
116
+ .filter((entry) => entry.score >= 0)
117
+ .sort((a, b) => a.score - b.score || a.length - b.length || a.index - b.index)
118
+ .slice(0, limit)
119
+ .map((entry) => entry.item)
120
+ }
121
+
122
+ /** Arguments d'une signature (`"range, criteria, [sum_range]"` → 3 entrées). */
123
+ export function signatureArgs(sig: string): string[] {
124
+ return sig
125
+ .split(",")
126
+ .map((part) => part.trim())
127
+ .filter(Boolean)
128
+ }
129
+
130
+ export interface FunctionCallAtResult {
131
+ /** Nom de la fonction (minuscules) */
132
+ name: string
133
+ /** Index de l'argument courant (0-based) */
134
+ index: number
135
+ }
136
+
137
+ /**
138
+ * Fonction (parmi `known`) dans les parenthèses de laquelle se trouve le curseur.
139
+ * Gère l'imbrication, les littéraux et les virgules de chaque niveau.
140
+ */
141
+ export function functionCallAt(
142
+ text: string,
143
+ caret: number,
144
+ known: Iterable<string>,
145
+ ): FunctionCallAtResult | null {
146
+ const names = new Set([...known].map((name) => name.toLowerCase()))
147
+ const end = Math.max(0, Math.min(caret, text.length))
148
+ const stack: { name: string; index: number }[] = []
149
+ let quote = ""
150
+ for (let i = 0; i < end; i++) {
151
+ const ch = text[i]!
152
+ if (quote) {
153
+ if (ch === quote) {
154
+ if (text[i + 1] === quote) {
155
+ i++
156
+ continue
157
+ }
158
+ quote = ""
159
+ }
160
+ continue
161
+ }
162
+ if (ch === '"' || ch === "'") {
163
+ quote = ch
164
+ continue
165
+ }
166
+ if (ch === "(") {
167
+ let j = i - 1
168
+ while (j >= 0 && /[A-Za-z0-9_]/.test(text[j]!)) j--
169
+ stack.push({ name: text.slice(j + 1, i).toLowerCase(), index: 0 })
170
+ continue
171
+ }
172
+ if (ch === ")") {
173
+ stack.pop()
174
+ continue
175
+ }
176
+ if (ch === "," && stack.length) stack[stack.length - 1]!.index++
177
+ }
178
+ for (let k = stack.length - 1; k >= 0; k--) {
179
+ const frame = stack[k]!
180
+ if (names.has(frame.name)) return { name: frame.name, index: frame.index }
181
+ }
182
+ return null
183
+ }
184
+
185
+ /** Segments d'une formule : `code: false` = littéral de chaîne (guillemets simples/doubles). */
186
+ const splitFormulaSegments = (src: string): { code: boolean; text: string }[] => {
187
+ const out: { code: boolean; text: string }[] = []
188
+ let code = ""
189
+ let i = 0
190
+ while (i < src.length) {
191
+ const ch = src[i]!
192
+ if (ch !== '"' && ch !== "'") {
193
+ code += ch
194
+ i++
195
+ continue
196
+ }
197
+ if (code) {
198
+ out.push({ code: true, text: code })
199
+ code = ""
200
+ }
201
+ let literal = ch
202
+ i++
203
+ while (i < src.length) {
204
+ const c = src[i]!
205
+ literal += c
206
+ i++
207
+ if (c !== ch) continue
208
+ // Guillemet doublé = caractère littéral (cf. parseString du moteur)
209
+ if (src[i] === ch) {
210
+ literal += src[i]
211
+ i++
212
+ continue
213
+ }
214
+ break
215
+ }
216
+ out.push({ code: false, text: literal })
217
+ }
218
+ if (code) out.push({ code: true, text: code })
219
+ return out
220
+ }
221
+
222
+ /** Décalage des références dans un segment de code (jamais dans une chaîne). */
223
+ const shiftCodeRefs = (code: string, dr: number, dc: number): string =>
224
+ code.replace(
32
225
  /(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g,
33
- (_m: string, ca: string, L: string, ra: string, D: string) => {
34
- const col = colFromLetters(L) + (ca === "$" ? 0 : dc)
35
- const row = Number(D) - 1 + (ra === "$" ? 0 : dr)
36
- if (col < 0 || row < 0) return _m
226
+ (match: string, ca: string, letters: string, ra: string, digits: string, offset: number) => {
227
+ // Borne gauche : la ref doit commencer un token (pas « LOG10 », « price1 »…)
228
+ const before = offset > 0 ? code[offset - 1]! : ""
229
+ if (before && /[A-Za-z0-9_.]/.test(before)) return match
230
+ // Borne droite : pas de suite alphanumérique, pas un appel de fonction
231
+ const after = code[offset + match.length] ?? ""
232
+ if (after && /[A-Za-z0-9_(]/.test(after)) return match
233
+ const col = colFromLetters(letters) + (ca === "$" ? 0 : dc)
234
+ const row = Number(digits) - 1 + (ra === "$" ? 0 : dr)
235
+ if (col < 0 || row < 0) return match
37
236
  return ca + colLetter(col) + ra + (row + 1)
38
237
  },
39
238
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.66",
3
+ "version": "0.0.67",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
package/styles/main.css CHANGED
@@ -9934,6 +9934,67 @@ a.q-breadcrumbs__el:hover {
9934
9934
  opacity: 0.55;
9935
9935
  }
9936
9936
 
9937
+ /* Aperçu live du résultat (barre fx) — discret, monospace, rouge si erreur */
9938
+ .q-spreadsheet__fx-preview {
9939
+ flex: 0 1 auto;
9940
+ min-width: 0;
9941
+ max-width: 40%;
9942
+ overflow: hidden;
9943
+ text-overflow: ellipsis;
9944
+ white-space: nowrap;
9945
+ padding-left: 8px;
9946
+ border-left: 1px solid var(--q-spreadsheet-border);
9947
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
9948
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
9949
+ font-size: 12px;
9950
+ }
9951
+ .q-spreadsheet__fx-preview-eq {
9952
+ margin-right: 4px;
9953
+ opacity: 0.6;
9954
+ }
9955
+ .q-spreadsheet__fx-preview--error {
9956
+ color: var(--negative, #c10015);
9957
+ font-weight: 600;
9958
+ }
9959
+ .dark .q-spreadsheet__fx-preview,
9960
+ .q-spreadsheet--dark .q-spreadsheet__fx-preview {
9961
+ color: rgb(255 255 255 / 0.6);
9962
+ }
9963
+
9964
+ /* Aide à l'argument : signature de la fonction au curseur */
9965
+ .q-spreadsheet__fxhint {
9966
+ flex: 0 1 auto;
9967
+ min-width: 0;
9968
+ overflow: hidden;
9969
+ text-overflow: ellipsis;
9970
+ white-space: nowrap;
9971
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
9972
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
9973
+ font-size: 12px;
9974
+ }
9975
+ .q-spreadsheet__fxhint strong {
9976
+ color: var(--foreground, #1d1d1d);
9977
+ }
9978
+ .q-spreadsheet__fxhint-arg--on {
9979
+ color: var(--primary, #1976d2);
9980
+ font-weight: 700;
9981
+ }
9982
+ /* Dans l'éditeur de cellule : sous le textarea, séparateur discret */
9983
+ .q-spreadsheet__fxhint--editor {
9984
+ flex: 0 0 auto;
9985
+ padding: 3px 8px 5px;
9986
+ border-top: 1px dashed var(--q-spreadsheet-border);
9987
+ white-space: normal;
9988
+ }
9989
+ .dark .q-spreadsheet__fxhint,
9990
+ .q-spreadsheet--dark .q-spreadsheet__fxhint {
9991
+ color: rgb(255 255 255 / 0.6);
9992
+ }
9993
+ .dark .q-spreadsheet__fxhint strong,
9994
+ .q-spreadsheet--dark .q-spreadsheet__fxhint strong {
9995
+ color: #fff;
9996
+ }
9997
+
9937
9998
  /* ─── Éditeur formule (textarea monospace) ─── */
9938
9999
  .q-spreadsheet__editor--formula {
9939
10000
  align-items: stretch;
@@ -10324,6 +10385,10 @@ a.q-breadcrumbs__el:hover {
10324
10385
  color: #8b93a1;
10325
10386
  font-size: 11.5px;
10326
10387
  }
10388
+ .q-spreadsheet__fnsug-kind {
10389
+ font-style: italic;
10390
+ opacity: 0.85;
10391
+ }
10327
10392
  .q-spreadsheet__fnsug-item:hover,
10328
10393
  .q-spreadsheet__fnsug-item--on {
10329
10394
  background: rgb(25 118 210 / 0.1);