Revoir l'ergonomie des panneaux et de la colonne des projets
Les panneaux d'édition étaient modaux et la colonne de gauche tronquait tout ce qu'elle portait. Sept corrections, mesurées sur data/exemple.json. Panneaux - Le voile qui recouvrait la page disparaît : la frise reste manipulable pendant l'édition, et cliquer une autre barre y bascule la sélection. Il rendait aussi le raccourci flèches inatteignable, le focus partant dans le champ « Nom » sans qu'on puisse cliquer ailleurs pour en sortir. - Un clic dans une zone inerte referme le panneau : ce que rendait le voile, sans le calque qui figeait la frise. - Les paramètres d'un projet quittent le <dialog> modal pour le même panneau qu'une phase, avec la même édition au fil de la saisie. « Nouveau projet » crée donc le projet aussitôt, comme le fait déjà l'ajout d'une phase : plus aucune création ne passe par un formulaire. Colonne des projets - Le nom ne disposait que de 56 px sur 250, et « client » s'affichait « c. ». Les commandes passent en surimpression au survol, les tags cèdent en bloc derrière un compteur, et le nom cède en dernier. - La colonne se redimensionne, largeur conservée dans localStorage. - L'œil disparaît : il faisait doublon avec l'entrée « Masquer » du menu. Un « + » le remplace, l'ajout d'une phase étant l'action la plus fréquente, et le nom du projet devient cliquable — il portait déjà un curseur « main » sans aucune action derrière. - Plus de commande « Ajouter un jalon » : un jalon est une phase dont on coche la case, pas un autre objet. Frise - Le nom d'une phase reste accroché au bord visible quand sa barre s'enfonce sous la colonne. - Cliquer un nom de phase hors écran ramène la frise sur elle. - La barre cumulative d'un projet s'affiche aussi quand il est déplié, amincie pour ne pas se faire passer pour une barre de phase. Arbitrages consignés dans docs/decisions.md, sections 8 et 17 à 21. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
330
js/app.js
330
js/app.js
@@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import { creerPanneau } from './detail.js';
|
||||
import { creerDialogueConfirmation, creerDialogueProjet, creerMenu } from './menu.js';
|
||||
import { creerPanneauProjet } from './projet.js';
|
||||
import { creerDialogueConfirmation, creerMenu } from './menu.js';
|
||||
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
||||
import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js';
|
||||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||||
@@ -52,6 +53,7 @@ const refs = {
|
||||
effacerFiltres: $('effacer-filtres'),
|
||||
alerte: $('alerte'),
|
||||
alerteMessage: $('alerte-message'),
|
||||
panneau: $('panneau'),
|
||||
};
|
||||
|
||||
let planning = planningVide();
|
||||
@@ -98,22 +100,6 @@ const PORTEE_MAX_ANNEES = 10;
|
||||
const stockage = creerStockage(afficherEtat);
|
||||
const menu = creerMenu($('menu'));
|
||||
|
||||
const demanderProjet = creerDialogueProjet(
|
||||
{
|
||||
dialogue: $('dialogue-projet'),
|
||||
titre: $('dialogue-projet-titre'),
|
||||
champNom: $('dialogue-projet-nom'),
|
||||
conteneurCouleurs: $('dialogue-projet-couleurs'),
|
||||
champTags: $('dialogue-projet-tags'),
|
||||
suggestionsTags: $('dialogue-projet-suggestions'),
|
||||
blocSuggestions: $('dialogue-projet-suggestions-bloc'),
|
||||
erreur: $('dialogue-projet-erreur'),
|
||||
valider: $('dialogue-projet-ok'),
|
||||
},
|
||||
COULEURS,
|
||||
{ normaliserTags, cleTag, teinteTag }
|
||||
);
|
||||
|
||||
const demanderConfirmation = creerDialogueConfirmation({
|
||||
dialogue: $('dialogue-confirmation'),
|
||||
titre: $('dialogue-confirmation-titre'),
|
||||
@@ -124,7 +110,6 @@ const demanderConfirmation = creerDialogueConfirmation({
|
||||
const panneau = creerPanneau(
|
||||
{
|
||||
panneau: $('panneau'),
|
||||
voile: $('voile'),
|
||||
formulaire: $('formulaire-phase'),
|
||||
titre: $('panneau-titre'),
|
||||
erreur: $('erreur-phase'),
|
||||
@@ -160,6 +145,38 @@ const panneau = creerPanneau(
|
||||
}
|
||||
);
|
||||
|
||||
const panneauProjet = creerPanneauProjet(
|
||||
{
|
||||
panneau: $('panneau-projet'),
|
||||
formulaire: $('formulaire-projet'),
|
||||
titre: $('panneau-projet-titre'),
|
||||
erreur: $('erreur-projet'),
|
||||
conteneurCouleurs: $('panneau-projet-couleurs'),
|
||||
suggestions: $('panneau-projet-suggestions'),
|
||||
blocSuggestions: $('panneau-projet-suggestions-bloc'),
|
||||
supprimer: $('supprimer-projet'),
|
||||
fermer: $('fermer-panneau-projet'),
|
||||
},
|
||||
{ couleurs: COULEURS, normaliserTags, cleTag, teinteTag },
|
||||
{
|
||||
tagsConnus: () => tousLesTags(planning),
|
||||
surModification(idProjet, projet) {
|
||||
// L'identifiant ne change pas : il identifie, il ne décrit pas.
|
||||
majPlanning(remplacerProjet(planning, idProjet, () => projet));
|
||||
},
|
||||
demanderSuppression(_idProjet, projet) {
|
||||
return confirmerSuppressionProjet(projet);
|
||||
},
|
||||
surSuppression(idProjet) {
|
||||
if (selection?.projet === idProjet) panneau.fermer();
|
||||
majPlanning(supprimerProjet(planning, idProjet));
|
||||
},
|
||||
surFermeture() {
|
||||
dessiner();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// État
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -202,6 +219,12 @@ function dessiner() {
|
||||
const trouve = localiser(selection.projet, selection.phase);
|
||||
if (trouve) panneau.rafraichir(trouve.projet, trouve.phase);
|
||||
}
|
||||
|
||||
const idProjetOuvert = panneauProjet.projetOuvert();
|
||||
if (idProjetOuvert) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjetOuvert);
|
||||
if (projet) panneauProjet.rafraichir(projet);
|
||||
}
|
||||
}
|
||||
|
||||
function localiser(idProjet, idPhase) {
|
||||
@@ -286,6 +309,101 @@ function effacerFiltres() {
|
||||
refs.effacerFiltres.addEventListener('click', effacerFiltres);
|
||||
$('effacer-filtres-vide').addEventListener('click', effacerFiltres);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Largeur de la colonne des libellés
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* La colonne héberge le nom du projet, ses tags, sa dérive et trois commandes.
|
||||
* Aucune largeur fixe ne convient à la fois à « Site web » et à « Refonte du
|
||||
* portail fournisseurs — lot 2 » : c'est donc à l'utilisateur de trancher.
|
||||
*
|
||||
* Contrairement à la fenêtre temporelle et au filtre par tags, cette largeur
|
||||
* **est** conservée — dans `localStorage`, pas dans le planning. C'est un
|
||||
* réglage d'affichage propre à un écran, non une donnée à synchroniser entre
|
||||
* machines : le stocker dans le fichier imposerait à un portable la largeur
|
||||
* choisie sur un 27 pouces. Et sans persistance, une poignée qu'il faut
|
||||
* réajuster à chaque ouverture ne sert à rien.
|
||||
*/
|
||||
// `defaut` doit rester en accord avec `--largeur-libelles` dans style.css.
|
||||
const LARGEUR_LIBELLES = { min: 150, max: 560, defaut: 300 };
|
||||
const CLE_LARGEUR_LIBELLES = 'frise.largeurLibelles';
|
||||
|
||||
/**
|
||||
* Largeur courante, tenue ici plutôt que lue sur le DOM : `offsetWidth` vaut
|
||||
* zéro tant que la frise est masquée — planning vide, ou filtre qui ne retient
|
||||
* rien —, et le premier glisser repartirait alors de zéro.
|
||||
*/
|
||||
let largeurLibelles = LARGEUR_LIBELLES.defaut;
|
||||
|
||||
function appliquerLargeurLibelles(largeur) {
|
||||
const borne = Math.min(LARGEUR_LIBELLES.max, Math.max(LARGEUR_LIBELLES.min, Math.round(largeur)));
|
||||
largeurLibelles = borne;
|
||||
document.documentElement.style.setProperty('--largeur-libelles', `${borne}px`);
|
||||
try {
|
||||
localStorage.setItem(CLE_LARGEUR_LIBELLES, String(borne));
|
||||
} catch {
|
||||
// Mode privé, quota saturé : la largeur vaut pour la session, sans plus.
|
||||
}
|
||||
return borne;
|
||||
}
|
||||
|
||||
function restaurerLargeurLibelles() {
|
||||
let memorisee = null;
|
||||
try {
|
||||
memorisee = localStorage.getItem(CLE_LARGEUR_LIBELLES);
|
||||
} catch {
|
||||
// Lecture refusée : on garde la valeur de la feuille de style.
|
||||
}
|
||||
const largeur = Number(memorisee);
|
||||
if (Number.isFinite(largeur) && largeur > 0) appliquerLargeurLibelles(largeur);
|
||||
}
|
||||
|
||||
restaurerLargeurLibelles();
|
||||
|
||||
{
|
||||
const poignee = $('poignee-colonne');
|
||||
let depart = null;
|
||||
|
||||
poignee.addEventListener('pointerdown', (evenement) => {
|
||||
if (evenement.button !== 0) return;
|
||||
depart = { x: evenement.clientX, largeur: largeurLibelles };
|
||||
poignee.setPointerCapture(evenement.pointerId);
|
||||
poignee.dataset.glisse = 'true';
|
||||
document.body.classList.add('glisse-en-cours');
|
||||
evenement.preventDefault();
|
||||
});
|
||||
|
||||
poignee.addEventListener('pointermove', (evenement) => {
|
||||
if (!depart) return;
|
||||
appliquerLargeurLibelles(depart.largeur + (evenement.clientX - depart.x));
|
||||
});
|
||||
|
||||
const terminer = (evenement) => {
|
||||
if (!depart) return;
|
||||
depart = null;
|
||||
poignee.releasePointerCapture?.(evenement.pointerId);
|
||||
delete poignee.dataset.glisse;
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
};
|
||||
|
||||
poignee.addEventListener('pointerup', terminer);
|
||||
poignee.addEventListener('pointercancel', terminer);
|
||||
|
||||
poignee.addEventListener('dblclick', () => appliquerLargeurLibelles(LARGEUR_LIBELLES.defaut));
|
||||
|
||||
// Les flèches ajustent aussi la largeur quand la poignée a le focus.
|
||||
// `stopPropagation` est indispensable : sans lui, le gestionnaire posé plus
|
||||
// bas sur `document` décalerait en même temps la phase sélectionnée.
|
||||
poignee.addEventListener('keydown', (evenement) => {
|
||||
const pas = evenement.key === 'ArrowLeft' ? -10 : evenement.key === 'ArrowRight' ? 10 : 0;
|
||||
if (pas === 0) return;
|
||||
evenement.preventDefault();
|
||||
evenement.stopPropagation();
|
||||
appliquerLargeurLibelles(largeurLibelles + pas);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fenêtre temporelle et défilement sans fin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -394,12 +512,48 @@ installerGlisser(refs.couloirs, {
|
||||
},
|
||||
});
|
||||
|
||||
function ouvrirPhase(idProjet, idPhase) {
|
||||
/**
|
||||
* @param {object} options
|
||||
* - nomAEcraser : transmis au panneau, pour une phase fraîchement créée.
|
||||
* - reveler : recentre la frise si la barre n'est pas dans le champ de
|
||||
* vision. Utile depuis la colonne des libellés, où l'on peut cliquer une
|
||||
* phase dont la barre est à des mois de la vue courante — sans cela, le
|
||||
* panneau s'ouvre sur une phase qu'on ne voit pas, et rien n'indique de
|
||||
* quel côté elle se trouve.
|
||||
*/
|
||||
function ouvrirPhase(idProjet, idPhase, { reveler = false, ...options } = {}) {
|
||||
const trouve = localiser(idProjet, idPhase);
|
||||
if (!trouve) return;
|
||||
// Les deux panneaux occupent le même bord de l'écran : ouvrir l'un ferme
|
||||
// l'autre.
|
||||
panneauProjet.fermer();
|
||||
selection = { projet: idProjet, phase: idPhase };
|
||||
panneau.ouvrir(trouve.projet, trouve.phase);
|
||||
panneau.ouvrir(trouve.projet, trouve.phase, options);
|
||||
dessiner();
|
||||
// Après l'ouverture du panneau : il recouvre le bord droit de la frise, et
|
||||
// `phaseVisible` doit en tenir compte.
|
||||
if (reveler && !phaseVisible(trouve.phase)) centrerSur(trouve.phase.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* La barre de la phase est-elle dans la portion réellement visible de la frise ?
|
||||
*
|
||||
* Deux occlusions à retrancher : la colonne des libellés, `sticky` à gauche, et
|
||||
* le panneau de détail quand il est ouvert, posé sur le bord droit.
|
||||
*/
|
||||
function phaseVisible(phase) {
|
||||
if (!echelle || refs.frise.hidden) return true;
|
||||
|
||||
const gauche = refs.frise.scrollLeft + largeurLibelles;
|
||||
const droite =
|
||||
refs.frise.scrollLeft +
|
||||
refs.frise.clientWidth -
|
||||
(refs.panneau.hidden ? 0 : refs.panneau.offsetWidth);
|
||||
|
||||
const debut = largeurLibelles + echelle.x(phase.start);
|
||||
const fin = largeurLibelles + echelle.x(phase.end) + PIXELS_PAR_JOUR;
|
||||
|
||||
return fin > gauche && debut < droite;
|
||||
}
|
||||
|
||||
// Clics sur la colonne des libellés : plier, masquer, ouvrir le menu ou la phase.
|
||||
@@ -408,7 +562,7 @@ refs.libelles.addEventListener('click', (evenement) => {
|
||||
|
||||
if (!cible) {
|
||||
const ligne = evenement.target.closest('.libelle-phase');
|
||||
if (ligne) ouvrirPhase(ligne.dataset.projet, ligne.dataset.phase);
|
||||
if (ligne) ouvrirPhase(ligne.dataset.projet, ligne.dataset.phase, { reveler: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -419,6 +573,18 @@ refs.libelles.addEventListener('click', (evenement) => {
|
||||
|
||||
const idProjet = cible.dataset.projet;
|
||||
|
||||
if (cible.dataset.action === 'parametres') {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (projet) ouvrirProjet(projet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cible.dataset.action === 'ajouter') {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (projet) ajouterUnePhase(projet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cible.dataset.action === 'plier') {
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (projet) => ({
|
||||
@@ -455,8 +621,7 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
||||
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
||||
|
||||
menu.ouvrir(ancre, [
|
||||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet, false) },
|
||||
{ libelle: 'Ajouter un jalon', action: () => ajouterUnePhase(projet, true) },
|
||||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
|
||||
{
|
||||
libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet',
|
||||
separateurAvant: true,
|
||||
@@ -470,7 +635,7 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
||||
action: () =>
|
||||
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
||||
},
|
||||
{ libelle: 'Renommer, changer la couleur…', action: () => renommerProjet(projet) },
|
||||
{ libelle: 'Renommer, changer la couleur…', action: () => ouvrirProjet(projet) },
|
||||
{
|
||||
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
|
||||
separateurAvant: true,
|
||||
@@ -491,18 +656,24 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
||||
]);
|
||||
}
|
||||
|
||||
function ajouterUnePhase(projet, jalon) {
|
||||
/**
|
||||
* Ajoute une phase et ouvre son panneau.
|
||||
*
|
||||
* Il n'y a **pas** de commande distincte pour créer un jalon : un jalon n'est
|
||||
* pas un autre objet, c'est une phase dont la fin rejoint le début — une case à
|
||||
* cocher du panneau, au même titre que son statut. Proposer deux entrées
|
||||
* revenait à faire choisir avant d'avoir saisi quoi que ce soit, alors que la
|
||||
* bascule reste possible à tout moment ensuite.
|
||||
*/
|
||||
function ajouterUnePhase(projet) {
|
||||
// On enchaîne à la suite de la dernière phase, ce qui est presque toujours ce
|
||||
// qu'on veut. Plutôt que de demander le nom dans un dialogue, on crée la
|
||||
// phase tout de suite et on ouvre le panneau de détail avec le nom
|
||||
// présélectionné : l'utilisateur tape directement par-dessus.
|
||||
const derniere = projet.phases[projet.phases.length - 1];
|
||||
const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
||||
const fin = jalon ? debut : ajouterJours(debut, 20);
|
||||
|
||||
const phase = creerPhase(projet, jalon ? 'Nouveau jalon' : 'Nouvelle phase', debut, fin, {
|
||||
milestone: jalon,
|
||||
});
|
||||
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
|
||||
|
||||
majPlanning(
|
||||
remplacerProjet(planning, projet.id, (p) => ({
|
||||
@@ -510,7 +681,7 @@ function ajouterUnePhase(projet, jalon) {
|
||||
collapsed: false, // inutile d'ajouter une phase dans un couloir replié
|
||||
}))
|
||||
);
|
||||
ouvrirPhase(projet.id, phase.id);
|
||||
ouvrirPhase(projet.id, phase.id, { nomAEcraser: true });
|
||||
|
||||
// La frise défile librement bien au-delà des phases existantes : sans ce
|
||||
// recentrage, une phase créée alors qu'on regardait 2029 apparaîtrait hors
|
||||
@@ -518,28 +689,42 @@ function ajouterUnePhase(projet, jalon) {
|
||||
centrerSur(phase.start);
|
||||
}
|
||||
|
||||
async function renommerProjet(projet) {
|
||||
const reponse = await demanderProjet({
|
||||
titreTexte: 'Modifier le projet',
|
||||
libelleValider: 'Enregistrer',
|
||||
nom: projet.name,
|
||||
couleur: projet.color,
|
||||
tags: projet.tags,
|
||||
tagsConnus: tousLesTags(planning),
|
||||
});
|
||||
if (!reponse) return;
|
||||
|
||||
// L'identifiant ne change pas : il identifie, il ne décrit pas.
|
||||
majPlanning(
|
||||
remplacerProjet(planning, projet.id, (p) => ({
|
||||
...p,
|
||||
name: reponse.nom,
|
||||
color: reponse.couleur,
|
||||
tags: reponse.tags,
|
||||
}))
|
||||
);
|
||||
/** Ouvre le panneau des paramètres d'un projet : nom, couleur, tags. */
|
||||
function ouvrirProjet(projet, options = {}) {
|
||||
panneau.fermer();
|
||||
panneauProjet.ouvrir(projet, options);
|
||||
dessiner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Éléments dont le propre gestionnaire ouvre ou rouvre un panneau. Un clic
|
||||
* dessus ne doit surtout pas passer par la fermeture générale ci-dessous : elle
|
||||
* redessine la frise, ce qui détacherait du DOM l'élément visé avant que son
|
||||
* gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail.
|
||||
*/
|
||||
const CIBLES_OUVRANTES =
|
||||
'[data-role="barre"], [data-role="poignee"], .libelle-phase, [data-action]';
|
||||
|
||||
/** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */
|
||||
const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne';
|
||||
|
||||
/**
|
||||
* Un clic dans le vide referme le panneau ouvert.
|
||||
*
|
||||
* C'est ce que remplace le voile supprimé en décision 17 : celui-ci fermait au
|
||||
* clic à côté, mais au prix d'une frise inerte. En filtrant les cibles plutôt
|
||||
* qu'en interposant un calque, on garde les deux — la frise reste manipulable,
|
||||
* et cliquer à côté ferme quand même.
|
||||
*/
|
||||
document.addEventListener('pointerdown', (evenement) => {
|
||||
if (evenement.button !== 0) return;
|
||||
if (evenement.target.closest(CIBLES_PROTEGEES)) return;
|
||||
if (evenement.target.closest(CIBLES_OUVRANTES)) return;
|
||||
|
||||
panneau.fermer();
|
||||
panneauProjet.fermer();
|
||||
});
|
||||
|
||||
async function figerLaReference(projet, dejaFigee) {
|
||||
const ok = await demanderConfirmation({
|
||||
titreTexte: dejaFigee ? 'Refiger la référence' : 'Figer la référence',
|
||||
@@ -565,9 +750,10 @@ async function retirerLaReference(projet) {
|
||||
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => libererReference(p)));
|
||||
}
|
||||
|
||||
async function supprimerLeProjet(projet) {
|
||||
/** Confirmation partagée par le menu du projet et son panneau. */
|
||||
function confirmerSuppressionProjet(projet) {
|
||||
const nombre = projet.phases.length;
|
||||
const ok = await demanderConfirmation({
|
||||
return demanderConfirmation({
|
||||
titreTexte: 'Supprimer le projet',
|
||||
messageTexte:
|
||||
`« ${projet.name} »` +
|
||||
@@ -576,30 +762,33 @@ async function supprimerLeProjet(projet) {
|
||||
libelleValider: 'Supprimer',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
async function supprimerLeProjet(projet) {
|
||||
if (!(await confirmerSuppressionProjet(projet))) return;
|
||||
|
||||
if (selection?.projet === projet.id) panneau.fermer();
|
||||
if (panneauProjet.projetOuvert() === projet.id) panneauProjet.fermer();
|
||||
majPlanning(supprimerProjet(planning, projet.id));
|
||||
}
|
||||
|
||||
async function nouveauProjet() {
|
||||
const reponse = await demanderProjet({
|
||||
titreTexte: 'Nouveau projet',
|
||||
libelleValider: 'Créer',
|
||||
couleur: COULEURS[planning.projects.length % COULEURS.length],
|
||||
// Un filtre actif préremplit les tags : quand on crée un projet en ne
|
||||
// regardant que les projets « client », c'en est presque toujours un — et
|
||||
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
||||
tags: tagsActifs,
|
||||
tagsConnus: tousLesTags(planning),
|
||||
});
|
||||
if (!reponse) return;
|
||||
|
||||
const projet = {
|
||||
...creerProjet(planning, reponse.nom, reponse.tags),
|
||||
color: reponse.couleur,
|
||||
};
|
||||
/**
|
||||
* Crée un projet et ouvre ses paramètres, sans passer par un formulaire à
|
||||
* valider.
|
||||
*
|
||||
* Même parti pris que pour l'ajout d'une phase (docs/decisions.md, section 13) :
|
||||
* le projet est créé tout de suite avec un nom provisoire, et le panneau s'ouvre
|
||||
* avec ce nom présélectionné — on tape simplement par-dessus. Un dialogue de
|
||||
* plus n'apportait rien, et il présentait les mêmes champs sous une autre forme
|
||||
* que celle qui sert ensuite à les modifier.
|
||||
*/
|
||||
function nouveauProjet() {
|
||||
// Un filtre actif préremplit les tags : quand on crée un projet en ne
|
||||
// regardant que les projets « client », c'en est presque toujours un — et
|
||||
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
||||
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
|
||||
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
||||
ouvrirProjet(projet, { nomAEcraser: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -625,7 +814,6 @@ function centrerSur(date) {
|
||||
// des libellés. Comme elle est `sticky`, elle recouvre en permanence les
|
||||
// premiers pixels de la vue : le centre utile des couloirs est donc décalé
|
||||
// d'une demi-largeur de colonne par rapport au centre de la frise.
|
||||
const largeurLibelles = refs.libelles.offsetWidth;
|
||||
const centreUtile = (largeurLibelles + refs.frise.clientWidth) / 2;
|
||||
|
||||
refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile);
|
||||
|
||||
33
js/detail.js
33
js/detail.js
@@ -8,12 +8,15 @@
|
||||
* C'est aussi ici qu'on saisit une date exacte : le glisser sur la frise
|
||||
* s'accroche au lundi, ce panneau est le recours quand une phase doit démarrer
|
||||
* un jeudi précis.
|
||||
*
|
||||
* Le panneau n'est **pas modal** : rien ne recouvre la frise pendant qu'il est
|
||||
* ouvert. Voir docs/decisions.md, section 17.
|
||||
*/
|
||||
|
||||
import { STATUTS, dateValide, modifierPhase } from './model.js';
|
||||
|
||||
export function creerPanneau(refs, rappels) {
|
||||
const { panneau, voile, formulaire, titre, erreur, champFin, supprimer, fermer } = refs;
|
||||
const { panneau, formulaire, titre, erreur, champFin, supprimer, fermer } = refs;
|
||||
|
||||
let selection = null; // { projet, phase }
|
||||
let phaseCourante = null;
|
||||
@@ -106,7 +109,6 @@ export function creerPanneau(refs, rappels) {
|
||||
});
|
||||
|
||||
fermer.addEventListener('click', () => api.fermer());
|
||||
voile.addEventListener('click', () => api.fermer());
|
||||
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
// Échap ferme le panneau, sauf pendant une saisie où il annule d'abord la
|
||||
@@ -117,14 +119,29 @@ export function creerPanneau(refs, rappels) {
|
||||
});
|
||||
|
||||
const api = {
|
||||
ouvrir(projet, phase) {
|
||||
/**
|
||||
* @param {object} options
|
||||
* - nomAEcraser : place le curseur dans le champ « Nom », texte
|
||||
* présélectionné. Réservé à une phase qui vient d'être créée, dont le
|
||||
* nom provisoire est justement là pour être remplacé.
|
||||
*
|
||||
* Hors de ce cas, le focus se pose sur le panneau lui-même et non dans un
|
||||
* champ : `app.js` ignore les flèches quand la frappe part dans un `input`,
|
||||
* et le raccourci le plus utile de l'outil — décaler la phase d'un jour —
|
||||
* serait inaccessible juste après l'avoir sélectionnée.
|
||||
*/
|
||||
ouvrir(projet, phase, { nomAEcraser = false } = {}) {
|
||||
selection = { projet: projet.id, phase: phase.id };
|
||||
phaseCourante = phase;
|
||||
remplir(projet, phase);
|
||||
panneau.hidden = false;
|
||||
voile.hidden = false;
|
||||
formulaire.name.focus();
|
||||
formulaire.name.select();
|
||||
|
||||
if (nomAEcraser) {
|
||||
formulaire.name.focus();
|
||||
formulaire.name.select();
|
||||
} else {
|
||||
panneau.focus();
|
||||
}
|
||||
},
|
||||
|
||||
/** Recharge le contenu après une modification venue de la frise. */
|
||||
@@ -143,10 +160,12 @@ export function creerPanneau(refs, rappels) {
|
||||
},
|
||||
|
||||
fermer() {
|
||||
// Sans cette garde, fermer un panneau déjà fermé relancerait un rendu
|
||||
// complet de la frise — ce qui arriverait à chaque clic dans le vide.
|
||||
if (panneau.hidden) return;
|
||||
selection = null;
|
||||
phaseCourante = null;
|
||||
panneau.hidden = true;
|
||||
voile.hidden = true;
|
||||
rappels.surFermeture();
|
||||
},
|
||||
|
||||
|
||||
121
js/menu.js
121
js/menu.js
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Menu contextuel et boîtes de dialogue.
|
||||
* Menu contextuel et dialogue de confirmation.
|
||||
*
|
||||
* 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.
|
||||
* Le dialogue s'appuie sur l'élément `<dialog>` natif, qui apporte gratuitement
|
||||
* le voile, le piège à focus, la fermeture par Échap et la restauration du
|
||||
* focus. Il ne reste que pour la confirmation d'une action irréversible, seul
|
||||
* cas où interrompre franchement est le but : les paramètres d'un projet sont
|
||||
* passés à un panneau (projet.js).
|
||||
*/
|
||||
|
||||
const MARGE_ECRAN = 8;
|
||||
@@ -134,117 +136,6 @@ function positionner(menu, ancre) {
|
||||
// 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>}
|
||||
|
||||
236
js/projet.js
Normal file
236
js/projet.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Panneau des paramètres d'un projet : nom, couleur, tags.
|
||||
*
|
||||
* Jumeau de `detail.js`, et volontairement : renommer un projet et renommer une
|
||||
* phase sont la même opération à un niveau de granularité près. Elles se
|
||||
* présentent donc pareil — même panneau latéral, même édition au fil de la
|
||||
* saisie, même bouton de suppression au bas du formulaire.
|
||||
*
|
||||
* Ces paramètres passaient auparavant par un `<dialog>` modal à valider. Voir
|
||||
* docs/decisions.md, section 21.
|
||||
*/
|
||||
|
||||
export function creerPanneauProjet(refs, outils, rappels) {
|
||||
const {
|
||||
panneau,
|
||||
formulaire,
|
||||
titre,
|
||||
erreur,
|
||||
conteneurCouleurs,
|
||||
suggestions,
|
||||
blocSuggestions,
|
||||
supprimer,
|
||||
fermer,
|
||||
} = refs;
|
||||
const { couleurs, normaliserTags, cleTag, teinteTag } = outils;
|
||||
|
||||
let idCourant = null;
|
||||
let projetCourant = null;
|
||||
|
||||
function afficherErreur(message) {
|
||||
erreur.textContent = message || '';
|
||||
erreur.hidden = !message;
|
||||
}
|
||||
|
||||
/** Tags saisis dans le champ, tels qu'ils seront enregistrés. */
|
||||
function tagsSaisis() {
|
||||
return normaliserTags(formulaire.tags.value.split(','));
|
||||
}
|
||||
|
||||
/**
|
||||
* La palette est bâtie une fois pour toutes, et seul l'état pressé change
|
||||
* ensuite. La reconstruire à chaque frappe ferait perdre le focus à qui
|
||||
* parcourt les couleurs au clavier — et l'édition en direct provoque un
|
||||
* rafraîchissement à chaque caractère saisi dans le nom.
|
||||
*/
|
||||
function peindrePalette(choisie) {
|
||||
if (!conteneurCouleurs.childElementCount) {
|
||||
for (const couleur of couleurs) {
|
||||
const bouton = document.createElement('button');
|
||||
bouton.type = 'button';
|
||||
bouton.className = 'pastille';
|
||||
bouton.dataset.couleur = couleur;
|
||||
bouton.style.background = couleur;
|
||||
bouton.setAttribute('aria-label', `Couleur ${couleur}`);
|
||||
// Une couleur se choisit d'un clic et s'applique aussitôt : la frise
|
||||
// change sous les yeux, ce qui est le seul moyen de juger si la teinte
|
||||
// se distingue bien de celle des couloirs voisins.
|
||||
bouton.addEventListener('click', () => appliquer({ color: couleur }));
|
||||
conteneurCouleurs.append(bouton);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bouton of conteneurCouleurs.children) {
|
||||
bouton.setAttribute('aria-pressed', String(bouton.dataset.couleur === choisie));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const tagsConnus = rappels.tagsConnus();
|
||||
// La liste change dès qu'un tag apparaît ou disparaît du planning : on la
|
||||
// reconstruit, mais on rend le focus au bouton de même rang, sans quoi
|
||||
// cliquer une suggestion au clavier éjecterait vers le début du document.
|
||||
const focalise = [...suggestions.children].indexOf(document.activeElement);
|
||||
suggestions.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 saisis = tagsSaisis();
|
||||
const restants = saisis.filter((pose) => cleTag(pose) !== cleTag(tag));
|
||||
const suivants = restants.length === saisis.length ? [...saisis, tag] : restants;
|
||||
formulaire.tags.value = normaliserTags(suivants).join(', ');
|
||||
appliquer();
|
||||
});
|
||||
suggestions.append(bouton);
|
||||
}
|
||||
|
||||
if (focalise >= 0) suggestions.children[focalise]?.focus();
|
||||
blocSuggestions.hidden = tagsConnus.length === 0;
|
||||
}
|
||||
|
||||
function remplir(projet) {
|
||||
titre.textContent = projet.name;
|
||||
formulaire.name.value = projet.name;
|
||||
formulaire.tags.value = projet.tags.join(', ');
|
||||
peindrePalette(projet.color);
|
||||
peindreSuggestions();
|
||||
afficherErreur(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le formulaire et remonte le changement.
|
||||
*
|
||||
* @param {object} forces champs imposés hors formulaire — la couleur, qui se
|
||||
* choisit dans la palette et n'a pas de champ de saisie.
|
||||
*/
|
||||
function appliquer(forces = {}) {
|
||||
if (!idCourant || !projetCourant) return;
|
||||
|
||||
const nom = formulaire.name.value.trim();
|
||||
if (!nom) {
|
||||
// Le projet garde son nom précédent : on signale sans rien écraser, comme
|
||||
// le fait le panneau d'une phase.
|
||||
afficherErreur('Le nom ne peut pas être vide.');
|
||||
return;
|
||||
}
|
||||
afficherErreur(null);
|
||||
|
||||
const suivant = {
|
||||
...projetCourant,
|
||||
name: nom,
|
||||
tags: tagsSaisis(),
|
||||
...forces,
|
||||
};
|
||||
|
||||
// Comparaison terme à terme plutôt que par concaténation : un tag peut
|
||||
// contenir une espace, et « a b » ne doit pas passer pour « a », « b ».
|
||||
const memesTags =
|
||||
suivant.tags.length === projetCourant.tags.length &&
|
||||
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
|
||||
|
||||
if (
|
||||
suivant.name === projetCourant.name &&
|
||||
suivant.color === projetCourant.color &&
|
||||
memesTags
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
projetCourant = suivant;
|
||||
titre.textContent = suivant.name;
|
||||
peindrePalette(suivant.color);
|
||||
peindreSuggestions();
|
||||
rappels.surModification(idCourant, suivant);
|
||||
}
|
||||
|
||||
formulaire.addEventListener('input', () => appliquer());
|
||||
formulaire.addEventListener('change', () => appliquer());
|
||||
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
|
||||
|
||||
supprimer.addEventListener('click', async () => {
|
||||
if (!idCourant) return;
|
||||
const cible = idCourant;
|
||||
const projet = projetCourant;
|
||||
|
||||
if (!(await rappels.demanderSuppression(cible, projet))) return;
|
||||
|
||||
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
|
||||
if (idCourant !== cible) return;
|
||||
|
||||
api.fermer();
|
||||
rappels.surSuppression(cible);
|
||||
});
|
||||
|
||||
fermer.addEventListener('click', () => api.fermer());
|
||||
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (evenement.key === 'Escape' && !panneau.hidden) api.fermer();
|
||||
});
|
||||
|
||||
const api = {
|
||||
/**
|
||||
* @param {object} options
|
||||
* - nomAEcraser : place le curseur dans le champ « Nom », texte
|
||||
* présélectionné. Réservé à un projet qui vient d'être créé, dont le nom
|
||||
* provisoire est justement là pour être remplacé.
|
||||
*/
|
||||
ouvrir(projet, { nomAEcraser = false } = {}) {
|
||||
idCourant = projet.id;
|
||||
projetCourant = projet;
|
||||
remplir(projet);
|
||||
panneau.hidden = false;
|
||||
|
||||
if (nomAEcraser) {
|
||||
formulaire.name.focus();
|
||||
formulaire.name.select();
|
||||
} else {
|
||||
panneau.focus();
|
||||
}
|
||||
},
|
||||
|
||||
/** Recharge le contenu après une modification venue d'ailleurs. */
|
||||
rafraichir(projet) {
|
||||
if (idCourant !== projet.id) return;
|
||||
projetCourant = projet;
|
||||
titre.textContent = projet.name;
|
||||
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur
|
||||
// au milieu d'une frappe.
|
||||
const actif = document.activeElement;
|
||||
if (formulaire.name !== actif) formulaire.name.value = projet.name;
|
||||
if (formulaire.tags !== actif) formulaire.tags.value = projet.tags.join(', ');
|
||||
peindrePalette(projet.color);
|
||||
peindreSuggestions();
|
||||
},
|
||||
|
||||
fermer() {
|
||||
if (panneau.hidden) return;
|
||||
idCourant = null;
|
||||
projetCourant = null;
|
||||
panneau.hidden = true;
|
||||
rappels.surFermeture();
|
||||
},
|
||||
|
||||
estOuvert() {
|
||||
return idCourant !== null;
|
||||
},
|
||||
|
||||
projetOuvert() {
|
||||
return idCourant;
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
118
js/timeline.js
118
js/timeline.js
@@ -328,11 +328,16 @@ function libelleProjet(projet, tagsActifs = []) {
|
||||
const nom = document.createElement('span');
|
||||
nom.className = 'libelle-projet__nom';
|
||||
nom.textContent = projet.name;
|
||||
nom.dataset.action = 'parametres';
|
||||
nom.dataset.projet = projet.id;
|
||||
// Les pastilles rognent le nom quand la colonne se remplit : l'infobulle
|
||||
// reste le seul endroit où les deux se lisent toujours en entier.
|
||||
nom.title = projet.tags.length
|
||||
? `${projet.name}\nTags : ${projet.tags.join(', ')}`
|
||||
: projet.name;
|
||||
nom.title =
|
||||
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
|
||||
// Le grisé de la ligne dit qu'il se passe quelque chose ; encore faut-il
|
||||
// pouvoir apprendre quoi, et comment revenir en arrière.
|
||||
(projet.hidden ? '\n\nMasqué de la frise — voir le menu ⋯ pour le rétablir.' : '') +
|
||||
'\n\nCliquer pour renommer, changer la couleur et les tags.';
|
||||
ligne.append(nom);
|
||||
|
||||
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
|
||||
@@ -349,19 +354,30 @@ function libelleProjet(projet, tagsActifs = []) {
|
||||
ligne.append(etiquette);
|
||||
}
|
||||
|
||||
const oeil = document.createElement('button');
|
||||
oeil.className = 'icone-oeil';
|
||||
oeil.type = 'button';
|
||||
oeil.dataset.action = 'masquer';
|
||||
oeil.dataset.projet = projet.id;
|
||||
oeil.textContent = projet.hidden ? '🚫' : '👁';
|
||||
oeil.setAttribute('aria-label', projet.hidden ? `Afficher ${projet.name}` : `Masquer ${projet.name}`);
|
||||
ligne.append(oeil);
|
||||
// Les commandes sont posées *au-dessus* de la ligne, révélées au survol,
|
||||
// plutôt que rangées dans son flux. Elles y occupaient une soixantaine de
|
||||
// pixels en permanence — de quoi tronquer le nom de tous les projets dans une
|
||||
// colonne de largeur courante. En surimpression, le nom dispose de toute la
|
||||
// colonne et rien ne se déplace à l'approche du curseur.
|
||||
const commandes = document.createElement('span');
|
||||
commandes.className = 'libelle-projet__commandes';
|
||||
|
||||
// Ajouter une phase est de loin l'action la plus fréquente : elle mérite son
|
||||
// bouton, pas une entrée à aller chercher dans un menu.
|
||||
const ajout = document.createElement('button');
|
||||
ajout.className = 'commande-projet';
|
||||
ajout.type = 'button';
|
||||
ajout.dataset.action = 'ajouter';
|
||||
ajout.dataset.projet = projet.id;
|
||||
ajout.textContent = '+';
|
||||
ajout.title = `Ajouter une phase à ${projet.name}`;
|
||||
ajout.setAttribute('aria-label', ajout.title);
|
||||
commandes.append(ajout);
|
||||
|
||||
// Bouton de menu explicite : les actions du projet passaient auparavant par
|
||||
// un clic sur son nom, ce que rien n'indiquait.
|
||||
const menu = document.createElement('button');
|
||||
menu.className = 'menu-projet';
|
||||
menu.className = 'commande-projet';
|
||||
menu.type = 'button';
|
||||
menu.dataset.action = 'menu';
|
||||
menu.dataset.projet = projet.id;
|
||||
@@ -369,7 +385,9 @@ function libelleProjet(projet, tagsActifs = []) {
|
||||
menu.title = `Actions sur ${projet.name}`;
|
||||
menu.setAttribute('aria-label', `Actions sur ${projet.name}`);
|
||||
menu.setAttribute('aria-haspopup', 'menu');
|
||||
ligne.append(menu);
|
||||
commandes.append(menu);
|
||||
|
||||
ligne.append(commandes);
|
||||
|
||||
return ligne;
|
||||
}
|
||||
@@ -381,7 +399,15 @@ function libelleProjet(projet, tagsActifs = []) {
|
||||
* recliquer l'en retire. C'est le geste le plus court pour passer d'un projet
|
||||
* repéré à « montre-moi tous ceux qui lui ressemblent », sans remonter à la
|
||||
* barre de filtres.
|
||||
*
|
||||
* Au-delà de `PASTILLES_VISIBLES`, les tags se résument à un compteur. La
|
||||
* colonne est étroite et disputée : montrer quatre pastilles y revenait à les
|
||||
* rogner toutes jusqu'à l'initiale, ce qui coûtait la place d'un tag sans en
|
||||
* livrer le nom. Les tags masqués restent lisibles dans l'infobulle, et tous
|
||||
* sont cliquables depuis la barre de filtres.
|
||||
*/
|
||||
const PASTILLES_VISIBLES = 2;
|
||||
|
||||
function pastillesTags(tags, tagsActifs) {
|
||||
const conteneur = document.createElement('span');
|
||||
conteneur.className = 'tags';
|
||||
@@ -390,7 +416,16 @@ function pastillesTags(tags, tagsActifs) {
|
||||
// c'est la clé, pas la graphie, qui décide s'il est actif.
|
||||
const clesActives = new Set(tagsActifs.map(cleTag));
|
||||
|
||||
for (const tag of tags) {
|
||||
// Un tag présent dans le filtre courant passe devant : c'est celui qui
|
||||
// explique pourquoi le projet est à l'écran, il ne doit pas tomber dans le
|
||||
// compteur.
|
||||
const ordonnes = [...tags].sort(
|
||||
(a, b) => Number(clesActives.has(cleTag(b))) - Number(clesActives.has(cleTag(a)))
|
||||
);
|
||||
const montres = ordonnes.slice(0, PASTILLES_VISIBLES);
|
||||
const restants = ordonnes.slice(PASTILLES_VISIBLES);
|
||||
|
||||
for (const tag of montres) {
|
||||
const pastille = document.createElement('button');
|
||||
pastille.type = 'button';
|
||||
pastille.className = 'tag';
|
||||
@@ -406,6 +441,14 @@ function pastillesTags(tags, tagsActifs) {
|
||||
conteneur.append(pastille);
|
||||
}
|
||||
|
||||
if (restants.length) {
|
||||
const compteur = document.createElement('span');
|
||||
compteur.className = 'tag--reste';
|
||||
compteur.textContent = `+${restants.length}`;
|
||||
compteur.title = restants.join(', ');
|
||||
conteneur.append(compteur);
|
||||
}
|
||||
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
@@ -421,12 +464,14 @@ function construireCouloirs(projets, echelle, selection) {
|
||||
couloirProjet.className = 'couloir couloir--projet';
|
||||
couloirProjet.style.setProperty('--couleur-projet', projet.color);
|
||||
|
||||
if (projet.collapsed) {
|
||||
const barre = construireBarrePliee(projet, echelle);
|
||||
if (barre) couloirProjet.append(barre);
|
||||
const fantome = construireFantome(projet, echelle);
|
||||
if (fantome) couloirProjet.append(fantome);
|
||||
}
|
||||
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
|
||||
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
|
||||
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
|
||||
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
|
||||
const barre = construireBarreCumulative(projet, echelle);
|
||||
if (barre) couloirProjet.append(barre);
|
||||
const fantome = construireFantome(projet, echelle);
|
||||
if (fantome) couloirProjet.append(fantome);
|
||||
|
||||
elements.push(couloirProjet);
|
||||
|
||||
@@ -486,12 +531,20 @@ function construirePhase(projet, phase, echelle, selection) {
|
||||
barre.dataset.projet = projet.id;
|
||||
barre.dataset.phase = phase.id;
|
||||
barre.dataset.role = 'barre';
|
||||
barre.textContent = phase.name;
|
||||
barre.title =
|
||||
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
||||
(phase.notes ? `\n\n${phase.notes}` : '');
|
||||
|
||||
// Le nom vit dans son propre élément plutôt que directement dans la barre :
|
||||
// il est `sticky`, ce qui le maintient visible quand la barre s'enfonce sous
|
||||
// la colonne des libellés. Une phase de plusieurs mois pouvait autrement
|
||||
// occuper tout l'écran sans qu'on lise son nom, parti hors cadre à gauche.
|
||||
const nom = document.createElement('span');
|
||||
nom.className = 'barre__nom';
|
||||
nom.textContent = phase.name;
|
||||
barre.append(nom);
|
||||
|
||||
for (const bord of ['debut', 'fin']) {
|
||||
const poignee = document.createElement('div');
|
||||
poignee.className = `poignee poignee--${bord}`;
|
||||
@@ -505,15 +558,20 @@ function construirePhase(projet, phase, echelle, selection) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Barre unique du mode plié, segmentée par phase.
|
||||
* Lecture seule : aucun `data-role`, donc drag.js l'ignore.
|
||||
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
|
||||
*
|
||||
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
|
||||
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
|
||||
*/
|
||||
function construireBarrePliee(projet, echelle) {
|
||||
function construireBarreCumulative(projet, echelle) {
|
||||
const bornes = bornesProjet(projet);
|
||||
if (!bornes) return null;
|
||||
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.className = 'barre-pliee';
|
||||
// Dépliée, elle voisine avec les barres de phases : elle s'amincit pour
|
||||
// qu'on ne la prenne pas pour l'une d'elles et qu'on n'essaie pas de la
|
||||
// glisser. Pliée, elle est seule sur sa ligne et porte toute l'information.
|
||||
conteneur.className = `barre-cumulative${projet.collapsed ? '' : ' barre-cumulative--fine'}`;
|
||||
conteneur.style.left = `${echelle.x(bornes.start)}px`;
|
||||
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
||||
|
||||
@@ -522,7 +580,9 @@ function construireBarrePliee(projet, echelle) {
|
||||
`${projet.name}\n${formaterDateLongue(bornes.start)} → ${formaterDateLongue(bornes.end)}` +
|
||||
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
|
||||
(derive ? `\nÉcart à la référence : ${derive}` : '') +
|
||||
'\n\nDéplier le projet pour modifier les dates.';
|
||||
// Déplié, les barres modifiables sont juste en dessous : le dire serait
|
||||
// enfoncer une porte ouverte.
|
||||
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
|
||||
|
||||
const origine = echelle.x(bornes.start);
|
||||
for (const phase of projet.phases) {
|
||||
@@ -539,13 +599,15 @@ function construireBarrePliee(projet, echelle) {
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
/** Barre fantôme du projet plié, reflétant l'étendue de référence. */
|
||||
/** Barre fantôme du projet, reflétant l'étendue de référence. */
|
||||
function construireFantome(projet, echelle) {
|
||||
const reference = bornesReference(projet);
|
||||
if (!reference) return null;
|
||||
|
||||
const fantome = document.createElement('div');
|
||||
fantome.className = 'fantome';
|
||||
// Le fantôme se cale sous la barre qu'il double : il doit suivre celle-ci
|
||||
// quand elle s'amincit, sans quoi il flotterait sous elle.
|
||||
fantome.className = `fantome${projet.collapsed ? '' : ' fantome--fin'}`;
|
||||
fantome.style.left = `${echelle.x(reference.start)}px`;
|
||||
fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`;
|
||||
fantome.title = `Référence : ${formaterDateLongue(reference.start)} → ${formaterDateLongue(reference.end)}`;
|
||||
|
||||
Reference in New Issue
Block a user