Une frise chronologique unique où plusieurs projets s'empilent en couloirs pliables, pour voir d'un coup d'œil où en est chacun et comment ils se situent les uns par rapport aux autres. Ce n'est pas un outil de suivi quotidien : une phase se compte en semaines, et il n'y a ni sous-tâches, ni tickets, ni dépendances entre phases. - Manipulation directe des barres (glisser, redimensionner), accrochées au lundi - Couloirs pliables : plié, un projet devient une barre segmentée par phase - Planning de référence figeable, avec barre fantôme et calcul de dérive - En-tête à trois bandes : année, mois, numéro de semaine ISO 8601 - Frise qui s'élargit au défilement, pour planifier dans un futur encore vide - Micro-serveur Python (bibliothèque standard) exposant GET/PUT sur /api/data, avec sauvegarde horodatée avant chaque écriture - Zéro build : modules ES natifs, aucune dépendance à installer 50 tests unitaires sur la logique métier (node --test, sans dépendance). Vérifié dans Chromium et Firefox. Les arbitrages de conception et surtout leurs raisons sont consignés dans docs/decisions.md — notamment l'abandon des dépendances entre phases, de la File System Access API, du SVG et des niveaux de zoom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
6.7 KiB
JavaScript
216 lines
6.7 KiB
JavaScript
/**
|
|
* Menu contextuel et boîtes de dialogue.
|
|
*
|
|
* Remplace les `prompt()` et `confirm()` natifs, qui bloquaient le fil
|
|
* d'exécution, ne se stylaient pas, et — pour le menu d'un projet — obligeaient
|
|
* à saisir un numéro d'action au clavier.
|
|
*
|
|
* Les dialogues s'appuient sur l'élément `<dialog>` natif, qui apporte
|
|
* gratuitement le voile, le piège à focus, la fermeture par Échap et la
|
|
* restauration du focus à la fermeture.
|
|
*/
|
|
|
|
const MARGE_ECRAN = 8;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Menu contextuel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @param {HTMLElement} racine l'élément `.menu` du document
|
|
*/
|
|
export function creerMenu(racine) {
|
|
let fermerCourant = null;
|
|
|
|
function fermer() {
|
|
if (!fermerCourant) return;
|
|
const rendreLeFocus = fermerCourant;
|
|
fermerCourant = null;
|
|
racine.hidden = true;
|
|
racine.replaceChildren();
|
|
rendreLeFocus();
|
|
}
|
|
|
|
// En capture : un clic ailleurs referme le menu avant que la cible ne le
|
|
// traite, sans quoi on pourrait activer un bouton et laisser le menu ouvert.
|
|
document.addEventListener(
|
|
'pointerdown',
|
|
(evenement) => {
|
|
if (fermerCourant && !racine.contains(evenement.target)) fermer();
|
|
},
|
|
true
|
|
);
|
|
|
|
document.addEventListener('keydown', (evenement) => {
|
|
if (!fermerCourant) return;
|
|
|
|
if (evenement.key === 'Escape') {
|
|
evenement.preventDefault();
|
|
fermer();
|
|
return;
|
|
}
|
|
|
|
const entrees = [...racine.querySelectorAll('button:not(:disabled)')];
|
|
if (!entrees.length) return;
|
|
const index = entrees.indexOf(document.activeElement);
|
|
|
|
if (evenement.key === 'ArrowDown') {
|
|
evenement.preventDefault();
|
|
entrees[(index + 1) % entrees.length].focus();
|
|
} else if (evenement.key === 'ArrowUp') {
|
|
evenement.preventDefault();
|
|
entrees[(index - 1 + entrees.length) % entrees.length].focus();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', fermer);
|
|
|
|
return {
|
|
/**
|
|
* @param {HTMLElement} ancre élément près duquel afficher le menu
|
|
* @param {Array} entrees { libelle, action, danger?, separateurAvant?, desactive? }
|
|
*/
|
|
ouvrir(ancre, entrees) {
|
|
fermer();
|
|
|
|
const aRendre = document.activeElement;
|
|
fermerCourant = () => aRendre?.focus?.();
|
|
|
|
for (const entree of entrees) {
|
|
if (entree.separateurAvant) {
|
|
const separateur = document.createElement('hr');
|
|
separateur.className = 'menu__separateur';
|
|
racine.append(separateur);
|
|
}
|
|
|
|
const bouton = document.createElement('button');
|
|
bouton.type = 'button';
|
|
bouton.className = `menu__entree${entree.danger ? ' menu__entree--danger' : ''}`;
|
|
bouton.setAttribute('role', 'menuitem');
|
|
bouton.textContent = entree.libelle;
|
|
bouton.disabled = Boolean(entree.desactive);
|
|
bouton.addEventListener('click', () => {
|
|
fermer();
|
|
entree.action();
|
|
});
|
|
racine.append(bouton);
|
|
}
|
|
|
|
racine.hidden = false;
|
|
positionner(racine, ancre);
|
|
racine.querySelector('button:not(:disabled)')?.focus();
|
|
},
|
|
|
|
fermer,
|
|
};
|
|
}
|
|
|
|
/** Place le menu sous son ancre, en le rabattant s'il déborde de la fenêtre. */
|
|
function positionner(menu, ancre) {
|
|
const cadre = ancre.getBoundingClientRect();
|
|
|
|
// Mesure d'abord au coin supérieur gauche : la taille du menu dépend de son
|
|
// contenu, et la lire avant de le placer évite un saut visible.
|
|
menu.style.left = '0px';
|
|
menu.style.top = '0px';
|
|
const taille = menu.getBoundingClientRect();
|
|
|
|
let gauche = cadre.left;
|
|
let haut = cadre.bottom + 2;
|
|
|
|
if (gauche + taille.width > window.innerWidth - MARGE_ECRAN) {
|
|
gauche = Math.max(MARGE_ECRAN, window.innerWidth - taille.width - MARGE_ECRAN);
|
|
}
|
|
if (haut + taille.height > window.innerHeight - MARGE_ECRAN) {
|
|
// Pas la place en dessous : on bascule au-dessus de l'ancre.
|
|
haut = Math.max(MARGE_ECRAN, cadre.top - taille.height - 2);
|
|
}
|
|
|
|
menu.style.left = `${Math.round(gauche)}px`;
|
|
menu.style.top = `${Math.round(haut)}px`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dialogues
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Demande le nom et la couleur d'un projet.
|
|
* @returns {Promise<{nom: string, couleur: string} | null>} null si annulé.
|
|
*/
|
|
export function creerDialogueProjet(refs, couleurs) {
|
|
const { dialogue, titre, champNom, conteneurCouleurs, erreur, valider } = refs;
|
|
let choisie = couleurs[0];
|
|
|
|
function peindrePalette() {
|
|
conteneurCouleurs.replaceChildren();
|
|
for (const couleur of couleurs) {
|
|
const bouton = document.createElement('button');
|
|
bouton.type = 'button';
|
|
bouton.className = 'pastille';
|
|
bouton.style.background = couleur;
|
|
bouton.setAttribute('aria-label', `Couleur ${couleur}`);
|
|
bouton.setAttribute('aria-pressed', String(couleur === choisie));
|
|
bouton.addEventListener('click', () => {
|
|
choisie = couleur;
|
|
peindrePalette();
|
|
});
|
|
conteneurCouleurs.append(bouton);
|
|
}
|
|
}
|
|
|
|
return function demander({ titreTexte, libelleValider, nom = '', couleur = couleurs[0] }) {
|
|
titre.textContent = titreTexte;
|
|
valider.textContent = libelleValider;
|
|
champNom.value = nom;
|
|
choisie = couleur;
|
|
erreur.hidden = true;
|
|
peindrePalette();
|
|
|
|
dialogue.showModal();
|
|
champNom.focus();
|
|
champNom.select();
|
|
|
|
return new Promise((resoudre) => {
|
|
dialogue.addEventListener(
|
|
'close',
|
|
() => {
|
|
// `returnValue` vaut '' quand le dialogue est fermé par Échap.
|
|
if (dialogue.returnValue !== 'ok') return resoudre(null);
|
|
const saisi = champNom.value.trim();
|
|
if (!saisi) return resoudre(null);
|
|
resoudre({ nom: saisi, couleur: choisie });
|
|
},
|
|
{ once: true }
|
|
);
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Demande confirmation avant une action irréversible.
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export function creerDialogueConfirmation(refs) {
|
|
const { dialogue, titre, message, valider } = refs;
|
|
|
|
return function demander({ titreTexte, messageTexte, libelleValider = 'Confirmer', danger = false }) {
|
|
titre.textContent = titreTexte;
|
|
message.textContent = messageTexte;
|
|
valider.textContent = libelleValider;
|
|
valider.classList.toggle('bouton--danger', danger);
|
|
valider.classList.toggle('bouton--principal', !danger);
|
|
|
|
dialogue.showModal();
|
|
// Le focus part sur « Annuler » : sur une action destructrice, un appui
|
|
// réflexe sur Entrée ne doit pas valider.
|
|
dialogue.querySelector('button[value="annuler"]').focus();
|
|
|
|
return new Promise((resoudre) => {
|
|
dialogue.addEventListener('close', () => resoudre(dialogue.returnValue === 'ok'), {
|
|
once: true,
|
|
});
|
|
});
|
|
};
|
|
}
|