@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 +33 -0
- package/README.md +6 -1
- package/llms.txt +10 -3
- package/package.json +6 -6
- package/src/index.js +114 -40
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,
|
|
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
|
|
|
@@ -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 (
|
|
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.
|
|
4
|
-
"description": "Interface de SAISIE
|
|
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.
|
|
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
|
-
|
|
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="${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}"` : ''}
|
|
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
|
|
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' : ''}
|
|
133
|
+
+ `${f.multiple ? ' multiple' : ''}${at(mode, 'input')}>`;
|
|
66
134
|
case 'select':
|
|
67
|
-
return `<select name="${name}"${req}
|
|
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
|
|
75
|
-
<input type="text" inputmode="decimal" name="${name}"${req} value="${escapeHtml(aff)}" placeholder="${escapeHtml(label(f.placeholder) || (dec ? '0,00' : '0'))}"
|
|
76
|
-
${f.devise ? `<span
|
|
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')}"
|
|
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 ?? '')}"
|
|
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
|
|
85
|
-
`<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"
|
|
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 ?? '')}"
|
|
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
|
|
102
|
-
<legend
|
|
103
|
-
${(f.itemFields || []).map((sub) => renderField({ ...sub, path: sub.path || sub.name }, getPath(item, sub.path || sub.name), `${prefix}.${sub.path || sub.name}
|
|
104
|
-
<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>
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
${
|
|
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
|
-
/**
|
|
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
|
|
164
|
-
<legend
|
|
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"
|
|
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
|
-
|
|
178
|
-
|
|
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é) ─────────────────────── */
|