@dnax/ui 0.0.28 → 0.0.29
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/QBar.vue +42 -0
- package/components/QBoard.vue +285 -0
- package/components/QConfigProvider.vue +9 -5
- package/components/QInteract.vue +387 -0
- package/components/QSpreadsheet.vue +4662 -0
- package/index.ts +6 -0
- package/lib/config.ts +6 -0
- package/lib/formula.test.ts +206 -0
- package/lib/formula.ts +292 -0
- package/lib/icons.ts +17 -0
- package/lib/intersection.ts +118 -0
- package/lib/spreadsheet.test.ts +92 -0
- package/lib/spreadsheet.ts +175 -0
- package/lib/useComponentProps.ts +7 -0
- package/module.ts +2 -0
- package/package.json +3 -1
- package/scripts/generate-exports.ts +2 -0
- package/styles/main.css +1344 -0
package/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { default as QAvatar } from "./components/QAvatar.vue"
|
|
|
9
9
|
export { default as QBackHeader } from "./components/QBackHeader.vue"
|
|
10
10
|
export { default as QBackTop } from "./components/QBackTop.vue"
|
|
11
11
|
export { default as QBadge } from "./components/QBadge.vue"
|
|
12
|
+
export { default as QBar } from "./components/QBar.vue"
|
|
13
|
+
export { default as QBoard } from "./components/QBoard.vue"
|
|
12
14
|
export { default as QBottomSheet } from "./components/QBottomSheet.vue"
|
|
13
15
|
export { default as QBottomSheetFooter } from "./components/QBottomSheetFooter.vue"
|
|
14
16
|
export { default as QBottomSheetHeader } from "./components/QBottomSheetHeader.vue"
|
|
@@ -64,6 +66,7 @@ export { default as QInput } from "./components/QInput.vue"
|
|
|
64
66
|
export { default as QInputOtp } from "./components/QInputOtp.vue"
|
|
65
67
|
export { default as QInputPassword } from "./components/QInputPassword.vue"
|
|
66
68
|
export { default as QInputTag } from "./components/QInputTag.vue"
|
|
69
|
+
export { default as QInteract } from "./components/QInteract.vue"
|
|
67
70
|
export { default as QIntersection } from "./components/QIntersection.vue"
|
|
68
71
|
export { default as QItem } from "./components/QItem.vue"
|
|
69
72
|
export { default as QItemSection } from "./components/QItemSection.vue"
|
|
@@ -111,6 +114,7 @@ export { default as QSlider } from "./components/QSlider.vue"
|
|
|
111
114
|
export { default as QSpace } from "./components/QSpace.vue"
|
|
112
115
|
export { default as QSpinner } from "./components/QSpinner.vue"
|
|
113
116
|
export { default as QSplitter } from "./components/QSplitter.vue"
|
|
117
|
+
export { default as QSpreadsheet } from "./components/QSpreadsheet.vue"
|
|
114
118
|
export { default as QSticky } from "./components/QSticky.vue"
|
|
115
119
|
export { default as QSwipeCell } from "./components/QSwipeCell.vue"
|
|
116
120
|
export { default as QSwiper } from "./components/QSwiper.vue"
|
|
@@ -166,3 +170,5 @@ export { vTouchSwipe } from "./lib/touchSwipe"
|
|
|
166
170
|
export type { TouchSwipeDetails, TouchSwipeHandler, SwipeDirection } from "./lib/touchSwipe"
|
|
167
171
|
export { vTouchRepeat } from "./lib/touchRepeat"
|
|
168
172
|
export type { TouchRepeatDetails, TouchRepeatHandler } from "./lib/touchRepeat"
|
|
173
|
+
export { vIntersection } from "./lib/intersection"
|
|
174
|
+
export type { IntersectionValue } from "./lib/intersection"
|
package/lib/config.ts
CHANGED
|
@@ -14,10 +14,14 @@ export interface QThemeColors {
|
|
|
14
14
|
warning?: string
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export type QAppLang = "en" | "fr"
|
|
18
|
+
|
|
17
19
|
export interface QTheme {
|
|
18
20
|
/** Mode clair/sombre : light | dark | system (défaut : system = préférence OS) */
|
|
19
21
|
mode?: ThemeMode
|
|
20
22
|
colors?: QThemeColors
|
|
23
|
+
/** Langue par défaut appliquée aux composants qui la supportent */
|
|
24
|
+
lang?: QAppLang
|
|
21
25
|
/** Props par défaut par composant (clé = nom du composant, ex. "QBtn") */
|
|
22
26
|
componentProps?: Record<string, Record<string, unknown>>
|
|
23
27
|
}
|
|
@@ -26,6 +30,8 @@ export interface QConfigContext {
|
|
|
26
30
|
theme: Ref<QTheme>
|
|
27
31
|
/** true si le mode effectif est sombre (résout "system") */
|
|
28
32
|
isDark: Readonly<Ref<boolean>>
|
|
33
|
+
/** Langue de l'application ("en" par défaut) */
|
|
34
|
+
lang: Readonly<Ref<QAppLang>>
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export const qConfigKey: InjectionKey<QConfigContext> = Symbol("q-config")
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Tests unitaires du moteur de formules A1 (bun test).
|
|
2
|
+
import { describe, expect, it } from "bun:test"
|
|
3
|
+
import { evaluateFormula, isError } from "./formula"
|
|
4
|
+
import type { FormulaValue } from "./formula"
|
|
5
|
+
|
|
6
|
+
type Grid = (string | number | boolean | null)[][]
|
|
7
|
+
|
|
8
|
+
function harness(grid: Grid, formulaRow = 0) {
|
|
9
|
+
return (f: string): FormulaValue =>
|
|
10
|
+
evaluateFormula(f, {
|
|
11
|
+
row: formulaRow,
|
|
12
|
+
resolveField: () => undefined,
|
|
13
|
+
resolveCell: (r, c) => {
|
|
14
|
+
const row = grid[r]
|
|
15
|
+
return row && c >= 0 && c < row.length
|
|
16
|
+
? (row[c] as FormulaValue | undefined)
|
|
17
|
+
: undefined
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("opérateurs & références", () => {
|
|
23
|
+
const grid: Grid = [
|
|
24
|
+
[1, 2, 3],
|
|
25
|
+
[10, 20, 30],
|
|
26
|
+
]
|
|
27
|
+
const e = harness(grid)
|
|
28
|
+
it("arithmétique et parenthèses", () => {
|
|
29
|
+
expect(e("=1+2*3")).toBe(7)
|
|
30
|
+
expect(e("=(1+2)*3")).toBe(9)
|
|
31
|
+
expect(e("=2^3")).toBe(8)
|
|
32
|
+
expect(e("=10%")).toBe(0.1)
|
|
33
|
+
expect(e("=200*10%")).toBe(20)
|
|
34
|
+
})
|
|
35
|
+
it("références A1 / plages / concat", () => {
|
|
36
|
+
expect(e("=A1+B1")).toBe(3)
|
|
37
|
+
expect(e("=A2*C1")).toBe(30)
|
|
38
|
+
expect(e('="x"&A1')).toBe("x1")
|
|
39
|
+
})
|
|
40
|
+
it("comparaisons", () => {
|
|
41
|
+
expect(e("=B2>B1")).toBe(true)
|
|
42
|
+
expect(e('=A1="1"')).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
it("référence hors grille → #REF!", () => {
|
|
45
|
+
expect(String(e("=Z99"))).toBe("#REF!")
|
|
46
|
+
})
|
|
47
|
+
it("division par zéro → #DIV/0!", () => {
|
|
48
|
+
expect(String(e("=1/0"))).toBe("#DIV/0!")
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("fonctions agrégation", () => {
|
|
53
|
+
const grid: Grid = [
|
|
54
|
+
[5, "x", 10],
|
|
55
|
+
[null, 2, 3],
|
|
56
|
+
]
|
|
57
|
+
const e = harness(grid)
|
|
58
|
+
it("SUM / AVERAGE / MIN / MAX sur plages", () => {
|
|
59
|
+
expect(e("=SUM(A1:C2)")).toBe(20)
|
|
60
|
+
expect(e("=AVERAGE(A1,C1,C2)")).toBe(6)
|
|
61
|
+
expect(e("=MIN(A1:C2)")).toBe(2)
|
|
62
|
+
expect(e("=MAX(A1:C2)")).toBe(10)
|
|
63
|
+
})
|
|
64
|
+
it("COUNT compte les nombres, COUNTA les non-vides", () => {
|
|
65
|
+
expect(e("=COUNT(A1:C2)")).toBe(4)
|
|
66
|
+
expect(e("=COUNTA(A1:C2)")).toBe(5)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("fonctions logiques (lazy)", () => {
|
|
71
|
+
const e = harness([[]])
|
|
72
|
+
it("IF n'évalue que la branche prise", () => {
|
|
73
|
+
expect(e("=IF(1,1,1/0)")).toBe(1)
|
|
74
|
+
expect(e('=IF(0,1,"no")')).toBe("no")
|
|
75
|
+
})
|
|
76
|
+
it("IFS premier vrai", () => {
|
|
77
|
+
expect(e('=IFS(1>2,"a",1<2,"b")')).toBe("b")
|
|
78
|
+
expect(String(e("=IFS(0,1)"))).toBe("#N/A")
|
|
79
|
+
})
|
|
80
|
+
it("SWITCH avec défaut", () => {
|
|
81
|
+
expect(e('=SWITCH(2,1,"un",2,"deux","autre")')).toBe("deux")
|
|
82
|
+
expect(e('=SWITCH(9,1,"un","autre")')).toBe("autre")
|
|
83
|
+
})
|
|
84
|
+
it("IFERROR / IFNA", () => {
|
|
85
|
+
expect(e('=IFERROR(1/0,"ko")')).toBe("ko")
|
|
86
|
+
expect(e('=IFERROR(2,"ko")')).toBe(2)
|
|
87
|
+
})
|
|
88
|
+
it("AND / OR / NOT", () => {
|
|
89
|
+
expect(e("=AND(1,1>0)")).toBe(true)
|
|
90
|
+
expect(e("=OR(0,1=1)")).toBe(true)
|
|
91
|
+
expect(e("=NOT(0)")).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("VLOOKUP", () => {
|
|
96
|
+
const grid: Grid = [
|
|
97
|
+
["A", 1, "x"],
|
|
98
|
+
["B", 2, "y"],
|
|
99
|
+
["A", 3, "z"],
|
|
100
|
+
]
|
|
101
|
+
const e = harness(grid)
|
|
102
|
+
it("recherche exacte dans la 1re colonne", () => {
|
|
103
|
+
expect(e('=VLOOKUP("B",A1:C3,2)')).toBe(2)
|
|
104
|
+
expect(e('=VLOOKUP("A",A1:C3,3)')).toBe("x")
|
|
105
|
+
})
|
|
106
|
+
it("absence → #N/A, colonne hors plage → #REF!", () => {
|
|
107
|
+
expect(String(e('=VLOOKUP("Z",A1:C3,2)'))).toBe("#N/A")
|
|
108
|
+
expect(String(e('=VLOOKUP("A",A1:C3,5)'))).toBe("#REF!")
|
|
109
|
+
})
|
|
110
|
+
it("pas une plage → #VALUE!", () => {
|
|
111
|
+
expect(String(e('=VLOOKUP("A",A1,2)'))).toBe("#VALUE!")
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe("XLOOKUP + dates avancées", () => {
|
|
116
|
+
const grid: Grid = [
|
|
117
|
+
["A", 1, "x"],
|
|
118
|
+
["B", 2, "y"],
|
|
119
|
+
["A", 3, "z"],
|
|
120
|
+
]
|
|
121
|
+
const e = harness(grid)
|
|
122
|
+
it("XLOOKUP retourne la cellule correspondante du range retour", () => {
|
|
123
|
+
expect(e('=XLOOKUP("B",A1:A3,B1:B3)')).toBe(2)
|
|
124
|
+
expect(e('=XLOOKUP("A",A1:A3,C1:C3)')).toBe("x")
|
|
125
|
+
})
|
|
126
|
+
it("XLOOKUP fallback / #N/A", () => {
|
|
127
|
+
expect(String(e('=XLOOKUP("Z",A1:A3,B1:B3,"?")'))).toBe("?")
|
|
128
|
+
expect(String(e('=XLOOKUP("Z",A1:A3,B1:B3)'))).toBe("#N/A")
|
|
129
|
+
})
|
|
130
|
+
it("WEEKDAY / EOMONTH / DATEDIF / TEXT", () => {
|
|
131
|
+
const d = harness([[]])
|
|
132
|
+
// 2026-09-07 = lundi
|
|
133
|
+
expect(d('=WEEKDAY("2026-09-07",2)')).toBe(1)
|
|
134
|
+
expect(d('=EOMONTH("2026-01-15",0)')).toBe("2026-01-31")
|
|
135
|
+
expect(d('=DATEDIF("2026-01-01","2026-02-10","D")')).toBe(40)
|
|
136
|
+
expect(d('=TEXT(1.5,"0.00")')).toBe("1.50")
|
|
137
|
+
expect(d('=TEXT(0.12,"0%")')).toBe("12%")
|
|
138
|
+
expect(d('=TEXT("2026-09-07","YYYY-MM-DD")')).toBe("2026-09-07")
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe("fonctions texte", () => {
|
|
143
|
+
const e = harness([[]])
|
|
144
|
+
it("LEFT / RIGHT / MID", () => {
|
|
145
|
+
expect(e('=LEFT("Hello",2)')).toBe("He")
|
|
146
|
+
expect(e('=RIGHT("Hello",2)')).toBe("lo")
|
|
147
|
+
expect(e('=MID("Hello",2,3)')).toBe("ell")
|
|
148
|
+
})
|
|
149
|
+
it("FIND / SUBSTITUTE / REPLACE / CONCAT", () => {
|
|
150
|
+
expect(e('=FIND("llo","Hello")')).toBe(3)
|
|
151
|
+
expect(e('=SUBSTITUTE("a-b-a","a","x",2)')).toBe("a-b-x")
|
|
152
|
+
expect(e('=SUBSTITUTE("a-b-a","a","x")')).toBe("x-b-x")
|
|
153
|
+
expect(e('=REPLACE("Hello",1,2,"XY")')).toBe("XYllo")
|
|
154
|
+
expect(e('=CONCATENATE("a",1)')).toBe("a1")
|
|
155
|
+
})
|
|
156
|
+
it("UPPER / LOWER / TRIM / LEN", () => {
|
|
157
|
+
expect(e('=UPPER("aB")')).toBe("AB")
|
|
158
|
+
expect(e('=TRIM(" x ")')).toBe("x")
|
|
159
|
+
expect(e('=LEN("abc")')).toBe(3)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe("fonctions dates", () => {
|
|
164
|
+
const e = harness([[]])
|
|
165
|
+
it("DATE construit en UTC", () => {
|
|
166
|
+
expect(e("=DATE(2026,9,7)")).toBe("2026-09-07")
|
|
167
|
+
})
|
|
168
|
+
it("YEAR / MONTH / DAY", () => {
|
|
169
|
+
expect(e('=YEAR("2026-09-07")')).toBe(2026)
|
|
170
|
+
expect(e('=MONTH("2026-09-07")')).toBe(9)
|
|
171
|
+
expect(e('=DAY("2026-09-07")')).toBe(7)
|
|
172
|
+
})
|
|
173
|
+
it("EDATE", () => {
|
|
174
|
+
expect(e('=EDATE("2026-01-15",1)')).toBe("2026-02-15")
|
|
175
|
+
})
|
|
176
|
+
it("TODAY / NOW / RAND sont valides", () => {
|
|
177
|
+
const t = e("=TODAY()")
|
|
178
|
+
expect(typeof t).toBe("string")
|
|
179
|
+
expect(String(t)).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
|
180
|
+
const r = e("=RAND()")
|
|
181
|
+
expect(typeof r).toBe("number")
|
|
182
|
+
expect(Number(r)).toBeGreaterThanOrEqual(0)
|
|
183
|
+
expect(Number(r)).toBeLessThan(1)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe("math & erreurs de type", () => {
|
|
188
|
+
const e = harness([[]])
|
|
189
|
+
it("ABS / ROUND / INT / MOD / SQRT / POWER", () => {
|
|
190
|
+
expect(e("=ABS(-4)")).toBe(4)
|
|
191
|
+
expect(e("=ROUND(1.234,2)")).toBe(1.23)
|
|
192
|
+
expect(e("=INT(-3.7)")).toBe(-3)
|
|
193
|
+
expect(e("=MOD(7,3)")).toBe(1)
|
|
194
|
+
expect(e("=SQRT(16)")).toBe(4)
|
|
195
|
+
expect(e("=POWER(2,3)")).toBe(8)
|
|
196
|
+
})
|
|
197
|
+
it("#NAME? pour une fonction inconnue", () => {
|
|
198
|
+
expect(String(e("=NOPE(1)"))).toBe("#NAME?")
|
|
199
|
+
})
|
|
200
|
+
it("#VALUE! pour du texte en numéro", () => {
|
|
201
|
+
expect(String(e('=1+"a"'))).toBe("#VALUE!")
|
|
202
|
+
})
|
|
203
|
+
it("isError détecte FormulaError", () => {
|
|
204
|
+
expect(isError(e("=1/0"))).toBe(true)
|
|
205
|
+
})
|
|
206
|
+
})
|
package/lib/formula.ts
CHANGED
|
@@ -503,6 +503,107 @@ function evalCall(node: Extract<Node, { kind: "call" }>, ctx: FormulaContext): F
|
|
|
503
503
|
}
|
|
504
504
|
return name === "and"
|
|
505
505
|
}
|
|
506
|
+
// IFS : paires cond → valeur, évaluées paresseusement
|
|
507
|
+
if (name === "ifs") {
|
|
508
|
+
if (node.args.length % 2 !== 0) return err("#VALUE!")
|
|
509
|
+
for (let i = 0; i < node.args.length; i += 2) {
|
|
510
|
+
const cond = toScalar(evalNode(node.args[i]!, ctx, false))
|
|
511
|
+
if (isError(cond)) return cond
|
|
512
|
+
if (truthy(cond)) return toScalar(evalNode(node.args[i + 1]!, ctx, false))
|
|
513
|
+
}
|
|
514
|
+
return err("#N/A")
|
|
515
|
+
}
|
|
516
|
+
// SWITCH(expr, v1, r1, …, défaut?)
|
|
517
|
+
if (name === "switch") {
|
|
518
|
+
if (!node.args.length) return err("#N/A")
|
|
519
|
+
const expr = toScalar(evalNode(node.args[0]!, ctx, false))
|
|
520
|
+
if (isError(expr)) return expr
|
|
521
|
+
const last = node.args.length - 1
|
|
522
|
+
const hasDefault = node.args.length % 2 === 0
|
|
523
|
+
for (let i = 1; i <= (hasDefault ? last - 1 : last); i += 2) {
|
|
524
|
+
const val = toScalar(evalNode(node.args[i]!, ctx, false))
|
|
525
|
+
if (isError(val)) return val
|
|
526
|
+
if (String(expr) === String(val)) return toScalar(evalNode(node.args[i + 1]!, ctx, false))
|
|
527
|
+
}
|
|
528
|
+
return hasDefault ? toScalar(evalNode(node.args[last]!, ctx, false)) : err("#N/A")
|
|
529
|
+
}
|
|
530
|
+
if (name === "ifna" && node.args.length >= 1) {
|
|
531
|
+
const v = toScalar(evalNode(node.args[0]!, ctx, false))
|
|
532
|
+
if (isError(v) && v.code === "#N/A")
|
|
533
|
+
return node.args[1] !== undefined ? toScalar(evalNode(node.args[1]!, ctx, false)) : null
|
|
534
|
+
return v
|
|
535
|
+
}
|
|
536
|
+
// VLOOKUP : recherche EXACTE de la 1re colonne d'une plage A1 (col_index 1-based)
|
|
537
|
+
if (name === "vlookup" && node.args.length >= 3) {
|
|
538
|
+
const key = toScalar(evalNode(node.args[0]!, ctx, false))
|
|
539
|
+
const rangeNode = node.args[1]!
|
|
540
|
+
const colNumRaw = toScalar(evalNode(node.args[2]!, ctx, false))
|
|
541
|
+
const colNum = Number(colNumRaw)
|
|
542
|
+
const colI = Number.isNaN(colNum) ? 1 : Math.trunc(colNum)
|
|
543
|
+
if (isError(key)) return key
|
|
544
|
+
if (rangeNode.kind === "range") {
|
|
545
|
+
const a = resolveRefPos(rangeNode.start)
|
|
546
|
+
const b = resolveRefPos(rangeNode.end)
|
|
547
|
+
if (a && b) {
|
|
548
|
+
const r0 = Math.min(a[0], b[0])
|
|
549
|
+
const r1 = Math.max(a[0], b[0])
|
|
550
|
+
const c0 = Math.min(a[1], b[1])
|
|
551
|
+
const c1 = Math.max(a[1], b[1])
|
|
552
|
+
const outC = c0 + colI - 1
|
|
553
|
+
if (outC < c0 || outC > c1) return err("#REF!")
|
|
554
|
+
for (let r = r0; r <= r1; r++) {
|
|
555
|
+
const cell = ctx.resolveCell(r, c0)
|
|
556
|
+
if (cell === undefined) continue
|
|
557
|
+
const hit =
|
|
558
|
+
typeof key === "number" && typeof cell === "number"
|
|
559
|
+
? key === cell
|
|
560
|
+
: String(key ?? "") === String(cell ?? "")
|
|
561
|
+
if (hit) {
|
|
562
|
+
const found = ctx.resolveCell(r, outC)
|
|
563
|
+
return found === undefined ? err("#REF!") : found
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return err("#N/A")
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return err("#VALUE!")
|
|
570
|
+
}
|
|
571
|
+
// XLOOKUP(key, table, return_range, [not_found]) — correspondance exacte
|
|
572
|
+
if (name === "xlookup" && node.args.length >= 3) {
|
|
573
|
+
const key = toScalar(evalNode(node.args[0]!, ctx, false))
|
|
574
|
+
const tableNode = node.args[1]!
|
|
575
|
+
const retNode = node.args[2]!
|
|
576
|
+
if (!isError(key) && tableNode.kind === "range" && retNode.kind === "range") {
|
|
577
|
+
const ta = resolveRefPos(tableNode.start)
|
|
578
|
+
const tb = resolveRefPos(tableNode.end)
|
|
579
|
+
const ra = resolveRefPos(retNode.start)
|
|
580
|
+
const rb = resolveRefPos(retNode.end)
|
|
581
|
+
if (ta && tb && ra && rb) {
|
|
582
|
+
const r0 = Math.min(ta[0], tb[0])
|
|
583
|
+
const r1 = Math.max(ta[0], tb[0])
|
|
584
|
+
const c0 = Math.min(ta[1], tb[1])
|
|
585
|
+
const retR0 = Math.min(ra[0], rb[0])
|
|
586
|
+
const retC = ra[1]
|
|
587
|
+
for (let r = r0; r <= r1; r++) {
|
|
588
|
+
const cell = ctx.resolveCell(r, c0)
|
|
589
|
+
if (cell === undefined) continue
|
|
590
|
+
const hit =
|
|
591
|
+
typeof key === "number" && typeof cell === "number"
|
|
592
|
+
? key === cell
|
|
593
|
+
: String(key ?? "") === String(cell ?? "")
|
|
594
|
+
if (hit) {
|
|
595
|
+
const row = retR0 + (r - r0)
|
|
596
|
+
const found = ctx.resolveCell(row, retC)
|
|
597
|
+
if (found === undefined) return err("#REF!")
|
|
598
|
+
return found
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (node.args[3] !== undefined) return toScalar(evalNode(node.args[3], ctx, false))
|
|
602
|
+
return err("#N/A")
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return err("#VALUE!")
|
|
606
|
+
}
|
|
506
607
|
|
|
507
608
|
const fn = FUNCTIONS[name]
|
|
508
609
|
if (!fn) return err("#NAME?")
|
|
@@ -615,6 +716,197 @@ const FUNCTIONS: Record<string, GridFunction> = {
|
|
|
615
716
|
}),
|
|
616
717
|
}
|
|
617
718
|
|
|
719
|
+
// ─── Fonctions étendues (texte / dates / logique) — 2026-09-07 ───
|
|
720
|
+
const pad2 = (n: number) => (n < 10 ? "0" + n : String(n))
|
|
721
|
+
const toLocalDate = (v: FormulaValue): Date | FormulaError => {
|
|
722
|
+
if (v === null || v === undefined || v === "") return err("#VALUE!")
|
|
723
|
+
if (typeof v === "number") return new Date(v * 86400000) // série Excel approx.
|
|
724
|
+
const s = String(v).trim().replace(" ", "T")
|
|
725
|
+
const d = new Date(s.length >= 10 ? s : s + "T00:00:00")
|
|
726
|
+
return Number.isNaN(d.getTime()) ? err("#VALUE!") : d
|
|
727
|
+
}
|
|
728
|
+
const isoLocal = (d: Date, time = false) => {
|
|
729
|
+
const base =
|
|
730
|
+
d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate())
|
|
731
|
+
return time ? base + "T" + pad2(d.getHours()) + ":" + pad2(d.getMinutes()) : base
|
|
732
|
+
}
|
|
733
|
+
const isoUTC = (d: Date) =>
|
|
734
|
+
d.getUTCFullYear() + "-" + pad2(d.getUTCMonth() + 1) + "-" + pad2(d.getUTCDate())
|
|
735
|
+
|
|
736
|
+
Object.assign(FUNCTIONS, {
|
|
737
|
+
// Logique étendue (évaluées paresseusement dans evalCall : IFS, SWITCH, IFNA, VLOOKUP)
|
|
738
|
+
// Texte
|
|
739
|
+
left: (args: FormulaValue[]) => {
|
|
740
|
+
const s = toStr(args[0] ?? null)
|
|
741
|
+
const n = args[1] === undefined ? 1 : Math.max(0, Math.trunc(Number(args[1]) || 0))
|
|
742
|
+
return s.slice(0, n)
|
|
743
|
+
},
|
|
744
|
+
right: (args: FormulaValue[]) => {
|
|
745
|
+
const s = toStr(args[0] ?? null)
|
|
746
|
+
const n = args[1] === undefined ? 1 : Math.max(0, Math.trunc(Number(args[1]) || 0))
|
|
747
|
+
return n === 0 ? "" : s.slice(-n)
|
|
748
|
+
},
|
|
749
|
+
mid: (args: FormulaValue[]) => {
|
|
750
|
+
const s = toStr(args[0] ?? null)
|
|
751
|
+
const start = Math.max(1, Math.trunc(Number(args[1]) || 0))
|
|
752
|
+
const len = args[2] === undefined ? s.length : Math.max(0, Math.trunc(Number(args[2]) || 0))
|
|
753
|
+
return s.slice(start - 1, start - 1 + len)
|
|
754
|
+
},
|
|
755
|
+
find: (args: FormulaValue[]) => {
|
|
756
|
+
const find = toStr(args[0] ?? null)
|
|
757
|
+
const within = toStr(args[1] ?? null)
|
|
758
|
+
const start = Math.max(1, Math.trunc(Number(args[2]) || 0)) - 1
|
|
759
|
+
if (!find) return err("#VALUE!")
|
|
760
|
+
const i = within.indexOf(find, Math.max(0, start))
|
|
761
|
+
return i === -1 ? err("#VALUE!") : i + 1
|
|
762
|
+
},
|
|
763
|
+
substitute: (args: FormulaValue[]) => {
|
|
764
|
+
const text = toStr(args[0] ?? null)
|
|
765
|
+
const oldS = toStr(args[1] ?? null)
|
|
766
|
+
const newS = toStr(args[2] ?? null)
|
|
767
|
+
if (!oldS) return text
|
|
768
|
+
const which = args[3] === undefined ? -1 : Math.trunc(Number(args[3]) || 0)
|
|
769
|
+
if (which <= 0) return text.split(oldS).join(newS)
|
|
770
|
+
let count = 0
|
|
771
|
+
let idx = 0
|
|
772
|
+
while (true) {
|
|
773
|
+
const at = text.indexOf(oldS, idx)
|
|
774
|
+
if (at === -1) return text
|
|
775
|
+
count++
|
|
776
|
+
if (count === which) {
|
|
777
|
+
return text.slice(0, at) + newS + text.slice(at + oldS.length)
|
|
778
|
+
}
|
|
779
|
+
idx = at + oldS.length
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
replace: (args: FormulaValue[]) => {
|
|
783
|
+
const s = toStr(args[0] ?? null)
|
|
784
|
+
const start = Math.max(1, Math.trunc(Number(args[1]) || 0)) - 1
|
|
785
|
+
const num = Math.max(0, Math.trunc(Number(args[2]) || 0))
|
|
786
|
+
const repl = toStr(args[3] ?? null)
|
|
787
|
+
return s.slice(0, start) + repl + s.slice(start + num)
|
|
788
|
+
},
|
|
789
|
+
concatenate: (args: FormulaValue[]) => FUNCTIONS.concat!(args, false),
|
|
790
|
+
// Dates (ISO : AAAA-MM-JJ, AAAA-MM-JJT HH:mm)
|
|
791
|
+
today: () => isoLocal(new Date()),
|
|
792
|
+
now: () => isoLocal(new Date(), true),
|
|
793
|
+
date: (args: FormulaValue[]) => {
|
|
794
|
+
const y = Math.trunc(Number(args[0]) || 0)
|
|
795
|
+
const m = Math.trunc(Number(args[1]) || 0)
|
|
796
|
+
const d = Math.trunc(Number(args[2]) || 0)
|
|
797
|
+
const dt = new Date(Date.UTC(y, m - 1, d))
|
|
798
|
+
return isoUTC(dt)
|
|
799
|
+
},
|
|
800
|
+
year: (args: FormulaValue[]) => {
|
|
801
|
+
const d = toLocalDate(args[0] ?? null)
|
|
802
|
+
return isError(d) ? d : d.getFullYear()
|
|
803
|
+
},
|
|
804
|
+
month: (args: FormulaValue[]) => {
|
|
805
|
+
const d = toLocalDate(args[0] ?? null)
|
|
806
|
+
return isError(d) ? d : d.getMonth() + 1
|
|
807
|
+
},
|
|
808
|
+
day: (args: FormulaValue[]) => {
|
|
809
|
+
const d = toLocalDate(args[0] ?? null)
|
|
810
|
+
return isError(d) ? d : d.getDate()
|
|
811
|
+
},
|
|
812
|
+
edate: (args: FormulaValue[]) => {
|
|
813
|
+
const d = toLocalDate(args[0] ?? null)
|
|
814
|
+
if (isError(d)) return d
|
|
815
|
+
const months = Math.trunc(Number(args[1]) || 0)
|
|
816
|
+
d.setMonth(d.getMonth() + months)
|
|
817
|
+
return isoLocal(d)
|
|
818
|
+
},
|
|
819
|
+
// Dates avancées (2026-09-07)
|
|
820
|
+
weekday: (args: FormulaValue[]) => {
|
|
821
|
+
const d = toLocalDate(args[0] ?? null)
|
|
822
|
+
if (isError(d)) return d
|
|
823
|
+
const t = Math.trunc(Number(args[1]) ?? 1) || 1
|
|
824
|
+
const wd = d.getDay() // 0 = dimanche
|
|
825
|
+
if (t === 2) return wd === 0 ? 7 : wd
|
|
826
|
+
if (t === 3) return wd === 0 ? 6 : wd - 1
|
|
827
|
+
return wd + 1 // 1 = dimanche .. 7 = samedi
|
|
828
|
+
},
|
|
829
|
+
eomonth: (args: FormulaValue[]) => {
|
|
830
|
+
const d = toLocalDate(args[0] ?? null)
|
|
831
|
+
if (isError(d)) return d
|
|
832
|
+
const m = Math.trunc(Number(args[1]) || 0)
|
|
833
|
+
d.setMonth(d.getMonth() + m + 1, 0)
|
|
834
|
+
return isoLocal(d)
|
|
835
|
+
},
|
|
836
|
+
datedif: (args: FormulaValue[]) => {
|
|
837
|
+
const a = toLocalDate(args[0] ?? null)
|
|
838
|
+
const b = toLocalDate(args[1] ?? null)
|
|
839
|
+
if (isError(a) || isError(b)) return isError(a) ? a : b
|
|
840
|
+
const unit = toStr(args[2] ?? "d").toUpperCase()
|
|
841
|
+
let ms = b.getTime() - a.getTime()
|
|
842
|
+
if (ms < 0 && unit !== "YD") return err("#NUM!")
|
|
843
|
+
const days = Math.floor(ms / 86400000)
|
|
844
|
+
if (unit === "D") return days
|
|
845
|
+
if (unit === "M") {
|
|
846
|
+
return (
|
|
847
|
+
(b.getFullYear() - a.getFullYear()) * 12 +
|
|
848
|
+
(b.getMonth() - a.getMonth()) -
|
|
849
|
+
(b.getDate() < a.getDate() ? 1 : 0)
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
if (unit === "Y") {
|
|
853
|
+
let y = b.getFullYear() - a.getFullYear()
|
|
854
|
+
if (b.getMonth() < a.getMonth() || (b.getMonth() === a.getMonth() && b.getDate() < a.getDate())) y--
|
|
855
|
+
return y
|
|
856
|
+
}
|
|
857
|
+
if (unit === "MD") {
|
|
858
|
+
const tmp = new Date(a)
|
|
859
|
+
tmp.setFullYear(b.getFullYear(), b.getMonth(), 1)
|
|
860
|
+
tmp.setMonth(tmp.getMonth() + 1, 0)
|
|
861
|
+
const base = Math.max(1, tmp.getDate())
|
|
862
|
+
return b.getDate() < a.getDate() ? b.getDate() + (base - a.getDate()) : b.getDate() - a.getDate()
|
|
863
|
+
}
|
|
864
|
+
if (unit === "YD") {
|
|
865
|
+
const anchor = new Date(b)
|
|
866
|
+
anchor.setFullYear(a.getFullYear())
|
|
867
|
+
if (anchor < a) anchor.setFullYear(a.getFullYear() + 1)
|
|
868
|
+
return Math.round((b.getTime() - anchor.getTime()) / 86400000)
|
|
869
|
+
}
|
|
870
|
+
return err("#VALUE!")
|
|
871
|
+
},
|
|
872
|
+
text: (args: FormulaValue[]) => {
|
|
873
|
+
const v = args[0] ?? null
|
|
874
|
+
const fmt = toStr(args[1] ?? "")
|
|
875
|
+
// nombres : 0.00 / 0% / milliers
|
|
876
|
+
if (typeof v === "number" || (typeof v === "string" && !Number.isNaN(Number(v)))) {
|
|
877
|
+
const n = typeof v === "number" ? v : Number(v)
|
|
878
|
+
const f = fmt.trim()
|
|
879
|
+
if (f.includes("%")) return String(Math.round(n * 100)) + "%"
|
|
880
|
+
const dec = /0+(\.0+)?/.exec(f)?.[0]
|
|
881
|
+
const digits = dec && dec.includes(".") ? dec.length - dec.indexOf(".") - 1 : 0
|
|
882
|
+
if (f === "0") return String(Math.round(n))
|
|
883
|
+
return n.toFixed(digits)
|
|
884
|
+
}
|
|
885
|
+
// dates ISO → tokens YYYY MM DD HH mm
|
|
886
|
+
const d = toLocalDate(v)
|
|
887
|
+
if (!isError(d)) {
|
|
888
|
+
const map: Record<string, string> = {
|
|
889
|
+
YYYY: String(d.getFullYear()),
|
|
890
|
+
YY: pad2(d.getFullYear() % 100),
|
|
891
|
+
MM: pad2(d.getMonth() + 1),
|
|
892
|
+
DD: pad2(d.getDate()),
|
|
893
|
+
HH: pad2(d.getHours()),
|
|
894
|
+
mm: pad2(d.getMinutes()),
|
|
895
|
+
hh: pad2(d.getHours() % 12 || 12),
|
|
896
|
+
ss: pad2(d.getSeconds()),
|
|
897
|
+
dddd: "",
|
|
898
|
+
}
|
|
899
|
+
let out = fmt
|
|
900
|
+
for (const k of Object.keys(map)) out = out.split(k).join(map[k]!)
|
|
901
|
+
out = out.replace(/dddd|ddd/g, "")
|
|
902
|
+
return out
|
|
903
|
+
}
|
|
904
|
+
return toStr(v)
|
|
905
|
+
},
|
|
906
|
+
// Aléatoire / autres
|
|
907
|
+
rand: () => Math.random(),
|
|
908
|
+
})
|
|
909
|
+
|
|
618
910
|
/** Évalue une formule ("=…" optionnel). Retourne une valeur ou une FormulaError. */
|
|
619
911
|
export function evaluateFormula(source: string, ctx: FormulaContext): FormulaValue {
|
|
620
912
|
const src = source.startsWith("=") ? source.slice(1) : source
|
package/lib/icons.ts
CHANGED
|
@@ -61,4 +61,21 @@ export const icons = {
|
|
|
61
61
|
alignRight: "lucide:align-right",
|
|
62
62
|
listChecks: "lucide:list-checks",
|
|
63
63
|
table: "lucide:table",
|
|
64
|
+
// — tableur (QSpreadsheet) —
|
|
65
|
+
trash2: "lucide:trash-2",
|
|
66
|
+
clipboardPaste: "lucide:clipboard-paste",
|
|
67
|
+
rows3: "lucide:rows-3",
|
|
68
|
+
columns3: "lucide:columns-3",
|
|
69
|
+
arrowAZ: "lucide:arrow-up-a-z",
|
|
70
|
+
arrowZA: "lucide:arrow-down-a-z",
|
|
71
|
+
squarePlus: "lucide:square-plus",
|
|
72
|
+
squareMinus: "lucide:square-minus",
|
|
73
|
+
pencil: "lucide:pencil",
|
|
74
|
+
sortAsc: "lucide:arrow-up-narrow-wide",
|
|
75
|
+
sortDesc: "lucide:arrow-down-wide-narrow",
|
|
76
|
+
selectCheck: "lucide:check",
|
|
77
|
+
filter: "lucide:filter",
|
|
78
|
+
scissors: "lucide:scissors",
|
|
79
|
+
highlighter: "lucide:highlighter",
|
|
80
|
+
tableCellsMerge: "lucide:table-cells-merge",
|
|
64
81
|
} as const
|