@mostajs/schema-form 0.1.0 → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog — @mostajs/schema-form
2
+
3
+ ## 0.2.0 — 17/08/2026
4
+
5
+ **Version incrémentée pour trois capacités qui existaient déjà en local sans avoir été publiées.**
6
+
7
+ `repeaterScript`, `affichageMontant` et `centimesDepuis` avaient été ajoutés à la 0.1.0 **sans
8
+ incrémenter la version**. Conséquence, constatée par une vérification en dossier vierge : la 0.1.0
9
+ du registre ne les exportait pas, et `@mostajs/carte-restaurant-ui` — qui importe `repeaterScript` —
10
+ **s'installait sans erreur puis cassait à l'import**.
11
+
12
+ C'est le défaut le plus insidieux du versionnement : deux paquets portant le même numéro et un
13
+ contenu différent. Rien ne le signale tant qu'on développe dans le dépôt, où le module local masque
14
+ le publié.
15
+
16
+ **Ajouté — un essai qui fige la surface publique** : toute modification de la liste des exports fait
17
+ échouer la suite avec « INCRÉMENTER la version, mettre à jour llms.txt et README AVANT de publier ».
18
+
19
+ **Rappel des trois capacités concernées**
20
+ - `repeaterScript()` — le bouton « + Ajouter » d'un repeater ne faisait **rien** sans script ; un
21
+ `<template class="sf-tpl">` est désormais rendu même sur liste vide.
22
+ - `affichageMontant` / `centimesDepuis` — saisie d'un montant en unité courante, stockage en
23
+ **entiers** de la plus petite unité. Jamais de flottant sur de l'argent.
24
+
25
+ `CHANGELOG.md` ajouté à `files`.
package/llms.txt CHANGED
@@ -52,3 +52,15 @@ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `nam
52
52
  ## RÉFÉRENCES
53
53
  docs/00-CARTOGRAPHIE-ET-PROPOSITION.md · examples/demo.html · examples/qatraxflow-recette.schema.js.
54
54
  Compose (à terme) `@mostajs/ui` pour une variante React. Voisin : `@mostajs/settings`, `@mostajs/crud-ui`.
55
+
56
+ ## SURFACE PUBLIQUE (13 membres, figée par un essai — v0.2.0)
57
+ SF_THEME · escapeHtml · getPath · setPath · label · moduleInfo · moduleName ·
58
+ renderForm · renderField · readValues · **repeaterScript** · **affichageMontant** · **centimesDepuis**
59
+
60
+ - repeaterScript() — SANS lui, le bouton « + Ajouter » d'un repeater ne fait RIEN. Le gabarit
61
+ `<template class="sf-tpl">` est rendu même sur liste vide.
62
+ - affichageMontant / centimesDepuis — l'utilisateur saisit en unité courante, le stockage est en
63
+ ENTIERS de la plus petite unité. Jamais de flottant sur de l'argent.
64
+
65
+ ⚠️ Ces trois exports existaient en 0.1.0 LOCALE mais PAS dans la 0.1.0 publiée : la version n'avait
66
+ pas été incrémentée. Un consommateur installait un paquet qui s'importait et cassait à l'usage.
package/package.json CHANGED
@@ -1,14 +1,41 @@
1
1
  {
2
2
  "name": "@mostajs/schema-form",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Interface de SAISIE générique pilotée par schéma (zéro-dép) : sections + champs typés (text/textarea/number/boolean/select/color/date/localized/repeater), chemins imbriqués, multilingue AR/FR/EN. Rendu HTML (renderForm) + relecture (readValues). Réutilisable par toute app @mostajs.",
5
5
  "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "type": "module",
8
8
  "main": "src/index.js",
9
- "exports": { ".": "./src/index.js" },
10
- "files": ["src", "README.md", "llms.txt", "LICENSE"],
11
- "scripts": { "test": "bash test-scripts/run-tests.sh" },
12
- "devDependencies": { "@mostajs/mjs-unit": "^0.3.1" },
13
- "keywords": ["form", "schema-form", "saisie", "data-entry", "zero-dependency", "localized", "repeater", "mostajs"]
14
- }
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "llms.txt",
16
+ "LICENSE",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "devDependencies": {
20
+ "@mostajs/mjs-unit": "^0.3.1"
21
+ },
22
+ "keywords": [
23
+ "form",
24
+ "schema-form",
25
+ "saisie",
26
+ "data-entry",
27
+ "zero-dependency",
28
+ "localized",
29
+ "repeater",
30
+ "mostajs"
31
+ ],
32
+ "mostajs": {
33
+ "niveau": "N2",
34
+ "nature": "compose",
35
+ "reutilisabilite": "multi-domaine",
36
+ "depend_de": []
37
+ },
38
+ "scripts": {
39
+ "test": "bash test-scripts/run-tests.sh"
40
+ }
41
+ }
package/src/index.js CHANGED
@@ -51,6 +51,16 @@ function renderControl(f, value, name) {
51
51
  return `<label style="display:inline-flex;gap:.4rem;align-items:center"><input type="checkbox" name="${name}" value="true"${value ? ' checked' : ''}> ${escapeHtml(label(f.help) || 'Oui')}</label>`;
52
52
  case 'select':
53
53
  return `<select name="${name}"${req} style="${inputStyle}">${(f.options || []).map((o) => `<option value="${escapeHtml(o.value)}"${String(value) === String(o.value) ? ' selected' : ''}>${escapeHtml(label(o.label ?? o.value))}</option>`).join('')}</select>`;
54
+ case 'montant': {
55
+ // Un texte, PAS un `number` : le contrôle numérique du navigateur rend un flottant, et un
56
+ // flottant n'est pas un montant. La conversion en unités mineures se fait à la relecture,
57
+ // par lecture de la CHAÎNE (voir centimesDepuis).
58
+ const dec = f.decimales ?? 2;
59
+ const aff = value === undefined || value === null || value === '' ? '' : affichageMontant(value, dec);
60
+ return `<div style="display:flex;gap:.4rem;align-items:center">
61
+ <input type="text" inputmode="decimal" name="${name}"${req} value="${escapeHtml(aff)}" placeholder="${escapeHtml(label(f.placeholder) || (dec ? '0,00' : '0'))}" style="${inputStyle}">
62
+ ${f.devise ? `<span style="opacity:.6;font-weight:600">${escapeHtml(f.devise)}</span>` : ''}</div>`;
63
+ }
54
64
  case 'color':
55
65
  return `<input type="color" name="${name}" value="${escapeHtml(value || '#000000')}" style="height:2.2rem;width:3rem;border:1px solid ${SF_THEME.line};border-radius:.4rem">`;
56
66
  case 'date':
@@ -63,7 +73,11 @@ function renderControl(f, value, name) {
63
73
  case 'repeater': {
64
74
  const arr = Array.isArray(value) ? value : [];
65
75
  const items = arr.map((item, i) => renderRepeaterItem(f, item, `${name}[${i}]`, i)).join('');
66
- return `<div class="sf-repeater" data-name="${name}">${items}<button type="button" class="sf-add" data-name="${name}" style="margin-top:.4rem;background:${SF_THEME.blue};color:#fff;border:0;border-radius:.4rem;padding:.35rem .8rem;cursor:pointer">${escapeHtml(label(f.addLabel) || '+ Ajouter')}</button></div>`;
76
+ // Un GABARIT accompagne toujours la liste, même vide : sans lui, « + Ajouter » n'aurait
77
+ // rien à cloner, et une liste qu'on ne peut pas allonger n'est pas une liste — c'est un
78
+ // formulaire figé qui en a l'air.
79
+ const gabarit = `<template class="sf-tpl">${renderRepeaterItem(f, {}, `${name}[__i__]`, 0)}</template>`;
80
+ return `<div class="sf-repeater" data-name="${name}">${items}${gabarit}<button type="button" class="sf-add" style="margin-top:.4rem;background:${SF_THEME.blue};color:#fff;border:0;border-radius:.4rem;padding:.35rem .8rem;cursor:pointer">${escapeHtml(label(f.addLabel) || '+ Ajouter')}</button></div>`;
67
81
  }
68
82
  default: // text
69
83
  return `<input type="text" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}" style="${inputStyle}">`;
