@dnax/ui 0.0.65 → 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.
@@ -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
  )
@@ -112,6 +112,7 @@ describe("classes de style (composant ↔ CSS)", () => {
112
112
  "q-tabs--scroll-end",
113
113
  ".q-tabs--arrows.q-tabs--scroll-end .q-tabs__content",
114
114
  ],
115
+ ["components/QTable.vue", "q-table__rows-field", ".q-table__rows-field"],
115
116
  ]
116
117
 
117
118
  for (const [file, className, selector] of CONTRACTS) {
@@ -160,6 +161,20 @@ describe("fondu de défilement des tabs (thème-agnostique)", () => {
160
161
  })
161
162
  })
162
163
 
164
+ describe("pagination de QTable", () => {
165
+ it("le sélecteur « lignes par page » est un QSelect, pas un `<select>` natif", async () => {
166
+ const vue = await Bun.file(new URL("../components/QTable.vue", import.meta.url)).text()
167
+ expect({ usesQSelect: vue.includes("<q-select") }).toEqual({ usesQSelect: true })
168
+ expect({ nativeSelect: /<select\b/.test(vue) }).toEqual({ nativeSelect: false })
169
+ })
170
+
171
+ it("la liste vient de la prop publique `rows-per-page-options` (aucun tableau dérivé)", async () => {
172
+ const vue = await Bun.file(new URL("../components/QTable.vue", import.meta.url)).text()
173
+ expect({ fromProp: vue.includes(':options="rowsPerPageOptions"') }).toEqual({ fromProp: true })
174
+ expect({ derived: vue.includes("rowsPerPageChoices") }).toEqual({ derived: false })
175
+ })
176
+ })
177
+
163
178
  describe("mode sombre des contrôles", () => {
164
179
  it("la bordure noire du checkbox / radio décoché est corrigée en sombre", () => {
165
180
  // Base claire : bordure codée en dur (le variant `--dark` du composant la corrige, mais
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.65",
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
@@ -3615,13 +3615,27 @@ html.dark body {
3615
3615
  color: rgb(255 255 255 / 0.6);
3616
3616
  }
3617
3617
 
3618
- .q-table__rows-select select {
3619
- padding: 2px 6px;
3620
- border: 1px solid var(--q-table-border);
3621
- border-radius: 4px;
3622
- background: transparent;
3623
- color: inherit;
3624
- font: inherit;
3618
+ /* Sélecteur « lignes par page » : QSelect compact — le footer n'a pas de place
3619
+ pour la ligne d'aide (hint/error) ni pour la hauteur de champ standard. */
3620
+ .q-table__rows-field {
3621
+ width: 72px;
3622
+ flex: 0 0 auto;
3623
+ }
3624
+
3625
+ .q-table__rows-field .q-field__control {
3626
+ min-height: 28px;
3627
+ padding: 0 8px;
3628
+ font-size: 13px;
3629
+ }
3630
+
3631
+ .q-table__rows-field .q-field__bottom {
3632
+ display: none;
3633
+ }
3634
+
3635
+ .q-table--dark .q-table__rows-field .q-field__control {
3636
+ background-color: #1d1d1d;
3637
+ border-color: var(--q-table-border);
3638
+ color: #fff;
3625
3639
  }
3626
3640
 
3627
3641
  .q-table__page-nav {
@@ -9920,6 +9934,67 @@ a.q-breadcrumbs__el:hover {
9920
9934
  opacity: 0.55;
9921
9935
  }
9922
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
+
9923
9998
  /* ─── Éditeur formule (textarea monospace) ─── */
9924
9999
  .q-spreadsheet__editor--formula {
9925
10000
  align-items: stretch;
@@ -10310,6 +10385,10 @@ a.q-breadcrumbs__el:hover {
10310
10385
  color: #8b93a1;
10311
10386
  font-size: 11.5px;
10312
10387
  }
10388
+ .q-spreadsheet__fnsug-kind {
10389
+ font-style: italic;
10390
+ opacity: 0.85;
10391
+ }
10313
10392
  .q-spreadsheet__fnsug-item:hover,
10314
10393
  .q-spreadsheet__fnsug-item--on {
10315
10394
  background: rgb(25 118 210 / 0.1);