@dnax/ui 0.0.48 → 0.0.49

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.
Files changed (45) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QImagePicker.vue +28 -1
  3. package/components/QInputChat.vue +859 -0
  4. package/components/QInputCurrency.vue +319 -0
  5. package/components/QItemLabel.vue +99 -0
  6. package/components/QMap.vue +410 -0
  7. package/components/QNumericKeyboard.vue +304 -0
  8. package/components/QPage.vue +26 -6
  9. package/components/QPageContainer.vue +32 -0
  10. package/components/QPopupProxy.vue +378 -0
  11. package/components/QQrcode.vue +132 -0
  12. package/components/QSpreadsheet.vue +395 -69
  13. package/components/QTime.vue +845 -0
  14. package/components/QTimeline.vue +111 -0
  15. package/components/QTimelineEntry.vue +271 -0
  16. package/index.ts +11 -1
  17. package/lib/bottomSheet.test.ts +121 -0
  18. package/lib/bottomSheet.ts +115 -0
  19. package/lib/color.ts +13 -0
  20. package/lib/currency.test.ts +193 -0
  21. package/lib/currency.ts +301 -0
  22. package/lib/fixedLayout.ts +12 -11
  23. package/lib/icons.ts +3 -20
  24. package/lib/map.test.ts +365 -0
  25. package/lib/map.ts +438 -0
  26. package/lib/mapEngine.ts +158 -0
  27. package/lib/mapLeaflet.ts +142 -0
  28. package/lib/mapMaplibre.ts +106 -0
  29. package/lib/mapMaptiler.ts +93 -0
  30. package/lib/numericKeyboard.test.ts +179 -0
  31. package/lib/numericKeyboard.ts +166 -0
  32. package/lib/pagePadding.test.ts +37 -0
  33. package/lib/pagePadding.ts +32 -0
  34. package/lib/qrcode.test.ts +103 -0
  35. package/lib/qrcode.ts +125 -0
  36. package/lib/spreadsheetChanges.test.ts +230 -0
  37. package/lib/spreadsheetChanges.ts +331 -0
  38. package/package.json +5 -17
  39. package/shims.d.ts +19 -0
  40. package/styles/main.css +689 -884
  41. package/components/QTiptap.vue +0 -996
  42. package/components/internal/QTiptapTableView.vue +0 -123
  43. package/components/internal/QTiptapTaskItemView.vue +0 -49
  44. package/lib/tiptap-table.ts +0 -19
  45. package/lib/tiptap-task-list.ts +0 -16
package/lib/color.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Normalisation des couleurs CSS → une forme que **zrender** (le moteur d'ECharts) sait
2
2
  // relire : `#rrggbb`, `rgb(a,b,c)`, `rgba(a,b,c,a)`, `hsl(h,s%,l%)` ou un nom CSS.
3
+ // (`<q-map>` s'en sert aussi : la couleur d'un marqueur part dans un attribut SVG.)
3
4
  //
4
5
  // Pourquoi c'est vital : au survol, ECharts **recalcule** la couleur de l'élément —
5
6
  // `emphasisStyle.fill = liftColor(fromFill)` (`echarts/lib/util/states.js`)
@@ -16,6 +17,18 @@
16
17
  /** Sentinelles : une couleur invalide laisse `fillStyle` inchangé */
17
18
  const SENTINELS = ["#010203", "#040506"]
18
19
 
20
+ /** Canvas 2D de 1×1 px, mémoïsé : le support de `normalizeCssColor` (créé au premier
21
+ * besoin, `null` hors navigateur). Un seul canvas pour toute l'application. */
22
+ let probe: CanvasRenderingContext2D | null | undefined
23
+ export function probeContext(): CanvasRenderingContext2D | null {
24
+ if (probe !== undefined) return probe
25
+ if (typeof document === "undefined") return (probe = null)
26
+ const canvas = document.createElement("canvas")
27
+ canvas.width = canvas.height = 1
28
+ probe = canvas.getContext("2d")
29
+ return probe
30
+ }
31
+
19
32
  /** Octets sRGB → CSS relisible par zrender : `#rrggbb` si opaque, sinon `rgba(…)` */
20
33
  export function rgbaFromBytes(r: number, g: number, b: number, a: number): string {
21
34
  const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)))
