/** * 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 `` 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, la couleur et les tags d'un projet. * @returns {Promise<{nom: string, couleur: string, tags: string[]} | null>} null si annulé. */ export function creerDialogueProjet(refs, couleurs, outilsTags) { const { dialogue, titre, champNom, conteneurCouleurs, champTags, suggestionsTags, blocSuggestions, erreur, valider, } = refs; const { normaliserTags, cleTag, teinteTag } = outilsTags; 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); } } /** Tags saisis dans le champ, tels qu'ils seront enregistrés. */ function tagsSaisis() { return normaliserTags(champTags.value.split(',')); } /** * Tags déjà employés ailleurs dans le planning, proposés en un clic. * * Sans eux, chaque projet inventerait sa propre orthographe et le filtre se * remplirait de « client », « Clients » et « client final ». */ function peindreSuggestions(tagsConnus) { suggestionsTags.replaceChildren(); const posees = new Set(tagsSaisis().map(cleTag)); for (const tag of tagsConnus) { const bouton = document.createElement('button'); bouton.type = 'button'; bouton.className = 'tag'; bouton.style.setProperty('--teinte-tag', String(teinteTag(tag))); bouton.textContent = tag; bouton.setAttribute('aria-pressed', String(posees.has(cleTag(tag)))); bouton.addEventListener('click', () => { const restants = tagsSaisis().filter((pose) => cleTag(pose) !== cleTag(tag)); const suivants = restants.length === tagsSaisis().length ? [...restants, tag] : restants; champTags.value = normaliserTags(suivants).join(', '); peindreSuggestions(tagsConnus); }); suggestionsTags.append(bouton); } blocSuggestions.hidden = tagsConnus.length === 0; } return function demander({ titreTexte, libelleValider, nom = '', couleur = couleurs[0], tags = [], tagsConnus = [], }) { titre.textContent = titreTexte; valider.textContent = libelleValider; champNom.value = nom; champTags.value = tags.join(', '); choisie = couleur; erreur.hidden = true; peindrePalette(); peindreSuggestions(tagsConnus); const rafraichirSuggestions = () => peindreSuggestions(tagsConnus); champTags.addEventListener('input', rafraichirSuggestions); dialogue.showModal(); champNom.focus(); champNom.select(); return new Promise((resoudre) => { dialogue.addEventListener( 'close', () => { champTags.removeEventListener('input', rafraichirSuggestions); // `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, tags: tagsSaisis() }); }, { once: true } ); }); }; } /** * Demande confirmation avant une action irréversible. * @returns {Promise} */ 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, }); }); }; }