@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
|
@@ -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
|
|
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
|
-
|
|
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) => {
|
|
@@ -1984,12 +2045,30 @@ const onFxKeydown = (e: KeyboardEvent) => {
|
|
|
1984
2045
|
|
|
1985
2046
|
// ─── Autocomplétion des fonctions (barre fx + éditeur formule) ───
|
|
1986
2047
|
const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
2048
|
+
// Agrégats & critères
|
|
1987
2049
|
{ name: "SUM", sig: "a, b, …" },
|
|
2050
|
+
{ name: "SUMIF", sig: "range, criteria, [sum_range]" },
|
|
2051
|
+
{ name: "SUMIFS", sig: "sum_range, range1, crit1…" },
|
|
1988
2052
|
{ name: "AVERAGE", sig: "a, b, …" },
|
|
1989
|
-
{ name: "
|
|
1990
|
-
{ name: "
|
|
2053
|
+
{ name: "AVERAGEIF", sig: "range, criteria, [avg_range]" },
|
|
2054
|
+
{ name: "AVERAGEIFS", sig: "avg_range, range1, crit1…" },
|
|
1991
2055
|
{ name: "COUNT", sig: "a, b, …" },
|
|
1992
2056
|
{ name: "COUNTA", sig: "a, b, …" },
|
|
2057
|
+
{ name: "COUNTIF", sig: "range, criteria" },
|
|
2058
|
+
{ name: "COUNTIFS", sig: "range1, crit1…" },
|
|
2059
|
+
{ name: "SUMPRODUCT", sig: "range1, range2…" },
|
|
2060
|
+
{ name: "MIN", sig: "a, b, …" },
|
|
2061
|
+
{ name: "MAX", sig: "a, b, …" },
|
|
2062
|
+
{ name: "MEDIAN", sig: "a, b, …" },
|
|
2063
|
+
// Statistiques
|
|
2064
|
+
{ name: "LARGE", sig: "range, k" },
|
|
2065
|
+
{ name: "SMALL", sig: "range, k" },
|
|
2066
|
+
{ name: "RANK", sig: "value, range, [order]" },
|
|
2067
|
+
{ name: "STDEV", sig: "a, b, …" },
|
|
2068
|
+
{ name: "STDEVP", sig: "a, b, …" },
|
|
2069
|
+
{ name: "VAR", sig: "a, b, …" },
|
|
2070
|
+
{ name: "VARP", sig: "a, b, …" },
|
|
2071
|
+
// Logique
|
|
1993
2072
|
{ name: "IF", sig: "cond, yes, no" },
|
|
1994
2073
|
{ name: "IFS", sig: "cond1, v1, cond2, v2…" },
|
|
1995
2074
|
{ name: "IFERROR", sig: "value, fallback" },
|
|
@@ -1998,7 +2077,22 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
1998
2077
|
{ name: "AND", sig: "a, b…" },
|
|
1999
2078
|
{ name: "OR", sig: "a, b…" },
|
|
2000
2079
|
{ name: "NOT", sig: "v" },
|
|
2001
|
-
{ name: "
|
|
2080
|
+
{ name: "TRUE", sig: "" },
|
|
2081
|
+
{ name: "FALSE", sig: "" },
|
|
2082
|
+
// Info / erreurs
|
|
2083
|
+
{ name: "ISBLANK", sig: "v" },
|
|
2084
|
+
{ name: "ISNUMBER", sig: "v" },
|
|
2085
|
+
{ name: "ISTEXT", sig: "v" },
|
|
2086
|
+
{ name: "ISERROR", sig: "v" },
|
|
2087
|
+
{ name: "ISERR", sig: "v" },
|
|
2088
|
+
{ name: "ISNA", sig: "v" },
|
|
2089
|
+
{ name: "NA", sig: "" },
|
|
2090
|
+
// Recherche
|
|
2091
|
+
{ name: "VLOOKUP", sig: "key, range, col" },
|
|
2092
|
+
{ name: "XLOOKUP", sig: "key, table, return_range, [not_found]" },
|
|
2093
|
+
{ name: "INDEX", sig: "range, row, [col]" },
|
|
2094
|
+
{ name: "MATCH", sig: "key, range, [type]" },
|
|
2095
|
+
// Maths
|
|
2002
2096
|
{ name: "ABS", sig: "n" },
|
|
2003
2097
|
{ name: "ROUND", sig: "n, [digits]" },
|
|
2004
2098
|
{ name: "ROUNDUP", sig: "n, [digits]" },
|
|
@@ -2011,23 +2105,26 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
2011
2105
|
{ name: "POWER", sig: "n, exp" },
|
|
2012
2106
|
{ name: "PI", sig: "" },
|
|
2013
2107
|
{ name: "RAND", sig: "" },
|
|
2108
|
+
// Texte
|
|
2014
2109
|
{ name: "CONCAT", sig: "a, b…" },
|
|
2015
2110
|
{ name: "CONCATENATE", sig: "a, b…" },
|
|
2111
|
+
{ name: "TEXTJOIN", sig: "delim, ignore_empty, a, b…" },
|
|
2016
2112
|
{ name: "UPPER", sig: "text" },
|
|
2017
2113
|
{ name: "LOWER", sig: "text" },
|
|
2114
|
+
{ name: "PROPER", sig: "text" },
|
|
2018
2115
|
{ name: "TRIM", sig: "text" },
|
|
2019
2116
|
{ name: "LEN", sig: "text" },
|
|
2020
2117
|
{ name: "LEFT", sig: "text, [n]" },
|
|
2021
2118
|
{ name: "RIGHT", sig: "text, [n]" },
|
|
2022
2119
|
{ name: "MID", sig: "text, start, n" },
|
|
2120
|
+
{ name: "SEARCH", sig: "find, text, [start]" },
|
|
2023
2121
|
{ name: "FIND", sig: "find, text, [start]" },
|
|
2024
2122
|
{ name: "SUBSTITUTE", sig: "text, old, new, [n]" },
|
|
2025
2123
|
{ name: "REPLACE", sig: "text, start, n, new" },
|
|
2124
|
+
{ name: "EXACT", sig: "a, b" },
|
|
2026
2125
|
{ name: "VALUE", sig: "text" },
|
|
2027
2126
|
{ name: "N", sig: "v" },
|
|
2028
|
-
|
|
2029
|
-
{ name: "ISNUMBER", sig: "v" },
|
|
2030
|
-
{ name: "ISTEXT", sig: "v" },
|
|
2127
|
+
// Dates
|
|
2031
2128
|
{ name: "TODAY", sig: "" },
|
|
2032
2129
|
{ name: "NOW", sig: "" },
|
|
2033
2130
|
{ name: "DATE", sig: "y, m, d" },
|
|
@@ -2035,20 +2132,51 @@ const FORMULA_FNS: { name: string; sig: string }[] = [
|
|
|
2035
2132
|
{ name: "MONTH", sig: "date" },
|
|
2036
2133
|
{ name: "DAY", sig: "date" },
|
|
2037
2134
|
{ name: "EDATE", sig: "date, months" },
|
|
2135
|
+
{ name: "EOMONTH", sig: "date, months" },
|
|
2136
|
+
{ name: "DATEDIF", sig: "start, end, unit" },
|
|
2137
|
+
{ name: "DAYS", sig: "end, start" },
|
|
2138
|
+
{ name: "WEEKDAY", sig: "date, [type]" },
|
|
2139
|
+
{ name: "WEEKNUM", sig: "date, [type]" },
|
|
2140
|
+
{ name: "ISOWEEKNUM", sig: "date" },
|
|
2141
|
+
{ name: "WORKDAY", sig: "start, days, [holidays]" },
|
|
2142
|
+
{ name: "NETWORKDAYS", sig: "start, end, [holidays]" },
|
|
2143
|
+
{ name: "TEXT", sig: "value, format" },
|
|
2038
2144
|
]
|
|
2039
2145
|
// La source alimente des méthodes de chaîne (`.trim()`, `.startsWith()`) :
|
|
2040
2146
|
// la valeur brute d'une cellule peut être un nombre / booléen → on coerce.
|
|
2041
2147
|
const fxSource = computed(() => String((editing.value ? draft.value : fxDraft.value) ?? ""))
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
+
)
|
|
2048
2157
|
const fxSuggestions = computed(() => {
|
|
2049
2158
|
const q = fxQuery.value
|
|
2050
2159
|
if (!q) return []
|
|
2051
|
-
|
|
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]
|
|
2052
2180
|
})
|
|
2053
2181
|
const fxHide = ref(false)
|
|
2054
2182
|
const fxActive = ref(0)
|
|
@@ -2057,6 +2185,23 @@ watch(fxQuery, () => {
|
|
|
2057
2185
|
fxActive.value = 0
|
|
2058
2186
|
fxHide.value = false
|
|
2059
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
|
+
|
|
2060
2205
|
const fxPopStyle = ref<Record<string, string>>({})
|
|
2061
2206
|
const refreshFxPop = () => {
|
|
2062
2207
|
if (!fxOpen.value || !fxInputEl.value) return
|
|
@@ -2078,9 +2223,18 @@ const stepFx = (d: number) => {
|
|
|
2078
2223
|
const acceptFx = () => {
|
|
2079
2224
|
const f = fxSuggestions.value[fxActive.value]
|
|
2080
2225
|
if (!f) return
|
|
2081
|
-
const
|
|
2082
|
-
|
|
2083
|
-
|
|
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
|
|
2084
2238
|
fxActive.value = 0
|
|
2085
2239
|
nextTick(() => {
|
|
2086
2240
|
const el = editing.value
|
|
@@ -2088,7 +2242,12 @@ const acceptFx = () => {
|
|
|
2088
2242
|
? editAreaEl.value
|
|
2089
2243
|
: editInputEl.value
|
|
2090
2244
|
: fxInputEl.value
|
|
2091
|
-
el
|
|
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
|
|
2092
2251
|
})
|
|
2093
2252
|
}
|
|
2094
2253
|
const acceptFxAt = (i: number) => {
|
|
@@ -2915,8 +3074,8 @@ const applyFill = () => {
|
|
|
2915
3074
|
const sr = b.r0 + (((rr - b.r0) % h) + h) % h
|
|
2916
3075
|
const sc = b.c0 + (((cc - b.c0) % w) + w) % w
|
|
2917
3076
|
let raw = state.value[sr]?.[colNameAt(sc)]
|
|
2918
|
-
if (typeof raw === "string" && raw.startsWith("=")) {
|
|
2919
|
-
raw = shiftFormulaRefs(raw, rr - sr, cc - sc)
|
|
3077
|
+
if (typeof raw === "string" && raw.trim().startsWith("=")) {
|
|
3078
|
+
raw = shiftFormulaRefs(raw.trim(), rr - sr, cc - sc)
|
|
2920
3079
|
}
|
|
2921
3080
|
val = raw
|
|
2922
3081
|
}
|
|
@@ -4527,9 +4686,23 @@ defineExpose({
|
|
|
4527
4686
|
:placeholder="fxCanEdit ? t('fxPlaceholder') : t('fxNoSel')"
|
|
4528
4687
|
spellcheck="false"
|
|
4529
4688
|
@input="onFxInput"
|
|
4689
|
+
@keyup="syncFxCaret"
|
|
4690
|
+
@click="syncFxCaret"
|
|
4530
4691
|
@keydown="onFxKeydown"
|
|
4531
4692
|
@blur="commitFx"
|
|
4532
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>
|
|
4533
4706
|
</div>
|
|
4534
4707
|
|
|
4535
4708
|
<!-- ═══════ Barre Find & Replace ═══════ -->
|
|
@@ -4909,6 +5082,9 @@ defineExpose({
|
|
|
4909
5082
|
class="q-spreadsheet__editor-input q-spreadsheet__editor-area"
|
|
4910
5083
|
:rows="draftLines"
|
|
4911
5084
|
spellcheck="false"
|
|
5085
|
+
@input="syncEditCaret"
|
|
5086
|
+
@keyup="syncEditCaret"
|
|
5087
|
+
@click="syncEditCaret"
|
|
4912
5088
|
@keydown="onEditKeydown"
|
|
4913
5089
|
@blur="commitEdit"
|
|
4914
5090
|
/>
|
|
@@ -4923,6 +5099,12 @@ defineExpose({
|
|
|
4923
5099
|
@keydown="onEditKeydown"
|
|
4924
5100
|
@blur="commitEdit"
|
|
4925
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>
|
|
4926
5108
|
<div v-if="editingIsChoice && selectOptions.length" class="q-spreadsheet__options">
|
|
4927
5109
|
<button
|
|
4928
5110
|
v-for="opt in selectOptions"
|
|
@@ -4947,7 +5129,7 @@ defineExpose({
|
|
|
4947
5129
|
<div v-if="editingIsFormula && fxOpen" class="q-spreadsheet__fnsug">
|
|
4948
5130
|
<button
|
|
4949
5131
|
v-for="(f, fi) in fxSuggestions"
|
|
4950
|
-
:key="f.name"
|
|
5132
|
+
:key="`${f.kind}:${f.name}`"
|
|
4951
5133
|
type="button"
|
|
4952
5134
|
class="q-spreadsheet__fnsug-item"
|
|
4953
5135
|
:class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
|
|
@@ -4955,7 +5137,8 @@ defineExpose({
|
|
|
4955
5137
|
@click="acceptFxAt(fi)"
|
|
4956
5138
|
>
|
|
4957
5139
|
<code>{{ f.name }}</code>
|
|
4958
|
-
<span>({{ f.
|
|
5140
|
+
<span v-if="f.kind === 'fn'">({{ f.detail }})</span>
|
|
5141
|
+
<span v-else class="q-spreadsheet__fnsug-kind">{{ f.detail }}</span>
|
|
4959
5142
|
</button>
|
|
4960
5143
|
</div>
|
|
4961
5144
|
</div>
|
|
@@ -5252,7 +5435,7 @@ defineExpose({
|
|
|
5252
5435
|
>
|
|
5253
5436
|
<button
|
|
5254
5437
|
v-for="(f, fi) in fxSuggestions"
|
|
5255
|
-
:key="f.name"
|
|
5438
|
+
:key="`${f.kind}:${f.name}`"
|
|
5256
5439
|
type="button"
|
|
5257
5440
|
class="q-spreadsheet__fnsug-item"
|
|
5258
5441
|
:class="{ 'q-spreadsheet__fnsug-item--on': fxActive === fi }"
|
|
@@ -5260,7 +5443,8 @@ defineExpose({
|
|
|
5260
5443
|
@click="acceptFxAt(fi)"
|
|
5261
5444
|
>
|
|
5262
5445
|
<code>{{ f.name }}</code>
|
|
5263
|
-
<span>({{ f.
|
|
5446
|
+
<span v-if="f.kind === 'fn'">({{ f.detail }})</span>
|
|
5447
|
+
<span v-else class="q-spreadsheet__fnsug-kind">{{ f.detail }}</span>
|
|
5264
5448
|
</button>
|
|
5265
5449
|
</div>
|
|
5266
5450
|
</Teleport>
|
package/components/QTable.vue
CHANGED
|
@@ -35,6 +35,7 @@ import { icons } from "../lib/icons"
|
|
|
35
35
|
import { cn } from "../lib/utils"
|
|
36
36
|
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
37
37
|
import type { RadiusProp } from "../lib/useComponentProps"
|
|
38
|
+
import QSelect from "./QSelect.vue"
|
|
38
39
|
|
|
39
40
|
interface Props {
|
|
40
41
|
/** Lignes de données */
|
|
@@ -164,6 +165,13 @@ const rowsPerPage = computed({
|
|
|
164
165
|
get: () => pagination.value.rowsPerPage,
|
|
165
166
|
set: (v) => setPagination({ page: 1, rowsPerPage: v }),
|
|
166
167
|
})
|
|
168
|
+
|
|
169
|
+
/** Libellé d'une option « lignes par page » (0 = tout afficher).
|
|
170
|
+
* Reçoit l'option normalisée par QSelect (`{ value, label }`) ou la valeur brute. */
|
|
171
|
+
const rowsPerPageOptionLabel = (opt: any): string => {
|
|
172
|
+
const value = opt !== null && typeof opt === "object" ? opt.value : opt
|
|
173
|
+
return value === 0 ? "All" : String(value)
|
|
174
|
+
}
|
|
167
175
|
const sortBy = computed(() => pagination.value.sortBy)
|
|
168
176
|
const descending = computed(() => pagination.value.descending)
|
|
169
177
|
|
|
@@ -729,12 +737,18 @@ const virtual = computed(() => {
|
|
|
729
737
|
<slot name="bottom" />
|
|
730
738
|
<div class="q-table__pagination">
|
|
731
739
|
<span class="q-table__range">{{ rangeLabel }}</span>
|
|
732
|
-
<
|
|
733
|
-
Rows per page
|
|
734
|
-
<select
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
740
|
+
<div class="q-table__rows-select">
|
|
741
|
+
<span>Rows per page</span>
|
|
742
|
+
<q-select
|
|
743
|
+
v-model="rowsPerPage"
|
|
744
|
+
class="q-table__rows-field"
|
|
745
|
+
:options="rowsPerPageOptions"
|
|
746
|
+
:option-label="rowsPerPageOptionLabel"
|
|
747
|
+
emit-value
|
|
748
|
+
dense
|
|
749
|
+
aria-label="Rows per page"
|
|
750
|
+
/>
|
|
751
|
+
</div>
|
|
738
752
|
<div class="q-table__page-nav">
|
|
739
753
|
<button
|
|
740
754
|
type="button"
|
package/lib/formula.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests unitaires du moteur de formules A1 (bun test).
|
|
2
2
|
import { describe, expect, it } from "bun:test"
|
|
3
|
-
import { evaluateFormula, isError } from "./formula"
|
|
3
|
+
import { criteriaMatcher, evaluateFormula, FORMULA_FUNCTION_NAMES, isError } from "./formula"
|
|
4
4
|
import type { FormulaValue } from "./formula"
|
|
5
5
|
|
|
6
6
|
type Grid = (string | number | boolean | null)[][]
|
|
@@ -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", () => {
|
|
@@ -204,3 +212,188 @@ describe("math & erreurs de type", () => {
|
|
|
204
212
|
expect(isError(e("=1/0"))).toBe(true)
|
|
205
213
|
})
|
|
206
214
|
})
|
|
215
|
+
|
|
216
|
+
// ─── Extension 2026-10-17 : critères, stats, recherche, dates, texte ───
|
|
217
|
+
|
|
218
|
+
describe("critères (SUMIF / COUNTIF / *IFS)", () => {
|
|
219
|
+
const grid: Grid = [
|
|
220
|
+
["A", "x", 10],
|
|
221
|
+
["B", "y", 20],
|
|
222
|
+
["C", "x", 30],
|
|
223
|
+
[null, null, null],
|
|
224
|
+
["D", "y", 40],
|
|
225
|
+
]
|
|
226
|
+
const e = harness(grid)
|
|
227
|
+
|
|
228
|
+
it("SUMIF : critère texte, comparaison numérique, plage de somme optionnelle", () => {
|
|
229
|
+
expect(e('=SUMIF(B1:B5,"x",C1:C5)')).toBe(40)
|
|
230
|
+
expect(e('=SUMIF(B1:B5,"y",C1:C5)')).toBe(60)
|
|
231
|
+
expect(e('=SUMIF(C1:C5,">15")')).toBe(90)
|
|
232
|
+
expect(e('=SUMIF(C1:C5,"<>")')).toBe(100)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("COUNTIF : égalité (insensible à la casse), vides, jokers", () => {
|
|
236
|
+
expect(e('=COUNTIF(A1:A5,"a")')).toBe(1)
|
|
237
|
+
expect(e('=COUNTIF(A1:A5,"?")')).toBe(4)
|
|
238
|
+
expect(e('=COUNTIF(A1:A5,"")')).toBe(1)
|
|
239
|
+
expect(e('=COUNTIF(A1:A5,"<>")')).toBe(4)
|
|
240
|
+
expect(e('=COUNTIF(A1:A5,"a*")')).toBe(1)
|
|
241
|
+
expect(e('=COUNTIF(A1:A5,">b")')).toBe(2)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("AVERAGEIF ignore les non-nombres ; #DIV/0! sans correspondance", () => {
|
|
245
|
+
expect(e('=AVERAGEIF(B1:B5,"y",C1:C5)')).toBe(30)
|
|
246
|
+
expect(String(e('=AVERAGEIF(B1:B5,"z",C1:C5)'))).toBe("#DIV/0!")
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("COUNTIFS / SUMIFS / AVERAGEIFS croisent plusieurs critères", () => {
|
|
250
|
+
expect(e('=COUNTIFS(B1:B5,"x",C1:C5,">15")')).toBe(1)
|
|
251
|
+
expect(e('=SUMIFS(C1:C5,B1:B5,"x",C1:C5,">15")')).toBe(30)
|
|
252
|
+
expect(e('=SUMIFS(C1:C5,B1:B5,"x")')).toBe(40)
|
|
253
|
+
expect(e('=AVERAGEIFS(C1:C5,B1:B5,"y")')).toBe(30)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("SUMPRODUCT multiplie les plages terme à terme", () => {
|
|
257
|
+
expect(e("=SUMPRODUCT(C1:C3,C1:C3)")).toBe(1400)
|
|
258
|
+
expect(e("=SUMPRODUCT(C1:C2)")).toBe(30)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe("criteriaMatcher (unitaire)", () => {
|
|
263
|
+
it("compare nombres, textes, vides et jokers", () => {
|
|
264
|
+
expect(criteriaMatcher(">10")(15)).toBe(true)
|
|
265
|
+
expect(criteriaMatcher(">10")("abc")).toBe(false)
|
|
266
|
+
expect(criteriaMatcher("<=5")(5)).toBe(true)
|
|
267
|
+
expect(criteriaMatcher(0)("0")).toBe(true)
|
|
268
|
+
expect(criteriaMatcher("paris")("PARIS")).toBe(true)
|
|
269
|
+
expect(criteriaMatcher("paris")("paris ")).toBe(false)
|
|
270
|
+
expect(criteriaMatcher("par*")("paris")).toBe(true)
|
|
271
|
+
expect(criteriaMatcher("<>")(null)).toBe(false)
|
|
272
|
+
expect(criteriaMatcher("")("")).toBe(true)
|
|
273
|
+
expect(criteriaMatcher("<m")("apple")).toBe(true)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe("statistiques", () => {
|
|
278
|
+
const grid: Grid = [
|
|
279
|
+
[10],
|
|
280
|
+
[20],
|
|
281
|
+
[30],
|
|
282
|
+
[40],
|
|
283
|
+
]
|
|
284
|
+
const e = harness(grid)
|
|
285
|
+
|
|
286
|
+
it("MEDIAN / LARGE / SMALL / RANK", () => {
|
|
287
|
+
expect(e("=MEDIAN(A1:A4)")).toBe(25)
|
|
288
|
+
expect(e("=LARGE(A1:A4,2)")).toBe(30)
|
|
289
|
+
expect(e("=SMALL(A1:A4,2)")).toBe(20)
|
|
290
|
+
expect(e("=RANK(20,A1:A4)")).toBe(3)
|
|
291
|
+
expect(e("=RANK(20,A1:A4,1)")).toBe(2)
|
|
292
|
+
expect(String(e("=LARGE(A1:A4,9)"))).toBe("#NUM!")
|
|
293
|
+
expect(String(e("=RANK(99,A1:A4)"))).toBe("#N/A")
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("STDEV / STDEVP / VAR / VARP", () => {
|
|
297
|
+
expect(Number(e("=STDEV(A1:A4)"))).toBeCloseTo(12.9099, 3)
|
|
298
|
+
expect(Number(e("=STDEVP(A1:A4)"))).toBeCloseTo(11.1803, 3)
|
|
299
|
+
expect(Number(e("=VAR(A1:A4)"))).toBeCloseTo(166.6667, 3)
|
|
300
|
+
expect(e("=VARP(A1:A4)")).toBe(125)
|
|
301
|
+
expect(String(e("=STDEV(A1)"))).toBe("#DIV/0!")
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe("recherche INDEX / MATCH", () => {
|
|
306
|
+
const grid: Grid = [
|
|
307
|
+
["A", "x", 10],
|
|
308
|
+
["B", "y", 20],
|
|
309
|
+
["C", "x", 30],
|
|
310
|
+
]
|
|
311
|
+
const e = harness(grid)
|
|
312
|
+
|
|
313
|
+
it("INDEX sur une plage (ligne/colonne, index unique)", () => {
|
|
314
|
+
expect(e("=INDEX(C1:C3,2)")).toBe(20)
|
|
315
|
+
expect(e("=INDEX(A1:C1,2)")).toBe("x")
|
|
316
|
+
expect(e("=INDEX(A1:C3,3,3)")).toBe(30)
|
|
317
|
+
expect(String(e("=INDEX(A1:C3,9,1)"))).toBe("#REF!")
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("MATCH exact et approché", () => {
|
|
321
|
+
expect(e('=MATCH("x",B1:B3,0)')).toBe(1)
|
|
322
|
+
expect(e("=MATCH(30,C1:C3,0)")).toBe(3)
|
|
323
|
+
expect(e("=MATCH(25,C1:C3,1)")).toBe(2)
|
|
324
|
+
expect(String(e("=MATCH(99,C1:C3,0)"))).toBe("#N/A")
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
describe("littéraux TRUE / FALSE", () => {
|
|
329
|
+
const e = harness([[]])
|
|
330
|
+
it("valeurs booléennes en littéral et en fonction", () => {
|
|
331
|
+
expect(e("=IF(TRUE,1,2)")).toBe(1)
|
|
332
|
+
expect(e("=IF(FALSE,1,2)")).toBe(2)
|
|
333
|
+
expect(e("=TRUE()")).toBe(true)
|
|
334
|
+
expect(e("=FALSE()")).toBe(false)
|
|
335
|
+
expect(e('=IFS(0,1,TRUE,"x")')).toBe("x")
|
|
336
|
+
})
|
|
337
|
+
it("un identifiant plus long reste une référence", () => {
|
|
338
|
+
expect(String(e("=TRUE1"))).toBe("#NAME?")
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe("erreurs (ISERROR / ISERR / ISNA / NA)", () => {
|
|
343
|
+
const e = harness([[]])
|
|
344
|
+
it("distingue erreur générique, #N/A et absence d'erreur", () => {
|
|
345
|
+
expect(e("=ISERROR(1/0)")).toBe(true)
|
|
346
|
+
expect(e("=ISERROR(1)")).toBe(false)
|
|
347
|
+
expect(e("=ISERR(1/0)")).toBe(true)
|
|
348
|
+
expect(e("=ISERR(NA())")).toBe(false)
|
|
349
|
+
expect(e("=ISNA(NA())")).toBe(true)
|
|
350
|
+
expect(e("=ISNA(1/0)")).toBe(false)
|
|
351
|
+
expect(String(e("=NA()"))).toBe("#N/A")
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
describe("texte étendu", () => {
|
|
356
|
+
const e = harness([[]])
|
|
357
|
+
it("TEXTJOIN / PROPER / SEARCH / EXACT", () => {
|
|
358
|
+
expect(e('=TEXTJOIN("-",1,"a","","b")')).toBe("a-b")
|
|
359
|
+
expect(e('=TEXTJOIN("-",0,"a","","b")')).toBe("a--b")
|
|
360
|
+
expect(e('=PROPER("hello world")')).toBe("Hello World")
|
|
361
|
+
expect(e('=SEARCH("B","abc")')).toBe(2)
|
|
362
|
+
expect(String(e('=SEARCH("z","abc")'))).toBe("#VALUE!")
|
|
363
|
+
expect(e('=EXACT("a","A")')).toBe(false)
|
|
364
|
+
expect(e('=EXACT("a","a")')).toBe(true)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe("dates étendues", () => {
|
|
369
|
+
const grid: Grid = [
|
|
370
|
+
["2026-10-05"],
|
|
371
|
+
["2026-10-06"],
|
|
372
|
+
]
|
|
373
|
+
const e = harness(grid)
|
|
374
|
+
it("DAYS / WEEKNUM / ISOWEEKNUM", () => {
|
|
375
|
+
expect(e('=DAYS("2026-10-10","2026-10-01")')).toBe(9)
|
|
376
|
+
expect(e('=WEEKNUM("2026-01-05",2)')).toBe(2)
|
|
377
|
+
expect(e('=ISOWEEKNUM("2026-01-05")')).toBe(2)
|
|
378
|
+
expect(e('=WEEKDAY("2026-10-05",2)')).toBe(1)
|
|
379
|
+
})
|
|
380
|
+
it("WORKDAY / NETWORKDAYS (avec et sans fériés)", () => {
|
|
381
|
+
expect(e('=WORKDAY("2026-10-16",1)')).toBe("2026-10-19")
|
|
382
|
+
expect(e('=NETWORKDAYS("2026-10-01","2026-10-07")')).toBe(5)
|
|
383
|
+
expect(e('=NETWORKDAYS("2026-10-01","2026-10-07",A1:A2)')).toBe(3)
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
describe("parité moteur ↔ autocomplétion", () => {
|
|
388
|
+
it("aucune fonction intégrée n'est absente de FORMULA_FNS", async () => {
|
|
389
|
+
const vue = await Bun.file(new URL("../components/QSpreadsheet.vue", import.meta.url)).text()
|
|
390
|
+
const start = vue.indexOf("const FORMULA_FNS")
|
|
391
|
+
const end = vue.indexOf("const fxSource")
|
|
392
|
+
const block = vue.slice(start, end)
|
|
393
|
+
const suggested = new Set([...block.matchAll(/name: "([A-Z]+)"/g)].map((m) => m[1]!))
|
|
394
|
+
// Alias internes volontairement absents des suggestions.
|
|
395
|
+
const aliases = new Set(["AVG", "CEIL", "POW"])
|
|
396
|
+
const missing = FORMULA_FUNCTION_NAMES.filter((n) => !aliases.has(n) && !suggested.has(n))
|
|
397
|
+
expect(missing).toEqual([])
|
|
398
|
+
})
|
|
399
|
+
})
|