@dnax/ui 0.0.66 → 0.0.69

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.
@@ -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.69",
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);