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.
251 lines
8.9 KiB
JavaScript
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(); } },
|
|
],
|
|
});
|
|
}
|