@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
|
@@ -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
|
+
}
|