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:
2026-08-01 10:42:35 +02:00
parent a2a5c5cb9c
commit eaca2f7813
9 changed files with 1032 additions and 333 deletions

330
js/app.js
View File

@@ -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);

View File

@@ -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();
},

View File

@@ -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
View 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;
}

View File

@@ -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)}`;