@mostajs/schema-form 0.2.0 → 0.4.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 +28 -0
- package/README.md +6 -1
- package/llms.txt +22 -3
- package/package.json +6 -6
- package/src/index.js +136 -38
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog — @mostajs/schema-form
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 08/10/2026
|
|
4
|
+
|
|
5
|
+
**L'habillage devient malléable** (demande du fondateur) : un style imposé en ligne ne se surchargeait qu'à
|
|
6
|
+
coups de `!important`.
|
|
7
|
+
|
|
8
|
+
**Ajouté**
|
|
9
|
+
- `opts.style` de `renderForm` / `renderField` : `'inline'` (défaut, rendu inchangé) ou `'classes'` (AUCUN
|
|
10
|
+
`style="…"` ; la page habille).
|
|
11
|
+
- Classes stables `sf-*` sur chaque élément, **dans les deux modes** : `sf-form`, `sf-section`, `sf-legend`,
|
|
12
|
+
`sf-field`, `sf-label`, `sf-req`, `sf-help`, `sf-input`, `sf-textarea`, `sf-check`, `sf-check-label`,
|
|
13
|
+
`sf-montant`, `sf-devise`, `sf-localized`, `sf-loc`, `sf-loc-tag`, `sf-couleur`, `sf-item`, `sf-item-legend`,
|
|
14
|
+
`sf-add`, `sf-remove`, `sf-submit`, `sf-title`.
|
|
15
|
+
- `SF_CSS` : la feuille du mode `classes`, sur les jetons de Palette Studio (`--brand`, `--ink`, `--text`,
|
|
16
|
+
`--line`, `--field-border`, `--surface`, `--text-on-brand`, `--error-text`), avec replis ; `--sf-radius`
|
|
17
|
+
et les autres `--sf-*` se surchargent.
|
|
18
|
+
- `opts.classe` : une classe ajoutée à `sf-form`.
|
|
19
|
+
|
|
20
|
+
**Corrigé**
|
|
21
|
+
- Les libellés écrits par le module (« Oui », « Enregistrer », « + Ajouter », « Retirer ») restaient en
|
|
22
|
+
français dans une page anglaise ou arabe : ils suivent désormais `locale` (fr, en, ar).
|
|
23
|
+
- Un formulaire en `locale: 'ar'` porte `dir="rtl"` ; dans un champ `localized`, chaque saisie garde le sens de SA
|
|
24
|
+
langue (`dir="ltr"` pour fr et en, même dans une page arabe).
|
|
25
|
+
- La devise d'un montant ne se coupe plus (`white-space:nowrap`).
|
|
26
|
+
- `moduleInfo.version` annonçait encore 0.1.0.
|
|
27
|
+
|
|
28
|
+
**Essais** : `test-scripts/style.test.mjs` (T-STY-1 à 4) ; surface publique : `SF_CSS` ajouté.
|
|
29
|
+
|
|
30
|
+
|
|
3
31
|
## 0.2.0 — 17/08/2026
|
|
4
32
|
|
|
5
33
|
**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,
|
|
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.
|
|
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
|
|
|
@@ -42,6 +42,25 @@ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `nam
|
|
|
42
42
|
## STATUTS (exemple QatraxFlow)
|
|
43
43
|
`resultat.statut` = Validé / Non validé / En cours / Interrompu (voir `examples/qatraxflow-recette.schema.js`).
|
|
44
44
|
|
|
45
|
+
## LE CHAMP FICHIER (0.3.0)
|
|
46
|
+
`{ path, type: 'fichier', accept?, capture?, multiple? }`
|
|
47
|
+
|
|
48
|
+
- ⚠️ **L'`enctype="multipart/form-data"` est posé AUTOMATIQUEMENT** dès qu'un champ `fichier`
|
|
49
|
+
figure au schéma — répéteurs compris. Sans lui, le navigateur envoie le seul NOM du fichier,
|
|
50
|
+
jamais ses octets : il ne se plaint pas, le serveur reçoit un champ texte, et la photo n'arrive
|
|
51
|
+
jamais. C'est l'oubli le plus fréquent des formulaires de téléversement, et il est SILENCIEUX.
|
|
52
|
+
- ⚠️ **La valeur n'est jamais pré-remplie** : un navigateur refuse qu'une page choisisse un fichier
|
|
53
|
+
(ce serait une exfiltration). Un écran de modification montre la pièce déjà envoyée À CÔTÉ.
|
|
54
|
+
- ⚠️ `capture="environment"` ouvre l'**appareil photo** sur mobile, pas la galerie.
|
|
55
|
+
- ⚠️ Les OCTETS ne passent pas par `readValues` : ils se lisent avec `@mostajs/form-data-extract`.
|
|
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
|
+
|
|
45
64
|
## PIÈGES
|
|
46
65
|
- **Zéro-dép, HTML-string** : `renderForm` rend une chaîne ; côté React, injecter ou porter en composants.
|
|
47
66
|
- `readValues` : Array traité comme liste [name,value] (pas via `.entries()`), FormData via `.entries()`.
|
|
@@ -53,8 +72,8 @@ Repeater côté navigateur : câbler add (clone d'un `.sf-item`, réindexer `nam
|
|
|
53
72
|
docs/00-CARTOGRAPHIE-ET-PROPOSITION.md · examples/demo.html · examples/qatraxflow-recette.schema.js.
|
|
54
73
|
Compose (à terme) `@mostajs/ui` pour une variante React. Voisin : `@mostajs/settings`, `@mostajs/crud-ui`.
|
|
55
74
|
|
|
56
|
-
## SURFACE PUBLIQUE (
|
|
57
|
-
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 ·
|
|
58
77
|
renderForm · renderField · readValues · **repeaterScript** · **affichageMontant** · **centimesDepuis**
|
|
59
78
|
|
|
60
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.
|
|
4
|
-
"description": "Interface de SAISIE
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
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,61 +37,142 @@ 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
|
-
|
|
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}"
|
|
115
|
+
return `<textarea name="${name}"${req}${ph} rows="${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}"` : ''}
|
|
117
|
+
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}"` : ''}${at(mode, 'input')}>`;
|
|
50
118
|
case 'boolean':
|
|
51
|
-
return `<label
|
|
119
|
+
return `<label${at(mode, 'check')}><input type="checkbox" name="${name}" value="true"${value ? ' checked' : ''}> ${escapeHtml(label(f.help, locale) || label(MOTS.oui, locale))}</label>`;
|
|
120
|
+
// ── LE CHAMP FICHIER (0.3.0) ──────────────────────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// ⚠️ `capture` ouvre l'APPAREIL PHOTO sur mobile, au lieu de la galerie. C'est ce qu'on veut
|
|
123
|
+
// pour photographier une facture au comptoir : demander à un exploitant de prendre la photo,
|
|
124
|
+
// de la retrouver dans sa galerie, puis de la choisir, c'est trois occasions d'abandonner.
|
|
125
|
+
//
|
|
126
|
+
// ⚠️ La valeur d'un champ fichier n'est JAMAIS pré-remplie : un navigateur refuse qu'une page
|
|
127
|
+
// décide quel fichier est sélectionné (ce serait une exfiltration). Un formulaire de
|
|
128
|
+
// modification doit donc afficher la pièce DÉJÀ envoyée à côté du champ, pas dedans.
|
|
129
|
+
case 'fichier':
|
|
130
|
+
return `<input type="file" name="${name}"${req}`
|
|
131
|
+
+ `${f.accept ? ` accept="${escapeHtml(f.accept)}"` : ''}`
|
|
132
|
+
+ `${f.capture ? ` capture="${escapeHtml(f.capture)}"` : ''}`
|
|
133
|
+
+ `${f.multiple ? ' multiple' : ''}${at(mode, 'input')}>`;
|
|
52
134
|
case 'select':
|
|
53
|
-
return `<select name="${name}"${req}
|
|
135
|
+
return `<select name="${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>`;
|
|
54
136
|
case 'montant': {
|
|
55
137
|
// Un texte, PAS un `number` : le contrôle numérique du navigateur rend un flottant, et un
|
|
56
138
|
// flottant n'est pas un montant. La conversion en unités mineures se fait à la relecture,
|
|
57
139
|
// par lecture de la CHAÎNE (voir centimesDepuis).
|
|
58
140
|
const dec = f.decimales ?? 2;
|
|
59
141
|
const aff = value === undefined || value === null || value === '' ? '' : affichageMontant(value, dec);
|
|
60
|
-
return `<div
|
|
61
|
-
<input type="text" inputmode="decimal" name="${name}"${req} value="${escapeHtml(aff)}" placeholder="${escapeHtml(label(f.placeholder) || (dec ? '0,00' : '0'))}"
|
|
62
|
-
${f.devise ? `<span
|
|
142
|
+
return `<div${at(mode, 'montant')}>
|
|
143
|
+
<input type="text" inputmode="decimal" name="${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>`;
|
|
63
145
|
}
|
|
64
146
|
case 'color':
|
|
65
|
-
return `<input type="color" name="${name}" value="${escapeHtml(value || '#000000')}"
|
|
147
|
+
return `<input type="color" name="${name}" value="${escapeHtml(value || '#000000')}"${at(mode, 'couleur')}>`;
|
|
66
148
|
case 'date':
|
|
67
|
-
return `<input type="date" name="${name}"${req} value="${escapeHtml(value ?? '')}"
|
|
149
|
+
return `<input type="date" name="${name}"${req} value="${escapeHtml(value ?? '')}"${at(mode, 'input')}>`;
|
|
68
150
|
case 'localized': {
|
|
69
151
|
const locs = f.locales || LOCALES;
|
|
70
|
-
return `<div
|
|
71
|
-
`<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="${name}.${lc}" dir="${lc === 'ar' ? 'rtl' : 'ltr'}" value="${escapeHtml((value && value[lc]) ?? '')}"${at(mode, 'input')}></div>`).join('')}</div>`;
|
|
72
154
|
}
|
|
73
155
|
case 'repeater': {
|
|
74
156
|
const arr = Array.isArray(value) ? value : [];
|
|
75
|
-
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('');
|
|
76
158
|
// Un GABARIT accompagne toujours la liste, même vide : sans lui, « + Ajouter » n'aurait
|
|
77
159
|
// rien à cloner, et une liste qu'on ne peut pas allonger n'est pas une liste — c'est un
|
|
78
160
|
// 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"
|
|
161
|
+
const gabarit = `<template class="sf-tpl">${renderRepeaterItem(f, {}, `${name}[__i__]`, 0, mode, locale)}</template>`;
|
|
162
|
+
return `<div class="sf-repeater" data-name="${name}">${items}${gabarit}<button type="button"${at(mode, 'add')}>${escapeHtml(label(f.addLabel, locale) || label(MOTS.ajouter, locale))}</button></div>`;
|
|
81
163
|
}
|
|
82
164
|
default: // text
|
|
83
|
-
return `<input type="text" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}"
|
|
165
|
+
return `<input type="text" name="${name}"${req}${ph} value="${escapeHtml(value ?? '')}"${at(mode, 'input')}>`;
|
|
84
166
|
}
|
|
85
167
|
}
|
|
86
|
-
function renderRepeaterItem(f, item, prefix, i) {
|
|
87
|
-
return `<fieldset
|
|
88
|
-
<legend
|
|
89
|
-
${(f.itemFields || []).map((sub) => renderField({ ...sub, path: sub.path || sub.name }, getPath(item, sub.path || sub.name), `${prefix}.${sub.path || sub.name}
|
|
90
|
-
<button type="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>
|
|
91
173
|
</fieldset>`;
|
|
92
174
|
}
|
|
93
175
|
|
|
94
|
-
/** Rend un champ complet (label + contrôle). `name` = attribut name/chemin. */
|
|
95
176
|
/**
|
|
96
177
|
* Une saisie textuelle → un ENTIER d'unités mineures, **sans jamais passer par un flottant**.
|
|
97
178
|
*
|
|
@@ -104,7 +185,7 @@ function renderRepeaterItem(f, item, prefix, i) {
|
|
|
104
185
|
* Au-delà de `decimales`, l'arrondi est commercial (demi au-dessus), calculé sur la chaîne.
|
|
105
186
|
*/
|
|
106
187
|
export function centimesDepuis(saisie, decimales = 2) {
|
|
107
|
-
const t = String(saisie ?? '').trim().replace(/[\s
|
|
188
|
+
const t = String(saisie ?? '').trim().replace(/[\s ]/g, '').replace(',', '.');
|
|
108
189
|
if (t === '') return undefined;
|
|
109
190
|
const m = /^(-?)(\d*)(?:\.(\d*))?$/.exec(t);
|
|
110
191
|
if (!m || (!m[2] && !m[3])) return null;
|
|
@@ -130,28 +211,45 @@ export function affichageMontant(centimes, decimales = 2) {
|
|
|
130
211
|
: `${signe}${brut.slice(0, -decimales)},${brut.slice(-decimales)}`;
|
|
131
212
|
}
|
|
132
213
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
${
|
|
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>` : ''}
|
|
139
222
|
</div>`;
|
|
140
223
|
}
|
|
141
224
|
|
|
142
|
-
/**
|
|
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
|
+
*/
|
|
143
229
|
export function renderForm(schema, values = {}, opts = {}) {
|
|
144
230
|
const locale = opts.locale || 'fr';
|
|
231
|
+
const mode = opts.style === 'classes' ? 'classes' : 'inline';
|
|
145
232
|
const sections = schema.sections || [{ fields: schema.fields || [] }];
|
|
146
233
|
const body = sections.map((sec) => {
|
|
147
|
-
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('');
|
|
148
235
|
if (!sec.label && !sec.id) return inner;
|
|
149
|
-
return `<fieldset
|
|
150
|
-
<legend
|
|
236
|
+
return `<fieldset${at(mode, 'section')}>
|
|
237
|
+
<legend${at(mode, 'legend')}>${escapeHtml(label(sec.label, locale) || sec.id)}</legend>${inner}</fieldset>`;
|
|
151
238
|
}).join('');
|
|
152
|
-
const submit = opts.submitLabel === null ? '' : `<button type="submit"
|
|
153
|
-
|
|
154
|
-
|
|
239
|
+
const submit = opts.submitLabel === null ? '' : `<button type="submit"${at(mode, 'submit')}>${escapeHtml(label(opts.submitLabel, locale) || label(MOTS.enregistrer, locale))}</button>`;
|
|
240
|
+
// ── L'ENCTYPE EST POSÉ TOUT SEUL, ET C'EST LE POINT ───────────────────────
|
|
241
|
+
//
|
|
242
|
+
// ⚠️ Un formulaire qui porte un `<input type=file>` SANS `enctype="multipart/form-data"` envoie
|
|
243
|
+
// le seul NOM du fichier, jamais ses octets. Le navigateur ne se plaint pas, le serveur reçoit
|
|
244
|
+
// un champ texte qui ressemble à un fichier, et la photo n'arrive jamais. C'est l'oubli le plus
|
|
245
|
+
// fréquent des formulaires de téléversement, et il est parfaitement silencieux.
|
|
246
|
+
//
|
|
247
|
+
// On ne le laisse donc pas à la charge de l'appelant : dès qu'un champ `fichier` figure au
|
|
248
|
+
// schéma — y compris DANS un répéteur — l'attribut est posé.
|
|
249
|
+
const aUnFichier = JSON.stringify(schema).includes('"fichier"');
|
|
250
|
+
const classe = `sf-form${opts.classe ? ` ${escapeHtml(opts.classe)}` : ''}`;
|
|
251
|
+
return `<form class="${classe}"${opts.id ? ` id="${opts.id}"` : ''}${opts.action ? ` method="post" action="${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>`;
|
|
155
253
|
}
|
|
156
254
|
|
|
157
255
|
/* ── relecture des valeurs (FormData → objet imbriqué) ─────────────────────── */
|