@@ -0,0 +1,193 @@
1
+ // Tests unitaires de la saisie/formatage des montants (`<q-input-currency>`).
2
+ import { describe, expect, it } from "bun:test"
3
+ import {
4
+ applyLimits,
5
+ caretForDigitCount,
6
+ currencyFormat,
7
+ digitCountBefore,
8
+ draftValue,
9
+ formatAmount,
10
+ formatDraft,
11
+ localeOf,
12
+ parseAmount,
13
+ roundTo,
14
+ stepValue,
15
+ } from "./currency"
16
+
17
+ /** Neutralise les différentes espaces de groupement (fine insécable selon ICU) */
18
+ const plain = (text: string) => text.replace(/[\s\u00a0\u202f]/g, " ")
19
+
20
+ describe("localeOf", () => {
21
+ it("déduit la locale de la langue du design system", () => {
22
+ expect(localeOf("fr")).toBe("fr-FR")
23
+ expect(localeOf("en")).toBe("en-US")
24
+ })
25
+
26
+ it("garde une locale complète et retombe sur en-US", () => {
27
+ expect(localeOf("de-DE")).toBe("de-DE")
28
+ expect(localeOf()).toBe("en-US")
29
+ })
30
+ })
31
+
32
+ describe("currencyFormat", () => {
33
+ it("lit séparateurs et devise de la locale français", () => {
34
+ const format = currencyFormat({ locale: "fr-FR", currency: "EUR" })
35
+
36
+ expect(format.decimal).toBe(",")
37
+ expect(format.symbol).toBe("€")
38
+ expect(format.position).toBe("after")
39
+ expect(format.spaced).toBe(true)
40
+ expect(format.decimals).toBe(2)
41
+ expect(/[\s\u00a0\u202f]/.test(format.group)).toBe(true)
42
+ })
43
+
44
+ it("lit les conventions anglo-saxonnes", () => {
45
+ const format = currencyFormat({ locale: "en-US", currency: "USD" })
46
+
47
+ expect(format.decimal).toBe(".")
48
+ expect(format.group).toBe(",")
49
+ expect(format.symbol).toBe("$")
50
+ expect(format.position).toBe("before")
51
+ expect(format.spaced).toBe(false)
52
+ })
53
+
54
+ it("prend les décimales de la devise, surchargeables", () => {
55
+ expect(currencyFormat({ locale: "fr-FR", currency: "JPY" }).decimals).toBe(0)
56
+ expect(currencyFormat({ locale: "fr-FR", currency: "EUR", decimals: 3 }).decimals).toBe(3)
57
+ expect(currencyFormat({ locale: "fr-FR" }).decimals).toBe(2)
58
+ })
59
+
60
+ it("suit le mode d'affichage demandé", () => {
61
+ expect(currencyFormat({ locale: "fr-FR", currency: "EUR", currencyDisplay: "code" }).symbol).toBe("EUR")
62
+ expect(
63
+ plain(currencyFormat({ locale: "fr-FR", currency: "EUR", currencyDisplay: "name" }).symbol),
64
+ ).toContain("euro")
65
+ })
66
+
67
+ it("reste utilisable sans devise et ne jette pas sur une devise inconnue", () => {
68
+ const bare = currencyFormat({ locale: "fr-FR" })
69
+ expect(bare.symbol).toBe("")
70
+ expect(bare.position).toBe("after")
71
+
72
+ // Un code inconnu n'est pas une erreur pour `Intl` : il ressort tel quel
73
+ const bogus = currencyFormat({ locale: "fr-FR", currency: "ZZZ" })
74
+ expect(bogus.currency).toBe("ZZZ")
75
+ expect(bogus.symbol).toBe("ZZZ")
76
+ })
77
+ })
78
+
79
+ describe("parseAmount", () => {
80
+ const fr = { locale: "fr-FR", currency: "EUR", allowNegative: true } as const
81
+ const en = { locale: "en-US", currency: "USD", allowNegative: true } as const
82
+
83
+ it("garde les chiffres et le séparateur de la locale", () => {
84
+ expect(parseAmount("1 234,56", fr)).toEqual({
85
+ negative: false,
86
+ integer: "1234",
87
+ fraction: "56",
88
+ hasSeparator: true,
89
+ })
90
+ })
91
+
92
+ it("borne le nombre de décimales", () => {
93
+ expect(parseAmount("12,3456", fr).fraction).toBe("34")
94
+ expect(parseAmount("12,3456", { ...fr, decimals: 3 }).fraction).toBe("345")
95
+ })
96
+
97
+ it("accepte l'autre séparateur quand il est sans ambiguïté", () => {
98
+ // Collage d'une valeur anglo-saxonne, saisie au point en français
99
+ expect(draftValue(parseAmount("$1,234.56", en))).toBe(1234.56)
100
+ expect(draftValue(parseAmount("1.234,56 €", fr))).toBe(1234.56)
101
+ expect(draftValue(parseAmount("1.5", fr))).toBe(1.5)
102
+ expect(draftValue(parseAmount("12,50", en))).toBe(12.5)
103
+ // Trop de chiffres après le point en français → c'est un séparateur de groupe
104
+ expect(parseAmount("1.234", fr).integer).toBe("1234")
105
+ expect(parseAmount("1.234", fr).hasSeparator).toBe(false)
106
+ })
107
+
108
+ it("gère le signe selon allowNegative", () => {
109
+ expect(parseAmount("-12,5", fr).negative).toBe(true)
110
+ expect(parseAmount("-12,5", { ...fr, allowNegative: false }).negative).toBe(false)
111
+ expect(parseAmount("12-5", fr).integer).toBe("125")
112
+ })
113
+
114
+ it("ignore tout le reste (lettres, devise, espaces)", () => {
115
+ expect(parseAmount("12 abc 345", fr).integer).toBe("12345")
116
+ expect(parseAmount("", fr)).toEqual({ negative: false, integer: "", fraction: "", hasSeparator: false })
117
+ })
118
+ })
119
+
120
+ describe("draftValue / formatDraft / formatAmount", () => {
121
+ const fr = { locale: "fr-FR", currency: "EUR" } as const
122
+ const en = { locale: "en-US", currency: "USD" } as const
123
+
124
+ it("rend null tant que rien n'est saisi", () => {
125
+ expect(draftValue(parseAmount("", fr))).toBeNull()
126
+ expect(draftValue(parseAmount("-", fr))).toBeNull()
127
+ expect(draftValue(parseAmount(",", fr))).toBeNull()
128
+ expect(draftValue(parseAmount("0", fr))).toBe(0)
129
+ expect(draftValue(parseAmount("0,05", fr))).toBe(0.05)
130
+ })
131
+
132
+ it("groupe les milliers et conserve le séparateur final", () => {
133
+ expect(plain(formatDraft(parseAmount("1234", fr), fr))).toBe("1 234")
134
+ expect(plain(formatDraft(parseAmount("1234,", fr), fr))).toBe("1 234,")
135
+ expect(plain(formatDraft(parseAmount("1234,5", fr), fr))).toBe("1 234,5")
136
+ expect(formatDraft(parseAmount("1234567.89", en), en)).toBe("1,234,567.89")
137
+ })
138
+
139
+ it("formate un montant avec ses décimales complètes", () => {
140
+ expect(plain(formatAmount(1234.5, fr))).toBe("1 234,50")
141
+ expect(formatAmount(1234.5, en)).toBe("1,234.50")
142
+ expect(plain(formatAmount(-12.5, fr))).toBe("-12,50")
143
+ expect(plain(formatAmount(1234.5, { locale: "fr-FR", currency: "JPY" }))).toBe("1 235")
144
+ expect(formatAmount(null, fr)).toBe("")
145
+ })
146
+ })
147
+
148
+ describe("roundTo / applyLimits / stepValue", () => {
149
+ const fr = { locale: "fr-FR", currency: "EUR" } as const
150
+
151
+ it("arrondit sans erreur binaire", () => {
152
+ expect(roundTo(1.005, 2)).toBe(1.01)
153
+ expect(roundTo(12.345, 2)).toBe(12.35)
154
+ expect(roundTo(2.675, 2)).toBe(2.68)
155
+ expect(roundTo(1234.5, 0)).toBe(1235)
156
+ })
157
+
158
+ it("applique l'arrondi de la devise puis les bornes", () => {
159
+ expect(applyLimits(1.005, fr)).toBe(1.01)
160
+ expect(applyLimits(12.5, { ...fr, max: 10 })).toBe(10)
161
+ expect(applyLimits(-3, { ...fr, min: 0 })).toBe(0)
162
+ expect(applyLimits(null, fr)).toBeNull()
163
+ })
164
+
165
+ it("avance par pas, en bornant", () => {
166
+ expect(stepValue(10, 1, 1, false, fr)).toBe(11)
167
+ expect(stepValue(10, 0.5, -1, false, fr)).toBe(9.5)
168
+ expect(stepValue(null, 1, 1, false, fr)).toBe(1)
169
+ expect(stepValue(10, 1, 1, true, fr)).toBe(20)
170
+ expect(stepValue(10, 1, 1, false, { ...fr, max: 10 })).toBe(10)
171
+ })
172
+ })
173
+
174
+ describe("curseur", () => {
175
+ it("compte les chiffres avant une position", () => {
176
+ expect(digitCountBefore("1 234,56", 3)).toBe(2)
177
+ expect(digitCountBefore("1 234,56", 0)).toBe(0)
178
+ expect(digitCountBefore("1 234,56", 99)).toBe(6)
179
+ })
180
+
181
+ it("replace le curseur après n chiffres, même après reformatage", () => {
182
+ expect(caretForDigitCount("1 234", 2)).toBe(3)
183
+ expect(caretForDigitCount("12 345", 6)).toBe(6)
184
+ expect(caretForDigitCount("1 234", 0)).toBe(0)
185
+ expect(caretForDigitCount("1 234", 99)).toBe(5)
186
+ })
187
+
188
+ it("survit à l'ajout d'un séparateur de groupe", () => {
189
+ // « 1234 » + « 5 » tapé après 4 chiffres → « 12 345 », curseur après le 5e chiffre
190
+ const digits = digitCountBefore("1234", 4) + 1
191
+ expect(caretForDigitCount(plain("12 345"), digits)).toBe(6)
192
+ })
193
+ })
@@ -0,0 +1,301 @@
1
+ // currency — saisie et formatage d'un montant en devise : analyse de la frappe (chiffres,
2
+ // séparateurs, signe), formatage avec groupement et décimales locales, position du symbole
3
+ // (`Intl.NumberFormat.formatToParts`), bornes et arrondi.
4
+ //
5
+ // Pur (aucun DOM) → testable hors navigateur, comme `lib/layout.ts` ou `lib/pagePadding.ts`.
6
+ // La saisie est conservée sous forme de **brouillon canonique** (`AmountDraft`) : signe,
7
+ // chiffres entiers, chiffres décimaux — indépendant de la locale, ce qui rend le parsing,
8
+ // l'émission et la restitution du curseur triviaux.
9
+
10
+ /** Ce qui peut partir dans un champ montant : vide, signe, chiffres, séparateurs. */
11
+ export interface AmountDraft {
12
+ /** Montant négatif (signe « - » tapé) */
13
+ negative: boolean
14
+ /** Chiffres de la partie entière ("" si la saisie commence par le séparateur) */
15
+ integer: string
16
+ /** Chiffres de la partie décimale */
17
+ fraction: string
18
+ /** Un séparateur décimal a été tapé (pour conserver « 12, » à l'écran) */
19
+ hasSeparator: boolean
20
+ }
21
+
22
+ export interface CurrencyOptions {
23
+ /** Locale BCP-47 (ex. `"fr-FR"`) — sert au groupement, au séparateur et au symbole */
24
+ locale?: string
25
+ /** Code ISO 4217 (ex. `"EUR"`) — absent : montant « nu », sans devise */
26
+ currency?: string
27
+ /** Décimales forcées (défaut : celles de la devise, ex. 2 pour EUR, 0 pour JPY) */
28
+ decimals?: number
29
+ /** Le champ accepte un montant négatif */
30
+ allowNegative?: boolean
31
+ /** Affichage de la devise : symbole (défaut), code ISO ou nom localisé */
32
+ currencyDisplay?: "symbol" | "code" | "name"
33
+ /** Borne basse (appliquée au relâchement, pas pendant la frappe) */
34
+ min?: number
35
+ /** Borne haute (idem) */
36
+ max?: number
37
+ }
38
+
39
+ /** Locale par défaut du design system (langue de QConfigProvider : "en" | "fr"). */
40
+ export function localeOf(lang?: string): string {
41
+ if (!lang) return "en-US"
42
+ if (lang === "fr") return "fr-FR"
43
+ if (lang === "en") return "en-US"
44
+ return lang.includes("-") ? lang : lang
45
+ }
46
+
47
+ /** Séparateurs (décimal et de groupement) et nombre de décimales d'une devise. */
48
+ export interface CurrencyFormat {
49
+ /** Locale effectivement utilisée */
50
+ locale: string
51
+ /** Code ISO, ou "" pour un montant nu */
52
+ currency: string
53
+ /** Décimales retenues */
54
+ decimals: number
55
+ /** Séparateur décimal de la locale ("." ou ",") */
56
+ decimal: string
57
+ /** Espace (ou espace insécable) de groupement — "" si la locale n'en met pas */
58
+ group: string
59
+ /** Texte de la devise ("€", "EUR", "euro"…), "" si `currency` est absent */
60
+ symbol: string
61
+ /** La devise se place-t-elle avant (en-US) ou après (fr-FR) le montant ? */
62
+ position: "before" | "after"
63
+ /** Une espace sépare-t-elle la devise du montant ? ("12,50 €" oui, "$12.50" non) */
64
+ spaced: boolean
65
+ }
66
+
67
+ /** Bornes numériques d'une devise via `Intl` (JPY → 0, EUR → 2…) */
68
+ const currencyDecimals = (locale: string, currency: string): number => {
69
+ try {
70
+ const resolved = new Intl.NumberFormat(locale, { style: "currency", currency }).resolvedOptions()
71
+ return resolved.maximumFractionDigits ?? 2
72
+ } catch {
73
+ return 2
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Réglages de formatage d'un champ montant : séparateurs et devise de la locale, décimale
79
+ * retenue et place du symbole. `formatToParts` donne la position **réelle** du symbole
80
+ * ("1 234,56 €" en français, "$1,234.56" en anglais) — inutile de la coder en dur.
81
+ */
82
+ export function currencyFormat(options: CurrencyOptions = {}): CurrencyFormat {
83
+ const locale = options.locale || localeOf()
84
+ const currency = (options.currency || "").trim().toUpperCase()
85
+ const display = options.currencyDisplay ?? "symbol"
86
+
87
+ const decimals =
88
+ typeof options.decimals === "number" && options.decimals >= 0
89
+ ? Math.floor(options.decimals)
90
+ : currency
91
+ ? currencyDecimals(locale, currency)
92
+ : 2
93
+
94
+ let decimal = "."
95
+ let group = ","
96
+ let symbol = ""
97
+ let position: "before" | "after" = "after"
98
+ let spaced = true
99
+
100
+ try {
101
+ const bare = new Intl.NumberFormat(locale)
102
+ bare.formatToParts(1234.5).forEach((part) => {
103
+ if (part.type === "decimal") decimal = part.value
104
+ if (part.type === "group") group = part.value
105
+ })
106
+
107
+ if (currency) {
108
+ const parts = new Intl.NumberFormat(locale, {
109
+ style: "currency",
110
+ currency,
111
+ currencyDisplay: display,
112
+ maximumFractionDigits: decimals,
113
+ minimumFractionDigits: decimals,
114
+ }).formatToParts(1234.5)
115
+ const index = parts.findIndex((part) => part.type === "currency")
116
+ symbol = parts[index]?.value ?? currency
117
+ position = index <= 1 ? "before" : "after"
118
+ const neighbour = parts[position === "before" ? index + 1 : index - 1]
119
+ spaced = !neighbour || neighbour.type === "literal" ? /\s|\u00a0/.test(neighbour?.value ?? "") : false
120
+ }
121
+ } catch {
122
+ /* locale ou devise inconnue : on garde les valeurs par défaut */
123
+ }
124
+
125
+ return { locale, currency, decimals, decimal, group, symbol, position, spaced }
126
+ }
127
+
128
+ /** Ajoute les séparateurs de groupes au chiffre (via `Intl`, sans décimales ni devise). */
129
+ function groupDigits(integer: string, format: CurrencyFormat): string {
130
+ if (!integer) return ""
131
+ const asNumber = Number(integer)
132
+ if (!Number.isFinite(asNumber)) return integer
133
+ try {
134
+ return new Intl.NumberFormat(format.locale, { maximumFractionDigits: 0 }).format(asNumber)
135
+ } catch {
136
+ return integer
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Analyse une frappe en brouillon : chiffres, un séparateur décimal et (si autorisé) un
142
+ * signe. Le séparateur décimal est celui de la locale ; l'autre caractère (« . » ou « , »)
143
+ * est accepté **quand c'est le dernier séparateur et qu'il est suivi d'au plus `decimals`
144
+ * chiffres** — coller `"$1,234.56"` en en-US comme `"1.234,56 €"` en fr-FR donne donc le
145
+ * bon montant, et un « . » isolé en fr-FR reste une décimale (← tolérance d'usage).
146
+ */
147
+ export function parseAmount(text: string, options: CurrencyOptions = {}): AmountDraft {
148
+ const format = currencyFormat(options)
149
+ const source = String(text ?? "")
150
+
151
+ // Emplacement du séparateur décimal : celui de la locale, sinon l'autre caractère
152
+ // uniquement s'il est le dernier séparateur et suivi d'assez peu de chiffres.
153
+ const other = format.decimal === "." ? "," : "."
154
+ let separatorIndex = -1
155
+ const localeIndex = source.lastIndexOf(format.decimal)
156
+ const otherIndex = source.lastIndexOf(other)
157
+ if (localeIndex >= 0) separatorIndex = localeIndex
158
+ if (localeIndex < 0 && otherIndex >= 0 && format.decimals > 0) {
159
+ const tail = source.slice(otherIndex + 1).replace(/[^0-9]/g, "")
160
+ if (!source.slice(otherIndex + 1).includes(format.decimal) && tail.length <= format.decimals) {
161
+ separatorIndex = otherIndex
162
+ }
163
+ }
164
+
165
+ let negative = false
166
+ let integer = ""
167
+ let fraction = ""
168
+ const hasSeparator = separatorIndex >= 0
169
+
170
+ for (let i = 0; i < source.length; i++) {
171
+ const char = source[i]!
172
+
173
+ if (char === "-" || char === "\u2212") {
174
+ // Un signe ne compte que sur une partie entière vide (les suivants sont ignorés)
175
+ if (options.allowNegative && !integer && !fraction) negative = !negative
176
+ continue
177
+ }
178
+
179
+ if (!/[0-9]/.test(char)) continue // séparateurs de groupe, devise, espaces… ignorés
180
+
181
+ if (i > separatorIndex && hasSeparator) {
182
+ if (fraction.length < format.decimals) fraction += char
183
+ } else if (integer.length < 15) {
184
+ integer += char
185
+ }
186
+ }
187
+
188
+ return { negative: negative && options.allowNegative === true, integer, fraction, hasSeparator }
189
+ }
190
+
191
+ /** Montant d'un brouillon : `null` quand rien n'est saisi (`""`, `"-"`, `","`). */
192
+ export function draftValue(draft: AmountDraft): number | null {
193
+ if (!draft.integer && !draft.fraction) return null
194
+ const value = Number(`${draft.integer || "0"}.${draft.fraction || "0"}`)
195
+ if (!Number.isFinite(value)) return null
196
+ return draft.negative ? -value : value
197
+ }
198
+
199
+ /**
200
+ * Arrondit au nombre de décimales de la devise (1,005 € → 1,01 €). Passer par la notation
201
+ * exponentielle évite l'erreur binaire de `value * 10**n` : `Number("1.005e2")` vaut
202
+ * exactement 100,5, donc l'arrondi est correct là où `Math.round(1.005 * 100)` donne 100.
203
+ */
204
+ export function roundTo(value: number, decimals: number): number {
205
+ const places = Math.max(0, Math.floor(decimals))
206
+ if (!Number.isFinite(value)) return value
207
+ const shifted = Number(`${value}e${places}`)
208
+ if (!Number.isFinite(shifted)) return value
209
+ return Number(`${Math.round(shifted)}e-${places}`)
210
+ }
211
+
212
+ /** Applique l'arrondi de la devise puis les bornes `min` / `max` (relâchement du champ). */
213
+ export function applyLimits(
214
+ value: number | null,
215
+ options: CurrencyOptions = {},
216
+ ): number | null {
217
+ if (value === null) return null
218
+ const format = currencyFormat(options)
219
+ let next = roundTo(value, format.decimals)
220
+ if (typeof options.min === "number" && next < options.min) next = options.min
221
+ if (typeof options.max === "number" && next > options.max) next = options.max
222
+ return roundTo(next, format.decimals)
223
+ }
224
+
225
+ /**
226
+ * Affichage d'une frappe : groupement de la partie entière, séparateur local, décimales
227
+ * frappées — le séparateur final est conservé (« 12, » reste « 12, »). Le signe n'apparaît
228
+ * que sur un montant non vide (« - » seul n'affiche rien d'utile, on le garde quand même).
229
+ */
230
+ export function formatDraft(draft: AmountDraft, options: CurrencyOptions = {}): string {
231
+ const format = currencyFormat(options)
232
+ const grouped = groupDigits(draft.integer, format)
233
+ const sign = draft.negative ? "-" : ""
234
+ if (!draft.hasSeparator) return `${sign}${grouped}`
235
+ return `${sign}${grouped}${format.decimal}${draft.fraction}`
236
+ }
237
+
238
+ /** Affichage d'un montant (état non focalisé) : décimales toujours complètes. */
239
+ export function formatAmount(value: number | null, options: CurrencyOptions = {}): string {
240
+ if (value === null || !Number.isFinite(value)) return ""
241
+ return formatDraft(draftFromValue(value, options), options)
242
+ }
243
+
244
+ /**
245
+ * Brouillon correspondant à un montant : c'est ce qu'un champ affiche quand il prend la
246
+ * main (au montage, à la sortie du champ, ou quand le modèle change de l'extérieur).
247
+ * Décimales complétées et arrondies selon la devise.
248
+ */
249
+ export function draftFromValue(value: number | null, options: CurrencyOptions = {}): AmountDraft {
250
+ if (value === null || !Number.isFinite(value)) {
251
+ return { negative: false, integer: "", fraction: "", hasSeparator: false }
252
+ }
253
+
254
+ const format = currencyFormat(options)
255
+ const [integer = "0", fraction = ""] = roundTo(Math.abs(value), format.decimals)
256
+ .toFixed(format.decimals)
257
+ .split(".")
258
+
259
+ return {
260
+ negative: value < 0,
261
+ integer,
262
+ fraction: format.decimals > 0 ? (fraction || "").padEnd(format.decimals, "0") : "",
263
+ hasSeparator: format.decimals > 0,
264
+ }
265
+ }
266
+
267
+ /** Nombre de chiffres contenus dans `text` avant l'index `caret`. */
268
+ export function digitCountBefore(text: string, caret: number): number {
269
+ let count = 0
270
+ for (let i = 0; i < Math.min(caret, text.length); i++) if (/[0-9]/.test(text[i]!)) count++
271
+ return count
272
+ }
273
+
274
+ /**
275
+ * Index du curseur dans `text` placé après `digits` chiffres — la façon de ne pas perdre
276
+ * l'utilisateur quand on reformate la valeur à chaque frappe.
277
+ */
278
+ export function caretForDigitCount(text: string, digits: number): number {
279
+ if (digits <= 0) return 0
280
+ let count = 0
281
+ for (let i = 0; i < text.length; i++) {
282
+ if (/[0-9]/.test(text[i]!)) {
283
+ count++
284
+ if (count === digits) return i + 1
285
+ }
286
+ }
287
+ return text.length
288
+ }
289
+
290
+ /** Montant enrichi d'un pas (`step`, ×10 avec `coarse`) puis borné. */
291
+ export function stepValue(
292
+ value: number | null,
293
+ step: number,
294
+ direction: 1 | -1,
295
+ coarse = false,
296
+ options: CurrencyOptions = {},
297
+ ): number | null {
298
+ const increment = (Number.isFinite(step) && step > 0 ? step : 1) * (coarse ? 10 : 1)
299
+ const base = value ?? 0
300
+ return applyLimits(roundTo(base + direction * increment, currencyFormat(options).decimals), options)
301
+ }
@@ -3,10 +3,14 @@
3
3
  //
4
4
  // Problème : les barres fixed sortent du flux (position: fixed) → le contenu de
5
5
  // q-page passe dessous. Ce module :
6
- // - q-page (mode "page") reçoit un padding-top = hauteur cumulée des barres
7
- // fixed HAUT qui le précèdent ET un padding-bottom = hauteur cumulée des
8
- // barres fixed BAS qui le suivent (le contenu n'est masqué ni en haut ni en
9
- // bas au scroll)
6
+ // - q-page et q-page-container (mode "page") publient la hauteur cumulée des barres
7
+ // fixed HAUT qui les précèdent et des barres fixed BAS qui les suivent dans les
8
+ // variables CSS `--q-page-offset` / `--q-page-offset-bottom` (le contenu n'est masqué
9
+ // ni en haut ni en bas au scroll). Le padding lui-même est composé dans
10
+ // `styles/main.css`, avec la safe-area et le padding utilisateur de la prop `padding`
11
+ // (`--q-page-padding`) — d'où des variables et non un style inline : deux sources de
12
+ // padding ne peuvent pas s'écraser mutuellement. Le conteneur ne s'en sert que s'il
13
+ // ne contient pas de q-page (règle `:has()`), pour ne jamais additionner les deux.
10
14
  // - chaque barre fixed suivante (mode "bar") s'empile sous la précédente (top)
11
15
  // - chaque barre fixed basse (mode "bar-bottom") s'empile au-dessus de la
12
16
  // suivante (bottom)
@@ -79,9 +83,10 @@ function fixedBarsInRoot(el: HTMLElement): HTMLElement[] {
79
83
 
80
84
  /**
81
85
  * Applique l'offset lié aux barres fixed qui entourent `el` :
82
- * - mode "page" : padding-top = barres HAUT précédentes,
83
- * padding-bottom = barres BAS suivantes
84
- * (+ variables --q-page-offset / --q-page-offset-bottom)
86
+ * - mode "page" : variables `--q-page-offset` (barres HAUT précédentes) et
87
+ * `--q-page-offset-bottom` (barres BAS suivantes) — le padding
88
+ * est composé par les règles `.q-page` / `.q-page-container`
89
+ * (`styles/main.css`)
85
90
  * - mode "bar" : top = hauteur cumulée (empilement des barres fixed HAUT)
86
91
  * - mode "bar-bottom" : bottom = hauteur cumulée (empilement des footers fixed
87
92
  * depuis le bas de l'écran)
@@ -113,8 +118,6 @@ export function useFixedBarOffset(
113
118
  const bottom = fixedBarsHeightAfter(node)
114
119
  node.style.setProperty("--q-page-offset", top ? `${top}px` : "0px")
115
120
  node.style.setProperty("--q-page-offset-bottom", bottom ? `${bottom}px` : "0px")
116
- node.style.paddingTop = top ? `${top}px` : ""
117
- node.style.paddingBottom = bottom ? `${bottom}px` : ""
118
121
  }
119
122
  else if (mode === "bar") {
120
123
  const h = fixedBarsHeightBefore(node)
@@ -132,8 +135,6 @@ export function useFixedBarOffset(
132
135
  if (mode === "page") {
133
136
  node.style.removeProperty("--q-page-offset")
134
137
  node.style.removeProperty("--q-page-offset-bottom")
135
- node.style.paddingTop = ""
136
- node.style.paddingBottom = ""
137
138
  }
138
139
  else if (mode === "bar") {
139
140
  node.style.top = ""
package/lib/icons.ts CHANGED
@@ -37,32 +37,15 @@ export const icons = {
37
37
  search: "lucide:search",
38
38
  star: "lucide:star",
39
39
  x: "lucide:x",
40
- // — éditeur de texte (QTiptap) —
40
+ // — clavier numérique —
41
+ backspace: "lucide:delete",
42
+ // — formatage / édition de cellule (tableur) —
41
43
  undo2: "lucide:undo-2",
42
44
  redo2: "lucide:redo-2",
43
- pilcrow: "lucide:pilcrow",
44
- heading1: "lucide:heading-1",
45
- heading2: "lucide:heading-2",
46
- heading3: "lucide:heading-3",
47
45
  bold: "lucide:bold",
48
46
  italic: "lucide:italic",
49
- underline: "lucide:underline",
50
- strikethrough: "lucide:strikethrough",
51
- code: "lucide:code",
52
- link: "lucide:link",
53
- unlink: "lucide:unlink",
54
- list: "lucide:list",
55
- listOrdered: "lucide:list-ordered",
56
- quote: "lucide:quote",
57
- codeXml: "lucide:code-xml",
58
- removeFormatting: "lucide:remove-formatting",
59
- palette: "lucide:palette",
60
47
  eraser: "lucide:eraser",
61
48
  alignLeft: "lucide:align-left",
62
- alignCenter: "lucide:align-center",
63
- alignRight: "lucide:align-right",
64
- listChecks: "lucide:list-checks",
65
- table: "lucide:table",
66
49
  // — tableur (QSpreadsheet) —
67
50
  trash2: "lucide:trash-2",
68
51
  clipboardPaste: "lucide:clipboard-paste",