@dnax/ui 0.0.48 → 0.0.50
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/QBottomSheet.vue +172 -6
- package/components/QChart.vue +1 -0
- package/components/QDatePicker.vue +1 -1
- package/components/QImagePicker.vue +28 -1
- package/components/QInputChat.vue +859 -0
- package/components/QInputCurrency.vue +319 -0
- package/components/QItemLabel.vue +99 -0
- package/components/QMap.vue +410 -0
- package/components/QNumericKeyboard.vue +304 -0
- package/components/QPage.vue +26 -6
- package/components/QPageContainer.vue +32 -0
- package/components/QPopupProxy.vue +378 -0
- package/components/QQrcode.vue +132 -0
- package/components/QSelect.vue +12 -0
- package/components/QSpreadsheet.vue +395 -69
- package/components/QTime.vue +845 -0
- package/components/QTimeline.vue +111 -0
- package/components/QTimelineEntry.vue +271 -0
- package/index.ts +11 -1
- package/lib/bottomSheet.test.ts +121 -0
- package/lib/bottomSheet.ts +115 -0
- package/lib/color.ts +13 -0
- package/lib/currency.test.ts +193 -0
- package/lib/currency.ts +301 -0
- package/lib/fixedLayout.ts +12 -11
- package/lib/icons.ts +3 -20
- package/lib/map.test.ts +365 -0
- package/lib/map.ts +438 -0
- package/lib/mapEngine.ts +158 -0
- package/lib/mapLeaflet.ts +142 -0
- package/lib/mapMaplibre.ts +106 -0
- package/lib/mapMaptiler.ts +93 -0
- package/lib/numericKeyboard.test.ts +179 -0
- package/lib/numericKeyboard.ts +166 -0
- package/lib/pagePadding.test.ts +37 -0
- package/lib/pagePadding.ts +32 -0
- package/lib/qrcode.test.ts +103 -0
- package/lib/qrcode.ts +125 -0
- package/lib/spreadsheetChanges.test.ts +230 -0
- package/lib/spreadsheetChanges.ts +331 -0
- package/package.json +5 -17
- package/shims.d.ts +19 -0
- package/styles/main.css +689 -884
- package/components/QTiptap.vue +0 -996
- package/components/internal/QTiptapTableView.vue +0 -123
- package/components/internal/QTiptapTaskItemView.vue +0 -49
- package/lib/tiptap-table.ts +0 -19
- 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
|
+
})
|
package/lib/currency.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/fixedLayout.ts
CHANGED
|
@@ -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")
|
|
7
|
-
// fixed HAUT qui
|
|
8
|
-
//
|
|
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" :
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
// —
|
|
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",
|