@dnax/ui 0.0.46 → 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 (47) hide show
  1. package/components/QBottomSheet.vue +172 -6
  2. package/components/QBtn.vue +3 -4
  3. package/components/QContainer.vue +2 -2
  4. package/components/QImagePicker.vue +28 -1
  5. package/components/QInputChat.vue +859 -0
  6. package/components/QInputCurrency.vue +319 -0
  7. package/components/QItemLabel.vue +99 -0
  8. package/components/QMap.vue +410 -0
  9. package/components/QNumericKeyboard.vue +304 -0
  10. package/components/QPage.vue +26 -6
  11. package/components/QPageContainer.vue +32 -0
  12. package/components/QPopupProxy.vue +378 -0
  13. package/components/QQrcode.vue +132 -0
  14. package/components/QSpreadsheet.vue +395 -69
  15. package/components/QTime.vue +845 -0
  16. package/components/QTimeline.vue +111 -0
  17. package/components/QTimelineEntry.vue +271 -0
  18. package/index.ts +11 -1
  19. package/lib/bottomSheet.test.ts +121 -0
  20. package/lib/bottomSheet.ts +115 -0
  21. package/lib/color.ts +13 -0
  22. package/lib/currency.test.ts +193 -0
  23. package/lib/currency.ts +301 -0
  24. package/lib/fixedLayout.ts +12 -11
  25. package/lib/icons.ts +3 -20
  26. package/lib/map.test.ts +365 -0
  27. package/lib/map.ts +438 -0
  28. package/lib/mapEngine.ts +158 -0
  29. package/lib/mapLeaflet.ts +142 -0
  30. package/lib/mapMaplibre.ts +106 -0
  31. package/lib/mapMaptiler.ts +93 -0
  32. package/lib/numericKeyboard.test.ts +179 -0
  33. package/lib/numericKeyboard.ts +166 -0
  34. package/lib/pagePadding.test.ts +37 -0
  35. package/lib/pagePadding.ts +32 -0
  36. package/lib/qrcode.test.ts +103 -0
  37. package/lib/qrcode.ts +125 -0
  38. package/lib/spreadsheetChanges.test.ts +230 -0
  39. package/lib/spreadsheetChanges.ts +331 -0
  40. package/package.json +5 -17
  41. package/shims.d.ts +19 -0
  42. package/styles/main.css +702 -893
  43. package/components/QTiptap.vue +0 -996
  44. package/components/internal/QTiptapTableView.vue +0 -123
  45. package/components/internal/QTiptapTaskItemView.vue +0 -49
  46. package/lib/tiptap-table.ts +0 -19
  47. package/lib/tiptap-task-list.ts +0 -16
