Les tags s'affichent en pastilles colorées dans la colonne des libellés, et une barre de filtres apparaît sous la barre d'outils dès qu'un tag existe. Cliquer un tag — dans cette barre ou directement sur la pastille d'un projet — restreint la frise ; recliquer le retire. Le filtre est conjonctif : un projet doit porter tous les tags cochés. Cocher un tag de plus resserre donc toujours la sélection, sans jamais faire se repeupler la frise. Un message dédié prend la place de la frise quand plus rien ne passe le filtre, sans quoi elle laisserait croire que les projets ont disparu. Le filtre est un état de vue, jamais enregistré, au même titre que la fenêtre temporelle. Il ne fait pas le même travail que le masquage par l'œil, qui reste enregistré : le filtre retire le projet de la vue entière, l'œil garde son libellé grisé et n'ôte que les barres. Deux ajustements de confort : créer un projet sous filtre actif préremplit ses tags avec ceux du filtre, sinon le nouveau venu naîtrait invisible ; et un tag que plus aucun projet ne porte est retiré du filtre, sinon il masquerait tout. Le dialogue d'un projet gagne un champ de saisie et propose en un clic les tags déjà employés ailleurs, seul garde-fou contre la dérive orthographique. Dans la colonne des libellés, les pastilles rognent le nom du projet mais lui laissent quatre caractères, et s'effacent à leur tour plutôt que de repousser l'œil et le menu hors de la colonne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
8.6 KiB
JavaScript
274 lines
8.6 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, 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<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,
|
|
});
|
|
});
|
|
};
|
|
}
|