HormoneTrack-web/js/ui/components.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

251 lines
8.9 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* Composants UI réutilisables (equivalent des composants Compose Android) :
* fabrique DOM `el()`, dialogs modaux, dropdown, champs date/heure, toast.
*
* Aucun framework : tout est construit via `el(tag, attrs, children)` — un
* helper minimal et lisible, commenté, qui remplace JSX/hyperscript.
* ─────────────────────────────────────────────────────────────────────────────
*/
import { t as tr } from '../util/i18n.js';
/**
* Fabrique DOM : crée un élément, applique les attributs/écouteurs, attache
* les enfants (string → nœud texte ; Element → append ; Array → aplatit ;
* null/undefined/false → ignoré).
*
* attrs spéciaux :
* - `class` : liste de classes (string) ;
* - `on<event>` : addEventListener (ex. `onclick`, `oninput`) ;
* - tout autre attribut passe par setAttribute (sauf `value`/`checked`/
* `disabled`/`selected` → propriétés DOM directes).
*
* @param {string} tag
* @param {object} [attrs={}]
* @param {Array<Node|string|Object|null|false>|Node|string|null} [children]
* @returns {HTMLElement}
*/
export function el(tag, attrs = {}, children = null) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs || {})) {
if (value === null || value === undefined || value === false) continue;
if (key.startsWith('on') && typeof value === 'function') {
node.addEventListener(key.slice(2), value);
} else if (key === 'class') {
node.className = value;
} else if (key === 'value' || key === 'checked' || key === 'disabled' || key === 'selected') {
node[key] = value;
} else {
node.setAttribute(key, value);
}
}
appendChildren(node, children);
return node;
}
/** @private */
function appendChildren(node, children) {
if (children === null || children === undefined || children === false) return;
if (Array.isArray(children)) {
for (const c of children) appendChildren(node, c);
} else if (children instanceof Node) {
node.appendChild(children);
} else {
node.appendChild(document.createTextNode(String(children)));
}
}
/**
* Dialog modal générique (miroir AlertDialog M3) :
* - fond cliquable = dismiss (comme onDismissRequest) ;
* - body : nœud ou string ; actions : [{label, className, onClick}].
*
* @param {object} opts { title, body, actions, dismissible=true }
* @returns {{close:Function, root:HTMLElement}}
*/
export function showDialog({ title, body, actions = [], dismissible = true }) {
const root = el('div', { class: 'dialog-backdrop' });
const dialog = el('div', { class: 'dialog' });
if (title) dialog.appendChild(el('h2', {}, title));
if (body) appendChildren(dialog, body);
if (actions.length) {
const row = el('div', { class: 'actions' });
for (const a of actions) {
row.appendChild(el('button', {
class: a.className || 'btn text',
onclick: () => a.onClick(),
}, a.label));
}
dialog.appendChild(row);
}
root.appendChild(dialog);
const close = () => root.remove();
if (dismissible) {
root.addEventListener('click', (e) => {
if (e.target === root) close(); // clic sur le fond seulement
});
}
document.body.appendChild(root);
return { close, root };
}
/**
* Toast de feedback (message temporaire en bas d'écran — feedback des
* actions Paramètres, miroir du `message` SettingsScreen Android).
*
* @param {string} text
*/
export function toast(text) {
const node = el('div', { class: 'toast' }, text);
document.body.appendChild(node);
setTimeout(() => node.remove(), 3500);
}
/**
* Champ de saisie (label + input/select/textarea) — miroir OutlinedTextField.
*
* @param {object} opts { label, type, value, onInput, options (select),
* placeholder, attrs, inputEl (retourne l'élément via
* result.input) }
* @returns {HTMLElement} le conteneur (.input = l'élément de saisie)
*/
export function field({ label, type = 'text', value = '', onInput = null, options = null, placeholder = null, attrs = {} }) {
let input;
if (options) {
input = el('select', {
onchange: (e) => onInput && onInput(e.target.value),
...attrs,
}, options.map((o) => el('option', { value: o.value, selected: o.value === value }, o.label)));
} else if (type === 'textarea') {
input = el('textarea', { oninput: (e) => onInput && onInput(e.target.value), placeholder, ...attrs }, value || '');
} else {
input = el('input', {
type,
value,
oninput: (e) => onInput && onInput(e.target.value),
placeholder,
...attrs,
});
}
const wrap = el('div', { class: 'field' }, [el('label', {}, label), input]);
wrap.input = input;
return wrap;
}
/**
* Dropdown (miroir ExposedDropdownMenuBox) : un <select> stylé suffit côté
* web — natif, accessible, sans menu flottant à gérer.
* (Voir `field({ options })` — ce wrapper existe pour la lisibilité des écrans.)
*/
export function dropdown(label, value, options, onSelect) {
return field({ label, value, options, onInput: onSelect });
}
/**
* Switch (miroir Switch M3) avec label cliquable.
* @param {string} labelText
* @param {boolean} checked
* @param {(checked:boolean)=>void} onChange
* @returns {HTMLElement} (.input = la checkbox)
*/
export function switchRow(labelText, checked, onChange) {
const input = el('input', { type: 'checkbox' });
input.checked = !!checked;
input.addEventListener('change', () => onChange(input.checked));
return el('div', { class: 'row', style: 'justify-content: space-between;' }, [
el('span', {}, labelText),
el('label', { class: 'switch' }, [input, el('span', { class: 'slider' })]),
]);
}
// expose la checkbox pour lecture/écriture programmatique
// (wrap.querySelector('input') fonctionne aussi)
/**
* Chips de filtre (miroir FilterChip) : sélection visuelle + callback.
* @param {string} label
* @param {boolean} selected
* @param {()=>void} onClick
*/
export function filterChip(label, selected, onClick, disabled = false) {
return el('span', {
class: `chip ${selected ? 'selected' : ''}`,
onclick: disabled ? null : onClick,
style: disabled ? 'opacity:0.4;cursor:default;' : null,
}, label);
}
/**
* Chip assist (non cliquable — affichage, miroir AssistChip).
*/
export function assistChip(label) {
return el('span', { class: 'chip static' }, label);
}
/**
* Bouton rond FAB (+).
* @param {()=>void} onClick
*/
export function fab(onClick) {
return el('button', { class: 'fab', 'aria-label': '+', onclick: onClick }, '+');
}
/**
* Champ date+heure (miroir DateTimeField : DatePicker+TimePicker M3) —
* deux inputs natifs côte à côte.
*
* @param {string} label
* @param {number} ms Valeur initiale (epoch ms)
* @param {(ms:number|null)=>void} onChange null si la saisie est incomplète
* @returns {HTMLElement} (.getMs() : valeur courante)
*/
export function dateTimeField(label, ms, onChange) {
const { date, time } = msToInput(ms);
const dateInput = el('input', { type: 'date', value: date });
const timeInput = el('input', { type: 'time', value: time });
const emit = () => onChange(toMs(dateInput.value, timeInput.value));
dateInput.addEventListener('input', emit);
timeInput.addEventListener('input', emit);
const wrap = el('div', { class: 'field' }, [
el('label', {}, label),
el('div', { class: 'row' }, [dateInput, timeInput]),
]);
wrap.getMs = () => toMs(dateInput.value, timeInput.value);
return wrap;
}
/** @private */
function msToInput(ms) {
const d = new Date(ms);
return {
date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
};
}
/** @private */
function toMs(dateISO, timeHM) {
if (!dateISO || !timeHM) return null;
const [y, mo, d] = dateISO.split('-').map(Number);
const [h, mi] = timeHM.split(':').map(Number);
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
return new Date(y, mo - 1, d, h, mi, 0, 0).getTime();
}
/**
* Confirme une action destructive (miroir AlertDialog de confirmation).
* @param {string} text
* @param {()=>void} onConfirm
*/
export function confirmDialog(title, text, onConfirm, confirmLabel) {
const current = showDialog({
title,
body: el('p', {}, text),
actions: [
{ label: tr('cancel'), className: 'btn text', onClick: () => current.close() },
{ label: confirmLabel || tr('delete'), className: 'btn text danger', onClick: () => { current.close(); onConfirm(); } },
],
});
}