@@ -78,6 +92,44 @@ function renderRepeaterItem(f, item, prefix, i) {
78
92
  }
79
93
 
80
94
  /** Rend un champ complet (label + contrôle). `name` = attribut name/chemin. */
95
+ /**
96
+ * Une saisie textuelle → un ENTIER d'unités mineures, **sans jamais passer par un flottant**.
97
+ *
98
+ * `Math.round(4.475 * 100)` rend **447**, pas 448 : la multiplication flottante a déjà perdu la
99
+ * valeur avant l'arrondi. Un centime de moins sur une charge mensuelle, c'est invisible — et c'est
100
+ * exactement pour cela que c'est grave. On lit donc la CHAÎNE, chiffre par chiffre.
101
+ *
102
+ * `''` → `undefined` : une saisie vide est ABSENTE, jamais zéro.
103
+ * Une saisie illisible → `null` : elle ne vaut pas zéro non plus.
104
+ * Au-delà de `decimales`, l'arrondi est commercial (demi au-dessus), calculé sur la chaîne.
105
+ */
106
+ export function centimesDepuis(saisie, decimales = 2) {
107
+ const t = String(saisie ?? '').trim().replace(/[\s\u00a0\u202f]/g, '').replace(',', '.');
108
+ if (t === '') return undefined;
109
+ const m = /^(-?)(\d*)(?:\.(\d*))?$/.exec(t);
110
+ if (!m || (!m[2] && !m[3])) return null;
111
+ const signe = m[1] === '-' ? -1 : 1;
112
+ const entier = m[2] || '0';
113
+ const frac = m[3] || '';
114
+ const gardees = frac.slice(0, decimales).padEnd(decimales, '0');
115
+ const suivant = frac.charCodeAt(decimales) - 48;
116
+ let n = Number(`${entier}${gardees}`);
117
+ if (suivant >= 5 && suivant <= 9) n += 1; // demi au-dessus, en entier
118
+ return signe * n;
119
+ }
120
+
121
+ /** L'inverse, pour réafficher : `4565` → `« 45,65 »`. Affichage seulement ; rien n'en repart. */
122
+ export function affichageMontant(centimes, decimales = 2) {
123
+ if (centimes === undefined || centimes === null) return '';
124
+ const n = Number(centimes);
125
+ if (!Number.isFinite(n)) return '';
126
+ const signe = n < 0 ? '-' : '';
127
+ const brut = String(Math.abs(Math.trunc(n))).padStart(decimales + 1, '0');
128
+ return decimales === 0
129
+ ? `${signe}${brut}`
130
+ : `${signe}${brut.slice(0, -decimales)},${brut.slice(-decimales)}`;
131
+ }
132
+
81
133
  export function renderField(f, value, name = f.path, locale = 'fr') {
82
134
  if (f.type === 'boolean') return `<div class="sf-field" style="margin-bottom:.8rem">${renderControl(f, value, name)}${f.help ? '' : ''}<div style="font-size:.85rem;color:${SF_THEME.ink};margin-top:.2rem;font-weight:600">${escapeHtml(label(f.label, locale))}</div></div>`;
83
135
  return `<div class="sf-field" style="margin-bottom:.9rem">
@@ -116,9 +168,60 @@ function coerce(obj, fields) {
116
168
  for (const f of fields) {
117
169
  const v = getPath(obj, f.path);
118
170
  if (f.type === 'boolean') setPath(obj, f.path, v === true || v === 'true' || v === 'on');
171
+ else if (f.type === 'montant') setPath(obj, f.path, centimesDepuis(v, f.decimales ?? 2));
119
172
  else if (f.type === 'number' && v !== undefined && v !== '') setPath(obj, f.path, Number(v));
173
+ else if (f.type === 'repeater' && Array.isArray(v)) {
174
+ // La coercition DESCEND dans les items. Sans cela, une quantité saisie dans une liste
175
+ // revenait en CHAÎNE (« 40 ») là où la même quantité, hors liste, revenait en nombre :
176
+ // deux comportements pour un même type, et un `Number.isInteger()` qui échoue au hasard.
177
+ for (const item of v) coerce(item, (f.itemFields || []).map((sf) => ({ ...sf, path: sf.path || sf.name })));
178
+ }
120
179
  }
121
180
  return obj;
122
181
  }
123
182
 
183
+ /**
184
+ * Le comportement des listes extensibles : ajouter, retirer, renuméroter.
185
+ *
186
+ * **Sans lui, le bouton « + Ajouter » ne fait rien** — la liste a l'air extensible et ne l'est pas.
187
+ * Le rendre ici plutôt que dans chaque application évite que chacune le réécrive, mal.
188
+ *
189
+ * Autonome, sans dépendance, à poser une fois dans la page. Le formulaire reste utilisable sans
190
+ * lui : les lignes déjà saisies s'affichent et se soumettent.
191
+ */
192
+ export function repeaterScript() {
193
+ return `<script>
194
+ document.addEventListener('click', (e) => {
195
+ const add = e.target.closest('.sf-add');
196
+ if (add) {
197
+ const zone = add.closest('.sf-repeater');
198
+ const tpl = zone.querySelector('.sf-tpl');
199
+ if (!tpl) return;
200
+ const i = zone.querySelectorAll('.sf-item').length;
201
+ const html = tpl.innerHTML.replace(/__i__/g, String(i));
202
+ const bloc = document.createElement('div');
203
+ bloc.innerHTML = html;
204
+ const item = bloc.firstElementChild;
205
+ item.querySelector('legend') && (item.querySelector('legend').textContent = '#' + (i + 1));
206
+ zone.insertBefore(item, tpl);
207
+ const premier = item.querySelector('input, select, textarea');
208
+ premier && premier.focus();
209
+ return;
210
+ }
211
+ const rm = e.target.closest('.sf-remove');
212
+ if (rm) {
213
+ const zone = rm.closest('.sf-repeater');
214
+ rm.closest('.sf-item').remove();
215
+ // Renuméroter : les index doivent rester contigus, sinon la relecture rend un tableau troué.
216
+ zone.querySelectorAll('.sf-item').forEach((it, k) => {
217
+ it.querySelector('legend') && (it.querySelector('legend').textContent = '#' + (k + 1));
218
+ it.querySelectorAll('[name]').forEach((ch) => {
219
+ ch.name = ch.name.replace(/\\[\\d+\\]/, '[' + k + ']');
220
+ });
221
+ });
222
+ }
223
+ });
224
+ </script>`;
225
+ }
226
+
124
227
  export const moduleName = moduleInfo.name;