@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.
- package/components/QSpreadsheet.vue +210 -26
- package/components/QTable.vue +20 -6
- package/lib/formula.test.ts +194 -1
- package/lib/formula.ts +446 -2
- package/lib/spreadsheet.test.ts +110 -0
- package/lib/spreadsheet.ts +204 -5
- package/lib/styleTokens.test.ts +15 -0
- package/package.json +1 -1
- package/styles/main.css +86 -7
package/lib/spreadsheet.ts
CHANGED
|
@@ -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
|
|
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
|
-
(
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
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/lib/styleTokens.test.ts
CHANGED
|
@@ -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
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
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
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);
|