@@ -0,0 +1,142 @@
1
+ // mapLeaflet — moteur **Leaflet** de `<q-map>` : raster sans clé, importé dynamiquement.
2
+ //
3
+ // Trois différences à garder en tête :
4
+ // - Leaflet travaille en **`[lat, lng]`** (l'inverse de dnax.ui, qui expose `[lng, lat]`)
5
+ // → toute position passe par `toLeaflet()` ;
6
+ // - il n'a ni styles vectoriels, ni relief, ni projection : `setStyle` / `setTerrain` /
7
+ // `setProjection` n'y font rien, c'est `setRaster` qui change la couche de tuiles ;
8
+ // - ses épingles par défaut sont des **images** (impossible de les recolorer par option)
9
+ // → les marqueurs sont dessinés en `divIcon` SVG, ce qui permet la prop `color`.
10
+ import {
11
+ safeCssColor,
12
+ toLeaflet,
13
+ type QMapLeafletOptions,
14
+ type QMapMarkSpec,
15
+ } from "./map"
16
+ import type { MapEngine, MapEngineInput } from "./mapEngine"
17
+
18
+ /** Épingle SVG (couleur du thème) : Leaflet place le `div`, l'ancre est au pied du pin. */
19
+ const pinIcon = (L: any, color: string) =>
20
+ L.divIcon({
21
+ className: "q-map__pin",
22
+ html:
23
+ '<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">' +
24
+ `<path d="M12 2c-3.9 0-7 3.1-7 7 0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z" fill="${color}" stroke="#fff" stroke-width="1.6"/>` +
25
+ '<circle cx="12" cy="9" r="2.6" fill="#fff"/>' +
26
+ "</svg>",
27
+ iconSize: [26, 26],
28
+ iconAnchor: [13, 25],
29
+ popupAnchor: [0, -22],
30
+ })
31
+
32
+ export async function createLeafletEngine(input: MapEngineInput): Promise<MapEngine> {
33
+ // Le paquet n'a pas d'export ESM explicite : on prend le défaut, sinon le module.
34
+ const leaflet: any = await import("leaflet")
35
+ const L: any = leaflet.default ?? leaflet
36
+ const options = input.options as QMapLeafletOptions
37
+
38
+ const map = L.map(input.el, {
39
+ center: options.center,
40
+ zoom: options.zoom,
41
+ ...options.mapOptions,
42
+ })
43
+
44
+ let layer = L.tileLayer(options.tiles, {
45
+ attribution: options.attribution,
46
+ ...options.layerOptions,
47
+ }).addTo(map)
48
+
49
+ let created: Array<() => void> = []
50
+ let readyCallbacks: Array<(engine: MapEngine) => void> = []
51
+ let ready = false
52
+
53
+ const clearMarks = () => {
54
+ for (const dispose of created) dispose()
55
+ created = []
56
+ }
57
+
58
+ /** Bulle thématée : la classe est posée sur le conteneur `.leaflet-popup`. */
59
+ const popupOptions = (offset?: number) => ({
60
+ className: "q-map__popup",
61
+ offset: L.point(0, -(typeof offset === "number" ? offset : 18)),
62
+ })
63
+
64
+ const engine: MapEngine = {
65
+ native: map,
66
+
67
+ setView: (center, zoom) => map.setView(toLeaflet(center), zoom),
68
+
69
+ // Leaflet n'a pas de notion de style : la couche de tuiles est la carte.
70
+ setStyle: () => {},
71
+
72
+ setRaster: (tiles, attribution) => {
73
+ const next = L.tileLayer(tiles, {
74
+ attribution: attribution ?? options.attribution,
75
+ ...options.layerOptions,
76
+ })
77
+ map.removeLayer(layer)
78
+ layer = next.addTo(map)
79
+ },
80
+
81
+ setMarks: (marks: QMapMarkSpec[]) => {
82
+ clearMarks()
83
+
84
+ for (const { mark, index, position, html } of marks) {
85
+ const latlng = toLeaflet(position)
86
+ const onPick = () => input.pick({ mark, index, position, lng: position[0], lat: position[1] })
87
+
88
+ if (mark.type === "popup") {
89
+ if (!html) continue
90
+ const popup = L.popup(popupOptions(mark.offset)).setLatLng(latlng).setContent(html)
91
+ if (mark.open !== false) popup.openOn(map)
92
+
93
+ const node = popup.getElement() as HTMLElement | null
94
+ node?.addEventListener("click", onPick)
95
+ created.push(() => {
96
+ node?.removeEventListener("click", onPick)
97
+ popup.remove()
98
+ })
99
+ continue
100
+ }
101
+
102
+ const marker = L.marker(latlng, {
103
+ icon: pinIcon(L, safeCssColor(input.color(mark.color)) ?? "#1976d2"),
104
+ draggable: !!mark.draggable,
105
+ })
106
+ if (html) marker.bindPopup(html, popupOptions(mark.offset))
107
+ marker.addTo(map)
108
+ if (mark.open && html) marker.openPopup()
109
+
110
+ marker.on("click", onPick)
111
+ created.push(() => marker.remove())
112
+ }
113
+ },
114
+
115
+ setTerrain: () => {},
116
+ setProjection: () => {},
117
+
118
+ resize: () => map.invalidateSize(),
119
+
120
+ destroy: () => {
121
+ clearMarks()
122
+ map.remove()
123
+ },
124
+
125
+ onReady: (callback) => {
126
+ if (ready) callback(engine)
127
+ else readyCallbacks.push(callback)
128
+ },
129
+ }
130
+
131
+ map.whenReady(() => {
132
+ if (ready) return
133
+ ready = true
134
+ const callbacks = readyCallbacks
135
+ readyCallbacks = []
136
+ for (const callback of callbacks) callback(engine)
137
+ })
138
+
139
+ engine.setMarks(input.marks)
140
+
141
+ return engine
142
+ }
@@ -0,0 +1,106 @@
1
+ // mapMaplibre — moteur **MapLibre GL JS** seul (sans le SDK MapTiler) : le fork
2
+ // open-source de mapbox-gl, importé dynamiquement (aucun WebGL ni DOM au prerender SSR).
3
+ //
4
+ // Différences avec le moteur MapTiler, qui partage pourtant la même base :
5
+ // - **aucun catalogue de styles** ni clé d'API : le style (`map-style`) est résolu par
6
+ // `maplibreStyle()` — style de démonstration MapLibre, raster OSM, URL ou objet ;
7
+ // - **aucun contrôle par défaut** : le SDK MapTiler ajoute zoom/géolocalisation tout
8
+ // seul, ici ils sont montés explicitement depuis les props ;
9
+ // - **pas de source de relief** : `terrain` n'a pas d'effet (on passe `options.terrain`
10
+ // au constructeur, avec une source DEM déclarée par le style).
11
+ import { maplibreStyle, type QMapMaplibreOptions } from "./map"
12
+ import {
13
+ buildMapLibreMarks,
14
+ controlPosition,
15
+ type MapEngine,
16
+ type MapEngineInput,
17
+ } from "./mapEngine"
18
+
19
+ export async function createMaplibreEngine(input: MapEngineInput): Promise<MapEngine> {
20
+ // Le paquet est un bundle UMD : on prend le défaut, sinon le module.
21
+ const mod: any = await import("maplibre-gl")
22
+ const maplibregl: any = mod.default ?? mod
23
+
24
+ const options = input.options as QMapMaplibreOptions
25
+ const map = new maplibregl.Map({ ...options.map, container: input.el })
26
+
27
+ if (!options.styleKnown) {
28
+ console.warn(
29
+ '[q-map] `map-style` inconnu avec provider="maplibre" : repli sur le style de ' +
30
+ 'démonstration MapLibre (valeurs : "demotiles", "openstreetmap", une URL de style)',
31
+ )
32
+ }
33
+
34
+ // Contrôles : MapLibre n'en monte aucun de lui-même (le SDK MapTiler, si).
35
+ const controls = input.controls ?? {}
36
+ if (controls.navigation !== false) {
37
+ map.addControl(
38
+ new maplibregl.NavigationControl(),
39
+ controlPosition(controls.navigation, "top-right"),
40
+ )
41
+ }
42
+ if (controls.geolocate !== false) {
43
+ map.addControl(
44
+ new maplibregl.GeolocateControl({ trackUserLocation: false }),
45
+ controlPosition(controls.geolocate, "top-right"),
46
+ )
47
+ }
48
+ if (controls.scale) {
49
+ map.addControl(new maplibregl.ScaleControl(), controlPosition(controls.scale, "bottom-right"))
50
+ }
51
+ if (controls.fullscreen) {
52
+ map.addControl(
53
+ new maplibregl.FullscreenControl(),
54
+ controlPosition(controls.fullscreen, "top-right"),
55
+ )
56
+ }
57
+
58
+ let clear = () => {}
59
+ let readyCallbacks: Array<(engine: MapEngine) => void> = []
60
+ let ready = false
61
+
62
+ const engine: MapEngine = {
63
+ native: map,
64
+
65
+ setView: (center, zoom) => map.jumpTo({ center, zoom }),
66
+
67
+ setStyle: (style) => map.setStyle(maplibreStyle({ style }).style),
68
+
69
+ setMarks: (marks) => {
70
+ clear()
71
+ clear = buildMapLibreMarks(maplibregl, map, marks, input)
72
+ },
73
+
74
+ // Pas de relief sans source DEM : `options.terrain` (constructeur) porte le cas.
75
+ setTerrain: () => {},
76
+
77
+ setProjection: (projection) => map.setProjection({ type: projection }),
78
+
79
+ resize: () => map.resize(),
80
+
81
+ destroy: () => {
82
+ clear()
83
+ map.remove()
84
+ },
85
+
86
+ onReady: (callback) => {
87
+ if (ready) callback(engine)
88
+ else readyCallbacks.push(callback)
89
+ },
90
+ }
91
+
92
+ const markReady = () => {
93
+ if (ready) return
94
+ ready = true
95
+ const callbacks = readyCallbacks
96
+ readyCallbacks = []
97
+ for (const callback of callbacks) callback(engine)
98
+ }
99
+
100
+ map.on("load", markReady)
101
+ map.on("error", (event: unknown) => input.error((event as any)?.error ?? event))
102
+
103
+ engine.setMarks(input.marks)
104
+
105
+ return engine
106
+ }
@@ -0,0 +1,93 @@
1
+ // mapMaptiler — moteur **MapTiler / OpenStreetMap** de `<q-map>` : le SDK JS MapTiler v4
2
+ // (MapLibre GL JS + catalogue de styles + APIs), importé dynamiquement (aucun WebGL ni DOM
3
+ // au prerender SSR).
4
+ //
5
+ // Le même moteur sert les deux fournisseurs : `maptiler` (styles vectoriels + clé) et
6
+ // `openstreetmap` (style raster construit par `mapOptionsOf`, sans clé). Les méthodes qui
7
+ // n'ont de sens que sur les styles MapTiler (`enableTerrain`, `setProjection`) sont
8
+ // ignorées pour `openstreetmap`.
9
+ //
10
+ // C'est aussi lui qui résout les **noms courts de style** (`streets`, `dataviz`…) en
11
+ // `MapStyle` du SDK — variante `.DARK` comprise — puisque c'est lui qui détient le SDK.
12
+ import { mapStyleKey } from "./map"
13
+ import { buildMapLibreMarks, type MapEngine, type MapEngineInput } from "./mapEngine"
14
+
15
+ export async function createMaptilerEngine(input: MapEngineInput): Promise<MapEngine> {
16
+ const sdk: any = await import("@maptiler/sdk")
17
+
18
+ /** Nom court → `MapStyle` du SDK (+ variante sombre si elle existe) ; un objet de
19
+ * style, un ID ou une URL sont rendus tels quels. */
20
+ const resolveStyle = (value: unknown, dark?: boolean): unknown => {
21
+ const key = mapStyleKey(value)
22
+ if (!key) return value
23
+
24
+ const reference = sdk.MapStyle?.[key.toUpperCase()]
25
+ if (!reference) return value
26
+
27
+ return dark && reference.DARK ? reference.DARK : reference
28
+ }
29
+
30
+ const options = { ...input.options, style: resolveStyle(input.options.style, input.dark) }
31
+ const map = new sdk.Map({ ...options, container: input.el })
32
+
33
+ let clear = () => {}
34
+ let readyCallbacks: Array<(engine: MapEngine) => void> = []
35
+ let ready = false
36
+
37
+ /** Relief et projection n'ont de sens qu'avec les styles du catalogue MapTiler. */
38
+ const terrainSupported = input.provider === "maptiler"
39
+
40
+ const engine: MapEngine = {
41
+ native: map,
42
+
43
+ setView: (center, zoom) => map.jumpTo({ center, zoom }),
44
+
45
+ setStyle: (style, dark) => map.setStyle(resolveStyle(style, dark)),
46
+
47
+ setMarks: (marks) => {
48
+ clear()
49
+ clear = buildMapLibreMarks(sdk, map, marks, input)
50
+ },
51
+
52
+ setTerrain: (on) => {
53
+ if (!terrainSupported) return
54
+ if (on) map.enableTerrain?.()
55
+ else map.disableTerrain?.()
56
+ },
57
+
58
+ setProjection: (projection) => {
59
+ if (!terrainSupported) return
60
+ map.setProjection?.(projection, { persist: true })
61
+ },
62
+
63
+ resize: () => map.resize?.(),
64
+
65
+ destroy: () => {
66
+ clear()
67
+ map.remove?.()
68
+ },
69
+
70
+ onReady: (callback) => {
71
+ if (ready) callback(engine)
72
+ else readyCallbacks.push(callback)
73
+ },
74
+ }
75
+
76
+ // Premier `load` **ou** `ready` : le second attend la fin de l'installation des
77
+ // contrôles du SDK, il peut donc être retardé.
78
+ const markReady = () => {
79
+ if (ready) return
80
+ ready = true
81
+ const callbacks = readyCallbacks
82
+ readyCallbacks = []
83
+ for (const callback of callbacks) callback(engine)
84
+ }
85
+
86
+ map.on("load", markReady)
87
+ map.on("ready", markReady)
88
+ map.on("error", (event: unknown) => input.error((event as any)?.error ?? event))
89
+
90
+ engine.setMarks(input.marks)
91
+
92
+ return engine
93
+ }
@@ -0,0 +1,179 @@
1
+ // Tests unitaires du clavier numérique : disposition des touches et règles de saisie.
2
+ import { describe, expect, it } from "bun:test"
3
+ import {
4
+ applyDigitOrder,
5
+ decimalCount,
6
+ digitCount,
7
+ keypadDigits,
8
+ keypadLayout,
9
+ pressKey,
10
+ pressKeys,
11
+ shuffledDigits,
12
+ } from "./numericKeyboard"
13
+
14
+ describe("keypadLayout", () => {
15
+ it("rend 12 touches, 1-9 puis 0 et l'effacement", () => {
16
+ const keys = keypadLayout()
17
+ expect(keys).toHaveLength(12)
18
+ expect(keys.slice(0, 9).map((k) => k.key)).toEqual([
19
+ "1",
20
+ "2",
21
+ "3",
22
+ "4",
23
+ "5",
24
+ "6",
25
+ "7",
26
+ "8",
27
+ "9",
28
+ ])
29
+ expect(keys[9]!.kind).toBe("empty")
30
+ expect(keys[10]!.key).toBe("0")
31
+ expect(keys[11]).toMatchObject({ key: "backspace", kind: "backspace", label: "⌫" })
32
+ })
33
+
34
+ it("met « C » dans la case libre en mode numeric + clearable", () => {
35
+ const keys = keypadLayout({ clearable: true })
36
+ expect(keys[9]).toMatchObject({ key: "clear", label: "C", kind: "clear" })
37
+ })
38
+
39
+ it("met le séparateur décimal en mode decimal (et jamais de C)", () => {
40
+ const keys = keypadLayout({ mode: "decimal", clearable: true })
41
+ expect(keys[9]).toMatchObject({ key: "separator", label: ",", kind: "separator" })
42
+ expect(keys.some((k) => k.kind === "clear")).toBe(false)
43
+ })
44
+
45
+ it("affiche le séparateur demandé", () => {
46
+ expect(keypadLayout({ mode: "decimal", decimalSeparator: "." })[9]!.label).toBe(".")
47
+ expect(keypadLayout({ mode: "decimal", decimalSeparator: "." })[9]!.ariaLabel).toContain(".")
48
+ })
49
+ })
50
+
51
+ describe("compteurs", () => {
52
+ it("compte les chiffres, séparateur exclu", () => {
53
+ expect(digitCount("12,50")).toBe(4)
54
+ expect(digitCount("")).toBe(0)
55
+ })
56
+
57
+ it("compte les décimales saisies", () => {
58
+ expect(decimalCount("12,5")).toBe(1)
59
+ expect(decimalCount("12,")).toBe(0)
60
+ expect(decimalCount("12")).toBe(-1)
61
+ })
62
+ })
63
+
64
+ describe("pressKey — chiffres", () => {
65
+ it("ajoute les chiffres bout à bout", () => {
66
+ expect(pressKeys("", ["1", "2", "3"])).toBe("123")
67
+ })
68
+
69
+ it("conserve les zéros de tête en mode numeric (code, téléphone)", () => {
70
+ expect(pressKeys("", ["0", "4", "0", "6"])).toBe("0406")
71
+ })
72
+
73
+ it("remplace le « 0 » seul en mode decimal (montant)", () => {
74
+ expect(pressKeys("", ["0", "5"], { mode: "decimal" })).toBe("5")
75
+ expect(pressKeys("", ["0", "5"], { mode: "numeric" })).toBe("05")
76
+ // … mais pas derrière le séparateur : « 0,5 » reste « 0,5 »
77
+ expect(pressKeys("0,", ["5"], { mode: "decimal" })).toBe("0,5")
78
+ })
79
+
80
+ it("respecte maxLength (en chiffres), quel que soit le séparateur", () => {
81
+ expect(pressKeys("", ["1", "2", "3", "4"], { maxLength: 3 })).toBe("123")
82
+ expect(pressKeys("12", ["3"], { maxLength: 3, mode: "decimal" })).toBe("123")
83
+ // Le séparateur ne compte pas : « 12,5 » = 3 chiffres, le 4e est refusé si maxLength = 3
84
+ expect(pressKeys("12,5", ["9"], { maxLength: 3, mode: "decimal" })).toBe("12,5")
85
+ expect(pressKeys("12,5", ["9"], { maxLength: 4, mode: "decimal" })).toBe("12,59")
86
+ })
87
+
88
+ it("ignore les touches inconnues", () => {
89
+ expect(pressKey("12", "abc")).toBe("12")
90
+ })
91
+ })
92
+
93
+ describe("pressKey — séparateur, décimales", () => {
94
+ it("n'accepte le séparateur qu'une fois, jamais en mode numeric", () => {
95
+ expect(pressKeys("", ["separator", "separator"], { mode: "decimal" })).toBe("0,")
96
+ expect(pressKeys("12", ["separator"], { mode: "decimal" })).toBe("12,")
97
+ expect(pressKeys("12", ["separator"], { mode: "numeric" })).toBe("12")
98
+ })
99
+
100
+ it("pose « 0, » quand le champ est vide", () => {
101
+ expect(pressKey("", "separator", { mode: "decimal" })).toBe("0,")
102
+ })
103
+
104
+ it("borne les décimales avec maxDecimals", () => {
105
+ expect(pressKeys("12,", ["5", "0", "9"], { mode: "decimal", maxDecimals: 2 })).toBe("12,50")
106
+ expect(pressKey("12,5", "separator", { mode: "decimal", maxDecimals: 0 })).toBe("12,5")
107
+ })
108
+
109
+ it("suit le séparateur demandé", () => {
110
+ expect(pressKeys("", ["1", "separator", "5"], { mode: "decimal", decimalSeparator: "." })).toBe(
111
+ "1.5",
112
+ )
113
+ })
114
+ })
115
+
116
+ describe("pressKey — effacement", () => {
117
+ it("retire le dernier caractère, puis vide", () => {
118
+ expect(pressKeys("123", ["backspace", "backspace"])).toBe("1")
119
+ expect(pressKey("1", "backspace")).toBe("")
120
+ expect(pressKey("", "backspace")).toBe("")
121
+ })
122
+
123
+ it("efface tout avec clear", () => {
124
+ expect(pressKey("12,50", "clear")).toBe("")
125
+ })
126
+
127
+ it("permet de repartir après un effacement complet", () => {
128
+ expect(pressKeys("123", ["clear", "7"])).toBe("7")
129
+ })
130
+ })
131
+
132
+ describe("disposition aléatoire", () => {
133
+ /** Générateur congruentiel linéaire : une suite reproductible pour les tests */
134
+ const seeded = (seed: number) => {
135
+ let state = seed
136
+ return () => {
137
+ state = (state * 1664525 + 1013904223) % 4294967296
138
+ return state / 4294967296
139
+ }
140
+ }
141
+
142
+ it("mélange les dix chiffres, sans perte ni doublon", () => {
143
+ const order = shuffledDigits()
144
+ expect([...order].sort()).toEqual([...keypadDigits].sort())
145
+ expect(new Set(order).size).toBe(10)
146
+ })
147
+
148
+ it("est reproductible pour une graine donnée", () => {
149
+ expect(shuffledDigits(seeded(7))).toEqual(shuffledDigits(seeded(7)))
150
+ })
151
+
152
+ it("ne retombe pas sur l'ordre canonique avec un vrai aléa", () => {
153
+ const canonical = keypadDigits.join("")
154
+ const identical = Array.from({ length: 100 }, () => shuffledDigits().join("")).filter(
155
+ (order) => order === canonical,
156
+ )
157
+ expect(identical).toHaveLength(0)
158
+ })
159
+
160
+ it("répartit les chiffres dans les cellules et laisse les touches d'édition", () => {
161
+ const base = keypadLayout({ mode: "decimal" })
162
+ const order = ["9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]
163
+ const shuffled = applyDigitOrder(base, order)
164
+
165
+ expect(shuffled[0]).toMatchObject({ key: "9", kind: "digit" })
166
+ expect(shuffled[8]).toMatchObject({ key: "1", kind: "digit" })
167
+ expect(shuffled[10]).toMatchObject({ key: "0", kind: "digit" })
168
+ // Le séparateur (case 9) et l'effacement (case 11) ne bougent pas
169
+ expect(shuffled[9]).toMatchObject({ key: "separator", kind: "separator" })
170
+ expect(shuffled[11]).toMatchObject({ key: "backspace", kind: "backspace" })
171
+ })
172
+
173
+ it("garde dix touches chiffre et les touches spéciales en place (clearable)", () => {
174
+ const shuffled = applyDigitOrder(keypadLayout({ clearable: true }), shuffledDigits(seeded(3)))
175
+ expect(shuffled.filter((k) => k.kind === "digit")).toHaveLength(10)
176
+ expect(shuffled[9]!.kind).toBe("clear")
177
+ expect(shuffled[11]!.kind).toBe("backspace")
178
+ })
179
+ })
@@ -0,0 +1,166 @@
1
+ // numericKeyboard — clavier numérique (pavé à l'écran) : disposition des touches et règles de
2
+ // saisie (chiffres, séparateur décimal, effacement, longueur maximale, zéros de tête).
3
+ //
4
+ // Pur (aucun DOM) → testable hors navigateur, comme `lib/pagePadding.ts`. La valeur manipulée
5
+ // est une **chaîne** : c'est ce qui conserve les zéros de tête d'un code (0406) et évite les
6
+ // arrondis flottants d'un montant.
7
+
8
+ /** Nature d'une touche — sert au style et à l'action */
9
+ export type KeypadKeyKind = "digit" | "separator" | "backspace" | "clear" | "empty"
10
+
11
+ export interface KeypadKey {
12
+ /** Identifiant de la touche : `"0"`–`"9"`, `"separator"`, `"backspace"`, `"clear"`, `""` */
13
+ key: string
14
+ kind: KeypadKeyKind
15
+ /** Texte affiché (le séparateur affiche celui de la locale) */
16
+ label: string
17
+ /** Libellé accessible quand la touche n'est pas un chiffre */
18
+ ariaLabel?: string
19
+ }
20
+
21
+ export type KeypadMode = "numeric" | "decimal"
22
+
23
+ export interface KeypadOptions {
24
+ /** `numeric` (défaut : code, téléphone) ou `decimal` (montants : touche séparateur) */
25
+ mode?: KeypadMode
26
+ /** Ajoute une touche « tout effacer » si une case est libre (`numeric`) */
27
+ clearable?: boolean
28
+ /** Séparateur décimal affiché — `,` par défaut (français), `.` en anglais */
29
+ decimalSeparator?: string
30
+ }
31
+
32
+ /**
33
+ * Les 12 touches du pavé (3 × 4). En mode `numeric` la case en bas à gauche est libre —
34
+ * elle accueille la touche « C » quand `clearable` est demandé ; en mode `decimal` elle
35
+ * porte le séparateur, il n'y a donc pas de place pour « C » (voir `clear()`).
36
+ */
37
+ export function keypadLayout(options: KeypadOptions = {}): KeypadKey[] {
38
+ const separator = (options.decimalSeparator ?? ",").slice(0, 1) || ","
39
+ const mode = options.mode === "decimal" ? "decimal" : "numeric"
40
+
41
+ const digit = (value: string): KeypadKey => ({ key: value, kind: "digit", label: value })
42
+
43
+ return [
44
+ digit("1"),
45
+ digit("2"),
46
+ digit("3"),
47
+ digit("4"),
48
+ digit("5"),
49
+ digit("6"),
50
+ digit("7"),
51
+ digit("8"),
52
+ digit("9"),
53
+ mode === "decimal"
54
+ ? { key: "separator", kind: "separator", label: separator, ariaLabel: `Séparateur décimal (${separator})` }
55
+ : options.clearable
56
+ ? { key: "clear", kind: "clear", label: "C", ariaLabel: "Tout effacer" }
57
+ : { key: "", kind: "empty", label: "" },
58
+ digit("0"),
59
+ { key: "backspace", kind: "backspace", label: "⌫", ariaLabel: "Effacer le dernier chiffre" },
60
+ ]
61
+ }
62
+
63
+ /** Les dix chiffres d'une disposition de pavé, dans l'ordre canonique */
64
+ export const keypadDigits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] as const
65
+
66
+ /**
67
+ * Une permutation aléatoire de `0`-`9` (Fisher–Yates), pour un pavé à **disposition
68
+ * aléatoire** (anti-observation : un code tapé sous les yeux d'un tiers).
69
+ *
70
+ * `rng` est injectable (tests, graine) ; par défaut `Math.random`. Le mélange porte sur les
71
+ * chiffres **entre eux** : c'est `applyDigitOrder` qui les répartit ensuite dans les cellules,
72
+ * en laissant les touches d'édition (séparateur, `C`, `⌫`) à leur place.
73
+ */
74
+ export function shuffledDigits(rng: () => number = Math.random): string[] {
75
+ const digits = [...keypadDigits]
76
+ for (let i = digits.length - 1; i > 0; i--) {
77
+ const j = Math.floor(rng() * (i + 1))
78
+ const swap = digits[i]!
79
+ digits[i] = digits[j]!
80
+ digits[j] = swap
81
+ }
82
+ return digits
83
+ }
84
+
85
+ /**
86
+ * Répartit des chiffres (`order`, une permutation de `0`-`9`) dans les cellules « chiffre »
87
+ * d'une disposition, les autres touches restant à leur place. Si `order` est plus courte que le
88
+ * nombre de cellules, les cellules surnuméraires gardent leur chiffre d'origine.
89
+ */
90
+ export function applyDigitOrder(keys: KeypadKey[], order: readonly string[]): KeypadKey[] {
91
+ let index = 0
92
+ return keys.map((key) => {
93
+ if (key.kind !== "digit") return key
94
+ const value = order[index++]
95
+ return value ? { ...key, key: value, label: value } : key
96
+ })
97
+ }
98
+
99
+ export interface KeypadPressOptions extends KeypadOptions {
100
+ /** Longueur maximale, en **chiffres** (le séparateur ne compte pas) — 0 = illimité */
101
+ maxLength?: number
102
+ /** Nombre maximal de décimales après le séparateur — 0 = illimité */
103
+ maxDecimals?: number
104
+ /** Interdit un second séparateur (défaut) — `false` pour l'autoriser ? Jamais utile. */
105
+ allowMultipleSeparators?: boolean
106
+ }
107
+
108
+ /** Nombre de chiffres d'une valeur de pavé (séparateur exclu) */
109
+ export const digitCount = (value: string): number => (value.match(/[0-9]/g) ?? []).length
110
+
111
+ /** Nombre de décimales saisies après le séparateur (`-1` s'il n'y en a pas) */
112
+ export const decimalCount = (value: string, separator = ","): number => {
113
+ const index = value.indexOf(separator)
114
+ return index === -1 ? -1 : digitCount(value.slice(index + 1))
115
+ }
116
+
117
+ /**
118
+ * Applique une touche à une valeur et renvoie la nouvelle valeur.
119
+ *
120
+ * - `backspace` retire le dernier caractère, `clear` vide la valeur ;
121
+ * - un chiffre au-delà de `maxLength` (en chiffres) est ignoré ;
122
+ * - le séparateur s'insère au plus une fois, et « 0, » est posé si le champ est vide ;
123
+ * - `maxDecimals` borne les décimales (les chiffres surnuméraires sont ignorés) ;
124
+ * - en mode `decimal` (montants), un « 0 » seul est **remplacé** par le chiffre suivant
125
+ * (saisie façon calculatrice) ; en mode `numeric` (code, téléphone) il est **conservé**,
126
+ * pour ne pas perdre les zéros de tête.
127
+ */
128
+ export function pressKey(
129
+ value: string,
130
+ key: string,
131
+ options: KeypadPressOptions = {},
132
+ ): string {
133
+ const current = typeof value === "string" ? value : ""
134
+ const separator = (options.decimalSeparator ?? ",").slice(0, 1) || ","
135
+ const mode = options.mode === "decimal" ? "decimal" : "numeric"
136
+ const maxDigits = typeof options.maxLength === "number" && options.maxLength > 0 ? options.maxLength : Infinity
137
+ const maxDecimals =
138
+ typeof options.maxDecimals === "number" && options.maxDecimals >= 0 ? options.maxDecimals : Infinity
139
+
140
+ if (key === "backspace") return current.slice(0, -1)
141
+ if (key === "clear") return ""
142
+
143
+ if (key === "separator") {
144
+ if (mode !== "decimal" || maxDecimals === 0) return current
145
+ if (current.includes(separator)) return current
146
+ if (!current) return `0${separator}`
147
+ return current.endsWith(separator) ? current : `${current}${separator}`
148
+ }
149
+
150
+ if (!/^[0-9]$/.test(key)) return current
151
+
152
+ // Décimales déjà complètes → le chiffre est ignoré
153
+ const decimals = decimalCount(current, separator)
154
+ if (decimals >= 0 && decimals >= maxDecimals) return current
155
+ if (digitCount(current) >= maxDigits) return current
156
+
157
+ // Calculatrice : « 0 » seul est remplacé (montants), sauf derrière le séparateur (« 0,5 »)
158
+ if (mode === "decimal" && current === "0") return key
159
+
160
+ return `${current}${key}`
161
+ }
162
+
163
+ /** Applique une suite de touches (utile aux tests et à une saisie programmée) */
164
+ export function pressKeys(value: string, keys: string[], options: KeypadPressOptions = {}): string {
165
+ return keys.reduce((acc, key) => pressKey(acc, key, options), value)
166
+ }