@mostajs/schema-form 0.3.0 → 0.4.1

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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog — @mostajs/schema-form
2
2
 
3
+ ## 0.4.1 — 2026-10-09
4
+
5
+ **Corrigé** (audit du 09/10/2026) : `name`, `opts.id`, `opts.action`, `min`, `max`, `step`, `rows` sont échappés —
6
+ une valeur d'attribut ne peut plus fermer la balise. Essai T-STY-5.
7
+
8
+ ## 0.4.0 — 08/10/2026
9
+
10
+ **L'habillage devient malléable** (demande du fondateur) : un style imposé en ligne ne se surchargeait qu'à
11
+ coups de `!important`.
12
+
13
+ **Ajouté**
14
+ - `opts.style` de `renderForm` / `renderField` : `'inline'` (défaut, rendu inchangé) ou `'classes'` (AUCUN
15
+ `style="…"` ; la page habille).
16
+ - Classes stables `sf-*` sur chaque élément, **dans les deux modes** : `sf-form`, `sf-section`, `sf-legend`,
17
+ `sf-field`, `sf-label`, `sf-req`, `sf-help`, `sf-input`, `sf-textarea`, `sf-check`, `sf-check-label`,
18
+ `sf-montant`, `sf-devise`, `sf-localized`, `sf-loc`, `sf-loc-tag`, `sf-couleur`, `sf-item`, `sf-item-legend`,
19
+ `sf-add`, `sf-remove`, `sf-submit`, `sf-title`.
20
+ - `SF_CSS` : la feuille du mode `classes`, sur les jetons de Palette Studio (`--brand`, `--ink`, `--text`,
21
+ `--line`, `--field-border`, `--surface`, `--text-on-brand`, `--error-text`), avec replis ; `--sf-radius`
22
+ et les autres `--sf-*` se surchargent.
23
+ - `opts.classe` : une classe ajoutée à `sf-form`.
24
+
25
+ **Corrigé**
26
+ - Les libellés écrits par le module (« Oui », « Enregistrer », « + Ajouter », « Retirer ») restaient en
27
+ français dans une page anglaise ou arabe : ils suivent désormais `locale` (fr, en, ar).
28
+ - Un formulaire en `locale: 'ar'` porte `dir="rtl"` ; dans un champ `localized`, chaque saisie garde le sens de SA
29
+ langue (`dir="ltr"` pour fr et en, même dans une page arabe).
30
+ - La devise d'un montant ne se coupe plus (`white-space:nowrap`).
31
+ - `moduleInfo.version` annonçait encore 0.1.0.
32
+
33
+ **Essais** : `test-scripts/style.test.mjs` (T-STY-1 à 4) ; surface publique : `SF_CSS` ajouté.
34
+
35
+
3
36
  ## 0.2.0 — 17/08/2026
4
37
 
5
38
  **Version incrémentée pour trois capacités qui existaient déjà en local sans avoir été publiées.**
