@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.
@@ -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) => {
@@ -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: "MIN", sig: "a, b, …" },
1990
- { name: "MAX", sig: "a, b, …" },
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: "VLOOKUP", sig: "key, range, col, [approx]" },
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
- { name: "ISBLANK", sig: "v" },
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
- const fxQuery = computed(() => {
2043
- const s = fxSource.value
2044
- if (!s.trim().startsWith("=")) return ""
2045
- const m = s.match(/[A-Za-z]{1,}$/)
2046
- return m ? m[0].toLowerCase() : ""
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
- 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]
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 replaceTail = (s: string) => s.replace(/[A-Za-z]+$/, f.name + "(")
2082
- if (editing.value) draft.value = replaceTail(String(draft.value ?? ""))
2083
- 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
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?.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
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.sig }})</span>
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.sig }})</span>
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>
@@ -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
- <label class="q-table__rows-select">
733
- Rows per page
734
- <select :value="rowsPerPage" @change="(e) => (rowsPerPage = Number((e.target as HTMLSelectElement).value))">
735
- <option v-for="n in rowsPerPageOptions" :key="n" :value="n">{{ n === 0 ? 'All' : n }}</option>
736
- </select>
737
- </label>
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"
@@ -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
+ })