package/README.md CHANGED
@@ -37,7 +37,12 @@ form.onsubmit = (e) => { e.preventDefault(); const values = readValues(new FormD
37
37
 
38
38
  ## Types de champs
39
39
  `text` · `textarea` · `number` · `boolean` · `select` · `color` · `date` ·
40
- `localized` (un input par locale, RTL pour `ar`) · `repeater` (liste d'objets, `itemFields`).
40
+ `localized` (un input par locale, chacun dans le sens de sa langue) · `montant` · `fichier` · `repeater` (liste d'objets, `itemFields`).
41
+
42
+ ## Habillage (0.4.0)
43
+ `renderForm(schema, values, { style: 'classes' })` ne pose **aucun style en ligne** : chaque élément porte sa
44
+ classe `sf-*`, et la page habille avec `SF_CSS` (jetons de Palette Studio : `--brand`, `--ink`, `--line`…) ou
45
+ sa propre feuille. Le mode par défaut (`inline`) est inchangé. Les libellés propres au module suivent `locale`.
41
46
 
42
47
  ## Positionnement
43
48
  - `@mostajs/settings` → réglages **clé-valeur plat** (reste le bon choix pour ça).
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @mostajs/schema-form — fiche LLM
2
2
  > Interface de SAISIE générique pilotée par schéma (zéro-dép HTML), réutilisable par toute app.
3
3
 
4
- - Version : 0.1.0 · Licence : AGPL-3.0-or-later · Auteur : Dr Hamid MADANI <drmdh@msn.com>
4
+ - Version : 0.4.0 · Licence : AGPL-3.0-or-later · Auteur : Dr Hamid MADANI <drmdh@msn.com>
5
5
  - Chemin : mostajs/mosta-ui-html-stack/mosta-schema-form · **Niveau** : N2 (composé/UI)
6
6
  - Zéro dépendance runtime. Généralise le SchemaForm de TicketFlow (cas C, règle d'or §0).
7
7
 
@@ -54,6 +54,13 @@ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `nam
54
54
  - ⚠️ `capture="environment"` ouvre l'**appareil photo** sur mobile, pas la galerie.
55
55
  - ⚠️ Les OCTETS ne passent pas par `readValues` : ils se lisent avec `@mostajs/form-data-extract`.
56
56
 
57
+ ## HABILLAGE (0.4.0)
58
+ - `renderForm(schema, values, { style: 'classes' })` : AUCUN style en ligne ; classes `sf-*` sur chaque élément
59
+ (posées aussi en mode `inline`, le défaut). Poser `SF_CSS` dans la page, ou sa propre feuille.
60
+ - `SF_CSS` lit les jetons de Palette Studio (`--brand`, `--ink`/`--text`, `--line`/`--field-border`, `--surface`,
61
+ `--text-on-brand`, `--error-text`) : dans une page de @mostajs/wagon-ui, le formulaire prend le thème du site.
62
+ - Les libellés propres au module (Oui, Enregistrer, + Ajouter, Retirer) suivent `locale` ; `locale: 'ar'` → `dir="rtl"`.
63
+
57
64
  ## PIÈGES
58
65
  - **Zéro-dép, HTML-string** : `renderForm` rend une chaîne ; côté React, injecter ou porter en composants.
59
66
  - `readValues` : Array traité comme liste [name,value] (pas via `.entries()`), FormData via `.entries()`.
@@ -65,8 +72,8 @@ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `nam
65
72
  docs/00-CARTOGRAPHIE-ET-PROPOSITION.md · examples/demo.html · examples/qatraxflow-recette.schema.js.
66
73
  Compose (à terme) `@mostajs/ui` pour une variante React. Voisin : `@mostajs/settings`, `@mostajs/crud-ui`.
67
74
 
68
- ## SURFACE PUBLIQUE (13 membres, figée par un essai — v0.2.0)
69
- SF_THEME · escapeHtml · getPath · setPath · label · moduleInfo · moduleName ·
75
+ ## SURFACE PUBLIQUE (14 membres, figée par un essai — v0.4.0)
76
+ SF_CSS · SF_THEME · escapeHtml · getPath · setPath · label · moduleInfo · moduleName ·
70
77
  renderForm · renderField · readValues · **repeaterScript** · **affichageMontant** · **centimesDepuis**
71
78
 
72
79
  - repeaterScript() — SANS lui, le bouton « + Ajouter » d'un repeater ne fait RIEN. Le gabarit
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mostajs/schema-form",
3
- "version": "0.3.0",
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.",
3
+ "version": "0.4.1",
4
+ "description": "Interface de SAISIE g\u00e9n\u00e9rique pilot\u00e9e par sch\u00e9ma (z\u00e9ro-d\u00e9p) : sections + champs typ\u00e9s (text/textarea/number/boolean/select/color/date/localized/repeater), chemins imbriqu\u00e9s, multilingue AR/FR/EN. Rendu HTML (renderForm) + relecture (readValues). R\u00e9utilisable 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",
@@ -16,6 +16,9 @@
16
16
  "LICENSE",
17
17
  "CHANGELOG.md"
18
18
  ],
19
+ "scripts": {
20
+ "test": "bash test-scripts/run-tests.sh"
21
+ },
19
22
  "devDependencies": {
20
23
  "@mostajs/mjs-unit": "^0.3.1"
21
24
  },
@@ -34,8 +37,5 @@
34
37
  "nature": "compose",
35
38
  "reutilisabilite": "multi-domaine",
36
39
  "depend_de": []
37
- },
38
- "scripts": {
39
- "test": "bash test-scripts/run-tests.sh"
40
40
  }
41
- }
41
+ }
package/src/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * 1er consommateur : MostaQatraxFlow (saisie de recette Condition→Exécution→Comportement→Logs).
10
10
  */
11
11
 
12
- export const moduleInfo = { name: '@mostajs/schema-form', version: '0.1.0' };
12
+ export const moduleInfo = { name: '@mostajs/schema-form', version: '0.4.0' };
13
13
  export const SF_THEME = { blue: '#1E88E5', ink: '#1a2b45', line: '#e3e9f2', bg: '#f7f9fc' };
14
14
  const LOCALES = ['ar', 'fr', 'en'];
15
15
 
@@ -37,18 +37,86 @@ export function setPath(obj, path, value) {
37
37
  }
38
38
 
39
39
  /* ── rendu d'un champ ──────────────────────────────────────────────────────── */
40
+ //
41
+ // DEUX HABILLAGES (0.4.0), et une seule structure :
42
+ // - `inline` (défaut, inchangé depuis 0.1) : chaque élément porte son `style="…"` — le formulaire s'affiche
43
+ // correct n'importe où, sans feuille de style ;
44
+ // - `classes` : AUCUN style en ligne. Chaque élément porte sa classe `sf-*`, et c'est la page qui habille —
45
+ // avec `SF_CSS` (jetons de Palette Studio : --brand, --ink, --line…) ou avec sa propre feuille.
46
+ // Les classes `sf-*` sont posées dans LES DEUX modes : une app peut surcharger le rendu en ligne sans changer
47
+ // de mode. Un style en ligne imposé ne se surcharge, lui, qu'à coups de `!important` : c'est ce qui rendait
48
+ // le module raide (demande du fondateur, 08/10/2026).
40
49
  const inputStyle = `width:100%;padding:.5rem;border:1px solid ${SF_THEME.line};border-radius:.4rem;font:inherit`;
50
+ const STYLES = {
51
+ input: inputStyle,
52
+ textarea: `${inputStyle};font-family:ui-monospace,monospace`,
53
+ check: 'display:inline-flex;gap:.4rem;align-items:center',
54
+ montant: 'display:flex;gap:.4rem;align-items:center',
55
+ devise: 'opacity:.6;font-weight:600;white-space:nowrap;flex:none',
56
+ couleur: `height:2.2rem;width:3rem;border:1px solid ${SF_THEME.line};border-radius:.4rem`,
57
+ localized: 'display:flex;flex-direction:column;gap:.3rem',
58
+ loc: 'display:flex;gap:.4rem;align-items:center',
59
+ locTag: 'width:2rem;font-size:.72rem;opacity:.6;text-transform:uppercase',
60
+ add: `margin-top:.4rem;background:${SF_THEME.blue};color:#fff;border:0;border-radius:.4rem;padding:.35rem .8rem;cursor:pointer`,
61
+ item: `border:1px solid ${SF_THEME.line};border-radius:.5rem;padding:.6rem;margin-bottom:.5rem`,
62
+ itemLegend: 'font-size:.75rem;opacity:.6',
63
+ remove: 'background:#C62828;color:#fff;border:0;border-radius:.4rem;padding:.2rem .6rem;cursor:pointer;font-size:.75rem',
64
+ field: 'margin-bottom:.9rem',
65
+ fieldCheck: 'margin-bottom:.8rem',
66
+ checkLabel: `font-size:.85rem;color:${SF_THEME.ink};margin-top:.2rem;font-weight:600`,
67
+ label: `display:block;font-size:.85rem;font-weight:600;color:${SF_THEME.ink};margin-bottom:.25rem`,
68
+ req: 'color:#C62828',
69
+ help: 'font-size:.75rem;opacity:.6;margin-top:.2rem',
70
+ section: `border:1px solid ${SF_THEME.line};border-radius:.6rem;padding:1rem;margin-bottom:1rem`,
71
+ legend: `padding:0 .5rem;font-weight:700;color:${SF_THEME.blue}`,
72
+ submit: `background:${SF_THEME.blue};color:#fff;border:0;border-radius:.45rem;padding:.6rem 1.2rem;font-weight:600;cursor:pointer`,
73
+ title: `color:${SF_THEME.ink}`,
74
+ };
75
+ /** ` class="sf-…"` toujours ; ` style="…"` seulement en mode `inline`. */
76
+ const at = (mode, cls, cle) => ` class="sf-${cls}"${mode === 'classes' || !STYLES[cle ?? cls] ? '' : ` style="${STYLES[cle ?? cls]}"`}`;
41
77
 
42
- function renderControl(f, value, name) {
78
+ /** Les libellés que le module écrit lui-même, dans la langue du formulaire. */
79
+ const MOTS = {
80
+ oui: { fr: 'Oui', en: 'Yes', ar: 'نعم' },
81
+ ajouter: { fr: '+ Ajouter', en: '+ Add', ar: '+ إضافة' },
82
+ retirer: { fr: 'Retirer', en: 'Remove', ar: 'حذف' },
83
+ enregistrer: { fr: 'Enregistrer', en: 'Save', ar: 'حفظ' },
84
+ };
85
+
86
+ /**
87
+ * La feuille de style du mode `classes`. Elle lit les jetons de Palette Studio (ceux que pose @mostajs/wagon-ui),
88
+ * avec des replis : posée dans une page qui n'en a aucun, elle donne le rendu de toujours. Une app change un
89
+ * jeton (`--sf-radius`, `--brand`…) sans réécrire une règle.
90
+ */
91
+ export const SF_CSS = `.sf-form{--sf-accent:var(--brand,${SF_THEME.blue});--sf-ink:var(--ink,var(--text,${SF_THEME.ink}));--sf-line:var(--field-border,var(--line,${SF_THEME.line}));--sf-radius:.6rem;--sf-fond:var(--surface,#fff);--sf-erreur:var(--error-text,#C62828)}
92
+ .sf-form .sf-field{margin-bottom:.9rem}
93
+ .sf-form .sf-label{display:block;font-size:.85rem;font-weight:600;color:var(--sf-ink);margin-bottom:.25rem}
94
+ .sf-form .sf-req{color:var(--sf-erreur)}
95
+ .sf-form .sf-help{font-size:.75rem;opacity:.65;margin-top:.2rem}
96
+ .sf-form .sf-input,.sf-form .sf-textarea{width:100%;padding:.55rem .7rem;border:1px solid var(--sf-line);border-radius:var(--sf-radius);font:inherit;background:var(--sf-fond);color:var(--sf-ink)}
97
+ .sf-form .sf-input:focus,.sf-form .sf-textarea:focus{outline:2px solid var(--sf-accent);outline-offset:1px}
98
+ .sf-form .sf-check{display:inline-flex;gap:.4rem;align-items:center}
99
+ .sf-form .sf-montant,.sf-form .sf-loc{display:flex;gap:.4rem;align-items:center}
100
+ .sf-form .sf-localized{display:flex;flex-direction:column;gap:.3rem}
101
+ .sf-form .sf-loc-tag{width:2rem;font-size:.72rem;opacity:.6;text-transform:uppercase}
102
+ .sf-form .sf-devise{opacity:.65;font-weight:600;white-space:nowrap;flex:none}
103
+ .sf-form .sf-couleur{height:2.2rem;width:3rem;border:1px solid var(--sf-line);border-radius:var(--sf-radius)}
104
+ .sf-form .sf-section{border:1px solid var(--sf-line);border-radius:var(--sf-radius);padding:1rem;margin-bottom:1rem}
105
+ .sf-form .sf-legend{padding:0 .5rem;font-weight:700;color:var(--sf-ink)}
106
+ .sf-form .sf-item{border:1px solid var(--sf-line);border-radius:var(--sf-radius);padding:.6rem;margin-bottom:.5rem}
107
+ .sf-form .sf-submit,.sf-form .sf-add{background:var(--sf-accent);color:var(--text-on-brand,#fff);border:0;border-radius:999px;padding:.6rem 1.3rem;font-weight:600;cursor:pointer}
108
+ .sf-form .sf-remove{background:transparent;color:var(--sf-erreur);border:1px solid currentColor;border-radius:999px;padding:.2rem .7rem;cursor:pointer;font-size:.75rem}`;
109
+
110
+ function renderControl(f, value, name, mode = 'inline', locale = 'fr') {
43
111
  const req = f.required ? ' required' : '';
44
- const ph = f.placeholder ? ` placeholder="${escapeHtml(label(f.placeholder))}"` : '';
112
+ const ph = f.placeholder ? ` placeholder="${escapeHtml(label(f.placeholder, locale))}"` : '';
45
113
  switch (f.type) {
46
114
  case 'textarea':
47
- return `<textarea name="${name}"${req}${ph} rows="${f.rows || 4}" style="${inputStyle};font-family:ui-monospace,monospace">${escapeHtml(value ?? '')}</textarea>`;
115
+ return `<textarea name="${escapeHtml(name)}"${req}${ph} rows="${escapeHtml(f.rows || 4)}"${at(mode, 'textarea')}>${escapeHtml(value ?? '')}</textarea>`;
48
116
  case 'number':
49
- return `<input type="number" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}${f.step != null ? ` step="${f.step}"` : ''} style="${inputStyle}">`;
117
+ return `<input type="number" name="${escapeHtml(name)}"${req}${ph} value="${escapeHtml(value ?? '')}"${f.min != null ? ` min="${escapeHtml(f.min)}"` : ''}${f.max != null ? ` max="${escapeHtml(f.max)}"` : ''}${f.step != null ? ` step="${escapeHtml(f.step)}"` : ''}${at(mode, 'input')}>`;
50
118
  case 'boolean':
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>`;
119
+ return `<label${at(mode, 'check')}><input type="checkbox" name="${escapeHtml(name)}" value="true"${value ? ' checked' : ''}> ${escapeHtml(label(f.help, locale) || label(MOTS.oui, locale))}</label>`;
52
120
  // ── LE CHAMP FICHIER (0.3.0) ──────────────────────────────────────────
53
121
  //
54
122
  // ⚠️ `capture` ouvre l'APPAREIL PHOTO sur mobile, au lieu de la galerie. C'est ce qu'on veut
@@ -59,53 +127,52 @@ function renderControl(f, value, name) {
59
127
  // décide quel fichier est sélectionné (ce serait une exfiltration). Un formulaire de
60
128
  // modification doit donc afficher la pièce DÉJÀ envoyée à côté du champ, pas dedans.
61
129
  case 'fichier':
62
- return `<input type="file" name="${name}"${req}`
130
+ return `<input type="file" name="${escapeHtml(name)}"${req}`
63
131
  + `${f.accept ? ` accept="${escapeHtml(f.accept)}"` : ''}`
64
132
  + `${f.capture ? ` capture="${escapeHtml(f.capture)}"` : ''}`
65
- + `${f.multiple ? ' multiple' : ''} style="${inputStyle}">`;
133
+ + `${f.multiple ? ' multiple' : ''}${at(mode, 'input')}>`;
66
134
  case 'select':
67
- 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>`;
135
+ return `<select name="${escapeHtml(name)}"${req}${at(mode, 'input')}>${(f.options || []).map((o) => `<option value="${escapeHtml(o.value)}"${String(value) === String(o.value) ? ' selected' : ''}>${escapeHtml(label(o.label ?? o.value, locale))}</option>`).join('')}</select>`;
68
136
  case 'montant': {
69
137
  // Un texte, PAS un `number` : le contrôle numérique du navigateur rend un flottant, et un
70
138
  // flottant n'est pas un montant. La conversion en unités mineures se fait à la relecture,
71
139
  // par lecture de la CHAÎNE (voir centimesDepuis).
72
140
  const dec = f.decimales ?? 2;
73
141
  const aff = value === undefined || value === null || value === '' ? '' : affichageMontant(value, dec);
74
- return `<div style="display:flex;gap:.4rem;align-items:center">
75
- <input type="text" inputmode="decimal" name="${name}"${req} value="${escapeHtml(aff)}" placeholder="${escapeHtml(label(f.placeholder) || (dec ? '0,00' : '0'))}" style="${inputStyle}">
76
- ${f.devise ? `<span style="opacity:.6;font-weight:600">${escapeHtml(f.devise)}</span>` : ''}</div>`;
142
+ return `<div${at(mode, 'montant')}>
143
+ <input type="text" inputmode="decimal" name="${escapeHtml(name)}"${req} value="${escapeHtml(aff)}" placeholder="${escapeHtml(label(f.placeholder, locale) || (dec ? '0,00' : '0'))}"${at(mode, 'input')}>
144
+ ${f.devise ? `<span${at(mode, 'devise')}>${escapeHtml(f.devise)}</span>` : ''}</div>`;
77
145
  }
78
146
  case 'color':
79
- 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">`;
147
+ return `<input type="color" name="${escapeHtml(name)}" value="${escapeHtml(value || '#000000')}"${at(mode, 'couleur')}>`;
80
148
  case 'date':
81
- return `<input type="date" name="${name}"${req} value="${escapeHtml(value ?? '')}" style="${inputStyle}">`;
149
+ return `<input type="date" name="${escapeHtml(name)}"${req} value="${escapeHtml(value ?? '')}"${at(mode, 'input')}>`;
82
150
  case 'localized': {
83
151
  const locs = f.locales || LOCALES;
84
- return `<div style="display:flex;flex-direction:column;gap:.3rem">${locs.map((lc) =>
85
- `<div style="display:flex;gap:.4rem;align-items:center"><span style="width:2rem;font-size:.72rem;opacity:.6;text-transform:uppercase">${lc}</span><input name="${name}.${lc}"${lc === 'ar' ? ' dir="rtl"' : ''} value="${escapeHtml((value && value[lc]) ?? '')}" style="${inputStyle}"></div>`).join('')}</div>`;
152
+ return `<div${at(mode, 'localized')}>${locs.map((lc) =>
153
+ `<div${at(mode, 'loc')}><span${at(mode, 'loc-tag', 'locTag')}>${lc}</span><input name="${escapeHtml(`${name}.${lc}`)}" dir="${lc === 'ar' ? 'rtl' : 'ltr'}" value="${escapeHtml((value && value[lc]) ?? '')}"${at(mode, 'input')}></div>`).join('')}</div>`;
86
154
  }
87
155
  case 'repeater': {
88
156
  const arr = Array.isArray(value) ? value : [];
89
- const items = arr.map((item, i) => renderRepeaterItem(f, item, `${name}[${i}]`, i)).join('');
157
+ const items = arr.map((item, i) => renderRepeaterItem(f, item, `${name}[${i}]`, i, mode, locale)).join('');
90
158
  // Un GABARIT accompagne toujours la liste, même vide : sans lui, « + Ajouter » n'aurait
91
159
  // rien à cloner, et une liste qu'on ne peut pas allonger n'est pas une liste — c'est un
92
160
  // formulaire figé qui en a l'air.
93
- const gabarit = `<template class="sf-tpl">${renderRepeaterItem(f, {}, `${name}[__i__]`, 0)}</template>`;
94
- 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>`;
161
+ const gabarit = `<template class="sf-tpl">${renderRepeaterItem(f, {}, `${name}[__i__]`, 0, mode, locale)}</template>`;
162
+ return `<div class="sf-repeater" data-name="${escapeHtml(name)}">${items}${gabarit}<button type="button"${at(mode, 'add')}>${escapeHtml(label(f.addLabel, locale) || label(MOTS.ajouter, locale))}</button></div>`;
95
163
  }
96
164
  default: // text
97
- return `<input type="text" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}" style="${inputStyle}">`;
165
+ return `<input type="text" name="${escapeHtml(name)}"${req}${ph} value="${escapeHtml(value ?? '')}"${at(mode, 'input')}>`;
98
166
  }
99
167
  }
100
- function renderRepeaterItem(f, item, prefix, i) {
101
- return `<fieldset class="sf-item" style="border:1px solid ${SF_THEME.line};border-radius:.5rem;padding:.6rem;margin-bottom:.5rem">
102
- <legend style="font-size:.75rem;opacity:.6">#${i + 1}</legend>
103
- ${(f.itemFields || []).map((sub) => renderField({ ...sub, path: sub.path || sub.name }, getPath(item, sub.path || sub.name), `${prefix}.${sub.path || sub.name}`)).join('')}
104
- <button type="button" class="sf-remove" style="background:#C62828;color:#fff;border:0;border-radius:.4rem;padding:.2rem .6rem;cursor:pointer;font-size:.75rem">Retirer</button>
168
+ function renderRepeaterItem(f, item, prefix, i, mode = 'inline', locale = 'fr') {
169
+ return `<fieldset${at(mode, 'item')}>
170
+ <legend${at(mode, 'item-legend', 'itemLegend')}>#${i + 1}</legend>
171
+ ${(f.itemFields || []).map((sub) => renderField({ ...sub, path: sub.path || sub.name }, getPath(item, sub.path || sub.name), `${prefix}.${sub.path || sub.name}`, locale, { style: mode })).join('')}
172
+ <button type="button"${at(mode, 'remove')}>${escapeHtml(label(MOTS.retirer, locale))}</button>
105
173
  </fieldset>`;
106
174
  }
107
175
 
108
- /** Rend un champ complet (label + contrôle). `name` = attribut name/chemin. */
109
176
  /**
110
177
  * Une saisie textuelle → un ENTIER d'unités mineures, **sans jamais passer par un flottant**.
111
178
  *
@@ -118,7 +185,7 @@ function renderRepeaterItem(f, item, prefix, i) {
118
185
  * Au-delà de `decimales`, l'arrondi est commercial (demi au-dessus), calculé sur la chaîne.
119
186
  */
120
187
  export function centimesDepuis(saisie, decimales = 2) {
121
- const t = String(saisie ?? '').trim().replace(/[\s\u00a0\u202f]/g, '').replace(',', '.');
188
+ const t = String(saisie ?? '').trim().replace(/[\s  ]/g, '').replace(',', '.');
122
189
  if (t === '') return undefined;
123
190
  const m = /^(-?)(\d*)(?:\.(\d*))?$/.exec(t);
124
191
  if (!m || (!m[2] && !m[3])) return null;
@@ -144,26 +211,32 @@ export function affichageMontant(centimes, decimales = 2) {
144
211
  : `${signe}${brut.slice(0, -decimales)},${brut.slice(-decimales)}`;
145
212
  }
146
213
 
147
- export function renderField(f, value, name = f.path, locale = 'fr') {
148
- 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>`;
149
- return `<div class="sf-field" style="margin-bottom:.9rem">
150
- <label style="display:block;font-size:.85rem;font-weight:600;color:${SF_THEME.ink};margin-bottom:.25rem">${escapeHtml(label(f.label, locale))}${f.required ? ' <span style="color:#C62828">*</span>' : ''}</label>
151
- ${renderControl(f, value, name)}
152
- ${f.help && f.type !== 'boolean' ? `<div style="font-size:.75rem;opacity:.6;margin-top:.2rem">${escapeHtml(label(f.help, locale))}</div>` : ''}
214
+ /** Rend un champ complet (label + contrôle). `name` = attribut name/chemin ; `opts.style` : 'inline' | 'classes'. */
215
+ export function renderField(f, value, name = f.path, locale = 'fr', opts = {}) {
216
+ const mode = opts.style === 'classes' ? 'classes' : 'inline';
217
+ if (f.type === 'boolean') return `<div${at(mode, 'field', 'fieldCheck')}>${renderControl(f, value, name, mode, locale)}<div${at(mode, 'check-label', 'checkLabel')}>${escapeHtml(label(f.label, locale))}</div></div>`;
218
+ return `<div${at(mode, 'field')}>
219
+ <label${at(mode, 'label')}>${escapeHtml(label(f.label, locale))}${f.required ? ` <span${at(mode, 'req')}>*</span>` : ''}</label>
220
+ ${renderControl(f, value, name, mode, locale)}
221
+ ${f.help ? `<div${at(mode, 'help')}>${escapeHtml(label(f.help, locale))}</div>` : ''}
153
222
  </div>`;
154
223
  }
155
224
 
156
- /** Rend un formulaire complet depuis un schéma { title?, sections?[], fields?[] }. */
225
+ /**
226
+ * Rend un formulaire complet depuis un schéma { title?, sections?[], fields?[] }.
227
+ * opts : id, action, locale, submitLabel (null = sans bouton), style ('inline' | 'classes'), classe (ajoutée à sf-form).
228
+ */
157
229
  export function renderForm(schema, values = {}, opts = {}) {
158
230
  const locale = opts.locale || 'fr';
231
+ const mode = opts.style === 'classes' ? 'classes' : 'inline';
159
232
  const sections = schema.sections || [{ fields: schema.fields || [] }];
160
233
  const body = sections.map((sec) => {
161
- const inner = (sec.fields || []).map((f) => renderField(f, getPath(values, f.path), f.path, locale)).join('');
234
+ const inner = (sec.fields || []).map((f) => renderField(f, getPath(values, f.path), f.path, locale, { style: mode })).join('');
162
235
  if (!sec.label && !sec.id) return inner;
163
- return `<fieldset style="border:1px solid ${SF_THEME.line};border-radius:.6rem;padding:1rem;margin-bottom:1rem">
164
- <legend style="padding:0 .5rem;font-weight:700;color:${SF_THEME.blue}">${escapeHtml(label(sec.label, locale) || sec.id)}</legend>${inner}</fieldset>`;
236
+ return `<fieldset${at(mode, 'section')}>
237
+ <legend${at(mode, 'legend')}>${escapeHtml(label(sec.label, locale) || sec.id)}</legend>${inner}</fieldset>`;
165
238
  }).join('');
166
- const submit = opts.submitLabel === null ? '' : `<button type="submit" style="background:${SF_THEME.blue};color:#fff;border:0;border-radius:.45rem;padding:.6rem 1.2rem;font-weight:600;cursor:pointer">${escapeHtml(opts.submitLabel || 'Enregistrer')}</button>`;
239
+ const submit = opts.submitLabel === null ? '' : `<button type="submit"${at(mode, 'submit')}>${escapeHtml(label(opts.submitLabel, locale) || label(MOTS.enregistrer, locale))}</button>`;
167
240
  // ── L'ENCTYPE EST POSÉ TOUT SEUL, ET C'EST LE POINT ───────────────────────
168
241
  //
169
242
  // ⚠️ Un formulaire qui porte un `<input type=file>` SANS `enctype="multipart/form-data"` envoie
@@ -174,8 +247,9 @@ export function renderForm(schema, values = {}, opts = {}) {
174
247
  // On ne le laisse donc pas à la charge de l'appelant : dès qu'un champ `fichier` figure au
175
248
  // schéma — y compris DANS un répéteur — l'attribut est posé.
176
249
  const aUnFichier = JSON.stringify(schema).includes('"fichier"');
177
- return `<form class="sf-form"${opts.id ? ` id="${opts.id}"` : ''}${opts.action ? ` method="post" action="${opts.action}"` : ''}${aUnFichier ? ' enctype="multipart/form-data"' : ''}>
178
- ${schema.title ? `<h2 style="color:${SF_THEME.ink}">${escapeHtml(label(schema.title, locale))}</h2>` : ''}${body}${submit}</form>`;
250
+ const classe = `sf-form${opts.classe ? ` ${escapeHtml(opts.classe)}` : ''}`;
251
+ return `<form class="${classe}"${opts.id ? ` id="${escapeHtml(opts.id)}"` : ''}${opts.action ? ` method="post" action="${escapeHtml(opts.action)}"` : ''}${aUnFichier ? ' enctype="multipart/form-data"' : ''}${locale === 'ar' ? ' dir="rtl"' : ''}>
252
+ ${schema.title ? `<h2${at(mode, 'title')}>${escapeHtml(label(schema.title, locale))}</h2>` : ''}${body}${submit}</form>`;
179
253
  }
180
254
 
181
255
  /* ── relecture des valeurs (FormData → objet imbriqué) ─────────────────────── */