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

View File

@@ -33,12 +33,26 @@ cp data/exemple.json data/projets.json
## Prise en main
Chaque projet porte trois commandes dans la colonne de gauche :
**Nouveau projet** crée le projet aussitôt, sous un nom provisoire, et ouvre ses paramètres avec
ce nom présélectionné : on tape simplement par-dessus. Il n'y a aucun formulaire à valider — ni
ici, ni pour une phase. Tout s'applique au fil de la saisie, et un projet créé par erreur se
supprime depuis son propre panneau.
- **le triangle** plie et déplie son couloir ;
- **l'œil** le masque de la frise sans le supprimer ;
- **`⋯`** ouvre son menu : ajouter une phase ou un jalon, renommer, changer la couleur et les
tags, figer la référence, supprimer le projet. Un clic droit sur le libellé ouvre le même menu.
Dans la colonne de gauche, le **triangle** plie et déplie le couloir d'un projet, et un clic sur
son **nom** ouvre ses paramètres : nom, couleur, tags. Deux commandes apparaissent au survol de
la ligne, sur son bord droit :
- **`+`** ajoute une phase à la suite de la dernière, et ouvre son panneau de détail ;
- **`⋯`** ouvre le menu du projet : ajouter une phase, plier, masquer de la frise, renommer,
figer la référence, supprimer. Un clic droit sur le libellé ouvre le même menu.
Il n'y a pas de commande séparée pour créer un **jalon** : c'est une phase dont on coche la case
« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens
à tout moment.
La colonne se **redimensionne** en glissant la ligne qui la sépare de la frise, en haut à
gauche : un double-clic revient à la largeur d'origine. Cette largeur est propre au navigateur,
elle n'est pas enregistrée dans le planning.
Une fois un projet en place :
@@ -47,9 +61,12 @@ Une fois un projet en place :
| Glisser le corps d'une barre | Décale la phase, durée conservée |
| Glisser le bord d'une barre | Change la date de début ou de fin |
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
| Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase |
| Clic sur un nom de phase hors écran | Ramène la frise sur elle |
| `←` `→` | Décale d'un jour la phase sélectionnée |
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
| Clic dans une zone vide | Ferme le panneau ouvert |
| `Maj` + molette | Fait défiler la frise horizontalement |
| `↑` `↓` dans un menu | Parcourt les entrées |
@@ -79,8 +96,10 @@ par les champs du panneau de détail.
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
un statut, des notes libres. Une phase peut être un **jalon** (une date unique, rendue en losange).
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
- **Couloirs pliables** : déplié, on voit chaque phase sur sa ligne ; plié, le projet devient une
barre unique segmentée en teintes, qui donne la forme d'ensemble sans le détail.
- **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la
forme d'ensemble, toujours visible sur la ligne du projet. Elle ne se glisse pas.
- **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il
ne reste que celle-ci, en plus épais.
- **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa
durée. Les barres s'accrochent au lundi ; pour une date exacte, on passe par le panneau de détail.
- **Planning de référence** : une fois le planning validé, on le fige. La frise affiche alors une
@@ -142,8 +161,9 @@ Node suffit.
| `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état |
| `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli |
| `js/drag.js` | Glisser et redimensionner les barres |
| `js/detail.js` | Panneau de détail |
| `js/menu.js` | Menu contextuel et boîtes de dialogue |
| `js/detail.js` | Panneau de détail d'une phase |
| `js/projet.js` | Panneau des paramètres d'un projet : nom, couleur, tags |
| `js/menu.js` | Menu contextuel et dialogue de confirmation |
| `js/app.js` | Amorçage, état en mémoire, câblage des événements |
Le rendu utilise des éléments DOM positionnés en absolu, pas de SVG ni de bibliothèque Gantt.

View File

@@ -25,7 +25,9 @@
--hauteur-barre: 26px;
--hauteur-ligne: 38px;
--hauteur-entete: 66px; /* trois bandes de 22 px : année, mois, semaine */
--largeur-libelles: 250px;
/* Doit rester en accord avec `LARGEUR_LIBELLES.defaut` (app.js), sur lequel
retombe le double-clic de la poignée. */
--largeur-libelles: 300px;
--rayon: 6px;
--ombre: 0 1px 2px rgb(15 23 42 / 0.08);
@@ -268,6 +270,37 @@ button[aria-pressed="true"] {
border-bottom: 1px solid var(--bordure);
}
/* Zone de saisie large de 9 px pour un trait qui n'en fait qu'un : viser une
bordure d'un pixel à la souris est un exercice de patience. */
.poignee-colonne {
position: absolute;
top: 0;
bottom: 0;
right: -4px;
width: 9px;
cursor: col-resize;
z-index: 7;
}
.poignee-colonne::after {
content: "";
position: absolute;
inset: 0 4px;
background: var(--accent);
opacity: 0;
transition: opacity 0.12s;
}
.poignee-colonne:hover::after,
.poignee-colonne:focus-visible::after,
.poignee-colonne[data-glisse="true"]::after {
opacity: 1;
}
.poignee-colonne:focus-visible {
outline: none;
}
.grille__entete {
position: sticky;
top: 0;
@@ -369,6 +402,9 @@ button[aria-pressed="true"] {
/* --- libellés ------------------------------------------------------------ */
.libelle-projet {
--fond-libelle: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface));
position: relative;
display: flex;
align-items: center;
gap: 6px;
@@ -376,25 +412,72 @@ button[aria-pressed="true"] {
padding: 0 8px 0 6px;
font-weight: 600;
border-bottom: 1px solid var(--bordure);
background: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface));
background: var(--fond-libelle);
}
/* Commandes secondaires, en surimpression sur le bord droit de la ligne. Le
dégradé leur donne un fond opaque sans trancher : le nom qu'elles recouvrent
s'estompe au lieu d'être coupé net. */
.libelle-projet__commandes {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px 0 18px;
opacity: 0;
background: linear-gradient(to right, transparent, var(--fond-libelle) 18px);
transition: opacity 0.12s;
}
.libelle-projet:hover .libelle-projet__commandes,
.libelle-projet:focus-within .libelle-projet__commandes {
opacity: 1;
}
/* Un projet masqué garde ses commandes visibles : le menu est le seul chemin
pour le rétablir, il ne doit pas falloir le deviner sous le curseur. */
.libelle-projet--masque .libelle-projet__commandes {
opacity: 1;
}
/* Le nom est ce qui identifie le projet : il cède en dernier, et jamais
au-delà d'une largeur où il reste reconnaissable. Sous l'ancienne règle
(`min-width: 4em`), les tags et l'étiquette de dérive le réduisaient à
« Refon… » dans une colonne pourtant deux fois trop large pour lui. */
.libelle-projet__nom {
flex: 1;
/* Le nom cède la place aux pastilles, mais jamais entièrement : sous quatre
caractères il ne resterait qu'un « … » qui n'identifie plus rien. */
min-width: 4em;
flex: 1 1 auto;
min-width: 7em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
/* Les pastilles rognent le nom, pas l'inverse — mais elles s'effacent à leur
tour plutôt que de repousser l'œil et le menu hors de la colonne. */
.libelle-projet .tags {
flex: 0 1 auto;
max-width: 45%;
max-width: 40%;
}
/* Une pastille écrasée à « c. » occupe la place d'un tag sans en donner
l'information. Elle garde donc une largeur minimale et cède en bloc :
`pastillesTags` n'en affiche qu'un nombre fixe, suivies d'un compteur. */
.libelle-projet .tag {
flex: 0 0 auto;
min-width: 3.4em;
text-align: center;
}
.tag--reste {
flex: 0 0 auto;
align-self: center;
font-size: 10px;
font-weight: 500;
color: var(--texte-doux);
white-space: nowrap;
cursor: default;
}
.libelle-projet__derive {
@@ -429,19 +512,6 @@ button[aria-pressed="true"] {
transform: rotate(90deg);
}
.icone-oeil {
border: 0;
background: none;
padding: 2px 4px;
cursor: pointer;
opacity: 0.45;
font-size: 12px;
}
.icone-oeil:hover {
opacity: 1;
}
/* `display: block` et non `flex` : `text-overflow: ellipsis` ne s'applique pas
au texte d'un conteneur flex, et le nom serait coupé net. Le centrage
vertical passe donc par `line-height`. */
@@ -487,16 +557,36 @@ button[aria-pressed="true"] {
color: #fff;
font-size: 12px;
line-height: var(--hauteur-barre);
padding: 0 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: grab;
box-shadow: var(--ombre);
user-select: none;
z-index: 2;
}
/* Le nom se décale pour rester au bord de la vue quand la barre s'enfonce sous
la colonne des libellés — le cas d'une phase longue dont on ne voit que la
fin. Trois conditions le rendent possible, et aucune n'est décorative :
- `sticky` s'ancre au conteneur défilant le plus proche. Un `overflow:
hidden` sur `.barre` en ferait ce conteneur, et le décalage n'aurait plus
lieu : le rognage du texte trop long est donc porté ici, pas sur la barre.
- le décalage est plafonné par la boîte du parent, donc le nom ne sort jamais
de sa barre — d'où `inline-block`, qui laisse au texte la marge de manœuvre
qu'un bloc pleine largeur n'aurait pas.
- `left` vaut la largeur de la colonne des libellés : c'est là que commence
la portion réellement visible de la frise. */
.barre__nom {
position: sticky;
left: var(--largeur-libelles);
display: inline-block;
max-width: 100%;
padding: 0 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: top;
pointer-events: none;
}
.barre:hover {
filter: brightness(1.08);
}
@@ -604,11 +694,11 @@ button[aria-pressed="true"] {
z-index: 3;
}
/* --- projet plié --------------------------------------------------------- */
/* --- barre cumulative d'un projet ---------------------------------------- */
/* Barre unique segmentée par phase. Lecture seule : voir docs/decisions.md,
section 8. */
.barre-pliee {
/* Barre unique segmentée par phase, affichée que le projet soit plié ou
déplié. Lecture seule : voir docs/decisions.md, section 8. */
.barre-cumulative {
position: absolute;
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
height: var(--hauteur-barre);
@@ -645,6 +735,17 @@ button[aria-pressed="true"] {
opacity: 0.85;
}
/* Dépliée, la barre cumulative coiffe les phases du projet : elle s'amincit
pour ne pas se faire passer pour l'une d'elles — elle ne se glisse pas. */
.barre-cumulative--fine {
--hauteur-cumulative: 9px;
top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2);
height: var(--hauteur-cumulative);
border-radius: 3px;
box-shadow: none;
}
/* --- barre fantôme de référence ------------------------------------------ */
.fantome {
@@ -665,8 +766,18 @@ button[aria-pressed="true"] {
z-index: 1;
}
/* Le fantôme suit la barre qu'il double : sous une barre cumulative amincie,
il flotterait sinon dans le vide. Déclaré après `.fantome`, dont il partage
la spécificité — l'ordre est ce qui le fait gagner. */
.fantome--fin {
top: calc((var(--hauteur-ligne) + 9px) / 2);
height: 4px;
}
/* --- panneau de détail --------------------------------------------------- */
/* Non modal : aucun voile derrière lui, la frise reste manipulable pendant
l'édition et cliquer une autre barre y bascule la sélection. */
.panneau {
position: fixed;
top: 0;
@@ -682,6 +793,13 @@ button[aria-pressed="true"] {
flex-direction: column;
}
/* Le panneau reçoit le focus à l'ouverture pour que le clavier le suive, mais
il n'est pas un contrôle : un anneau de focus autour de tout le panneau
n'apprendrait rien et attirerait l'œil loin du champ à remplir. */
.panneau:focus {
outline: none;
}
.panneau__entete {
display: flex;
align-items: center;
@@ -771,13 +889,6 @@ button[aria-pressed="true"] {
margin-top: 4px;
}
.voile {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 0.15);
z-index: 15;
}
/* --- menu contextuel ------------------------------------------------------ */
.menu {
@@ -830,7 +941,8 @@ button[aria-pressed="true"] {
border-top: 1px solid var(--bordure);
}
.menu-projet {
/* Commandes de la ligne d'un projet : ajouter une phase, ouvrir le menu. */
.commande-projet {
border: 0;
background: none;
padding: 2px 5px;
@@ -841,13 +953,16 @@ button[aria-pressed="true"] {
border-radius: 4px;
}
.menu-projet:hover {
.commande-projet:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--texte);
}
/* --- dialogues ------------------------------------------------------------ */
/* --- dialogue de confirmation --------------------------------------------- */
/* Seul `<dialog>` restant : les paramètres d'un projet ont rejoint un panneau
(voir `.panneau`). Modal ici à dessein — interrompre est le but quand une
action est irréversible. */
.dialogue {
padding: 0;
border: 1px solid var(--bordure-forte);
@@ -880,24 +995,6 @@ button[aria-pressed="true"] {
line-height: 1.5;
}
.dialogue label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: var(--texte-doux);
}
.dialogue input[type="text"] {
font: inherit;
padding: 7px 9px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
background: var(--fond);
color: var(--texte);
}
.couleurs {
margin: 0;
padding: 0;
@@ -931,11 +1028,6 @@ button[aria-pressed="true"] {
box-shadow: inset 0 0 0 2px var(--surface);
}
.dialogue__erreur {
color: var(--danger) !important;
font-size: 12px;
}
.dialogue__actions {
display: flex;
justify-content: flex-end;

View File

@@ -92,15 +92,20 @@ Gérer les jours ouvrés impose de faire passer chaque calcul de date par un hel
aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des
phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation.
## 8. Le mode plié est en lecture seule
## 8. La barre cumulative est en lecture seule
Un couloir plié affiche une barre unique segmentée par phase. On aurait pu rendre cette barre
glissable pour décaler tout le projet en bloc.
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. On
aurait pu la rendre glissable pour décaler tout le projet en bloc.
Écarté pour une raison de lisibilité du geste : la même barre représenterait tantôt une phase, tantôt
un projet entier, et un glissement de quelques pixels déplacerait alors plusieurs mois de travail
d'un coup, sans que l'utilisateur voie ce qui bouge. Le mode plié sert à regarder ; pour modifier, on
déplie.
d'un coup, sans que l'utilisateur voie ce qui bouge. Elle sert à regarder ; pour modifier, on agit
sur les phases.
**Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce
qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était
en train d'éditer. Dépliée, elle voisine avec les barres de phases : elle s'amincit alors pour qu'on
ne la prenne pas pour l'une d'elles, et le fantôme de référence s'amincit avec elle.
## 9. Accroche à la semaine
@@ -239,15 +244,17 @@ liste des tags actifs pour savoir dans quel sens la prochaine case va jouer.
**Le filtre n'est pas enregistré.** C'est un état de vue, comme la fenêtre temporelle
(décision 15) : un filtre qui survivrait au rechargement rouvrirait le planning amputé sans
qu'on se souvienne pourquoi. Le masquage par l'œil (`hidden`), lui, est bien enregistré —
qu'on se souvienne pourquoi. Le masquage d'un projet (`hidden`), lui, est bien enregistré —
et les deux ne font pas la même chose :
| | Filtre par tags | Œil (`hidden`) |
| | Filtre par tags | Masquage (`hidden`) |
|---|---|---|
| Portée | La vue entière, libellé compris | Les barres seules, le libellé reste grisé |
| Durée de vie | La session | Enregistré dans le fichier |
| Question posée | « Je ne regarde que les projets clients » | « Ce projet existe, mais il encombre ma frise » |
Le masquage n'a plus de bouton dédié dans la colonne : voir décision 20.
**La couleur d'un tag est calculée, pas choisie** : une teinte HSL dérivée du nom par
hachage. Rien à stocker, rien à saisir, et le même tag garde sa couleur partout. Des
collisions de teinte sont possibles et sans gravité — le nom est toujours écrit à côté.
@@ -261,3 +268,168 @@ appliquée en normalisant, ce qui la rend vraie par construction — aussi bien
Créer un projet alors qu'un filtre est actif **préremplit ses tags** avec ceux du filtre :
sans cela, le nouveau venu naîtrait invisible, hors de la vue depuis laquelle on vient de le
demander.
## 17. Le panneau de détail n'est pas modal
Le panneau s'ouvrait derrière un voile couvrant toute la page. L'intention était
raisonnable — concentrer l'attention sur la phase en cours d'édition —, mais elle
allait contre ce que fait un outil de frise : on y compare, on passe d'une phase à
la suivante, on ajuste en regardant le voisinage.
Trois conséquences, dont deux invisibles à la lecture du code :
- passer d'une phase à l'autre imposait fermer, cliquer, rouvrir, alors que c'est
le geste le plus fréquent de l'outil ;
- le raccourci `←` `→` était **inatteignable**. Le panneau plaçait le focus dans
le champ « Nom », et `app.js` ignore les flèches quand la frappe part dans un
`input` — pour en sortir il aurait fallu cliquer ailleurs, ce que le voile
interdisait précisément ;
- le rafraîchissement du panneau depuis la frise, prévu pour qu'un glisser se
reflète dans les champs, ne pouvait jamais se produire à la souris.
Le voile a donc disparu. Le panneau reste posé sur le bord droit, la frise
continue de vivre derrière lui, et cliquer une autre barre y bascule la
sélection. Le focus se pose sur le panneau lui-même — assez pour que le clavier
le suive, pas assez pour capturer les flèches. Le champ « Nom » n'est
présélectionné que pour une phase fraîchement créée, dont le nom provisoire est
justement là pour être écrasé.
**Cliquer à côté ferme quand même.** C'était le seul service que rendait le
voile, et il ne valait pas la frise inerte qu'il coûtait. Un écouteur sur
`document` le rend sans calque : il ferme les panneaux au `pointerdown`, sauf
sur deux familles de cibles.
- Les **surfaces protégées** — un panneau, le menu, un dialogue, la poignée de
colonne — où l'on est manifestement au travail.
- Les **cibles ouvrantes** : une barre, une poignée de barre, un libellé de
phase, tout élément portant `data-action`. Leur propre gestionnaire ouvre déjà
le bon panneau, et les faire passer par la fermeture générale serait pire
qu'inutile : celle-ci 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. C'est aussi pourquoi `fermer()` sort tout de suite quand le panneau est
déjà fermé : sans cette garde, chaque clic dans le vide relancerait un rendu
complet.
Échap et la croix restent les deux autres façons de fermer.
## 18. La colonne des libellés est redimensionnable
La colonne portait, sur 250 pixels fixes, le triangle de pli, le nom, les tags,
l'étiquette de dérive, l'œil et le menu. Sur le jeu d'exemple, il ne restait que
**56 pixels au nom** : « Refonte du site web » s'affichait « Refon… », pendant que
« client » se réduisait à « c. ». Le pire des deux mondes — des pastilles qui
occupaient la place d'un tag sans en livrer le nom.
Trois corrections, dans l'ordre de leur rendement :
- **Les commandes passent en surimpression**, révélées au survol de la ligne.
Ce sont des actions occasionnelles ; elles coûtaient soixante pixels en
permanence à une information lue en continu. Elles sont posées en `absolute`,
et non retirées du flux au repos, pour que rien ne se déplace à l'approche du
curseur. Elles restent visibles sans survol sur un projet masqué, seul chemin
pour le rétablir.
- **Les tags cèdent en bloc plutôt que de rétrécir.** Deux pastilles au plus,
puis un compteur `+N` dont l'infobulle donne les manquants. Un tag présent dans
le filtre courant passe devant : c'est lui qui explique la présence du projet à
l'écran, il ne doit pas tomber dans le compteur.
- **Le nom cède en dernier**, et pas en deçà de sept caractères.
Restait qu'aucune largeur ne convient à la fois à « Site web » et à « Refonte du
portail fournisseurs — lot 2 ». D'où une poignée dans le coin de la grille —
seul point de la colonne qui reste visible quel que soit le défilement —, avec
double-clic pour revenir au défaut et flèches au clavier.
**Cette largeur est conservée, dans `localStorage`.** C'est la première entorse à
la règle des décisions 15 et 16, où les états de vue meurent avec la session, et
elle est délibérée : la fenêtre temporelle et le filtre décrivent *ce qu'on
regarde*, une largeur de colonne décrit *l'écran sur lequel on regarde*. La
placer dans le planning imposerait à un portable la largeur choisie sur un
27 pouces ; ne pas la garder du tout obligerait à réajuster la poignée à chaque
ouverture, ce qui la rendrait inutile. `localStorage` est exactement le bon
rangement : local à la machine, hors des données.
## 19. Le nom d'une phase reste accroché au bord visible
Le libellé d'une barre était écrit à son bord gauche. Une phase de plusieurs mois
dont le début sortait de l'écran occupait donc toute la largeur de la vue sans
qu'on puisse lire son nom, parti sous la colonne des libellés.
Le nom vit désormais dans son propre élément, en `position: sticky`, calé sur la
largeur de la colonne — c'est-à-dire sur le premier pixel réellement visible de
la frise. Trois détails le rendent possible, et aucun n'est cosmétique :
- un `overflow: hidden` sur la barre en aurait fait le conteneur défilant de
référence, et le décalage n'aurait plus eu lieu. Le rognage du texte trop long
est donc porté par le nom lui-même, pas par la barre ;
- le décalage est plafonné par la boîte du parent, ce qui garantit que le nom ne
sort jamais de sa barre — d'où `inline-block`, qui laisse au texte la marge de
manœuvre qu'un bloc pleine largeur n'aurait pas ;
- quand le nom occupe presque toute sa barre, la marge s'épuise et il finit par
s'enfoncer sous la colonne. On lit alors sa fin plutôt que rien, ce qui reste
un progrès, et la colonne des libellés donne de toute façon le nom entier.
## 20. Ce que porte la ligne d'un projet
Trois commandes s'y étaient accumulées sans qu'on se demande si chacune méritait
sa place. Le tri s'est fait sur une seule question : cette action est-elle assez
fréquente pour valoir un bouton permanent ?
**L'œil disparaît.** Il faisait doublon avec l'entrée « Masquer de la frise » du
menu, qui reste. Le masquage est une décision durable sur un projet — on le pose
une fois et on l'oublie — pas un geste qu'on refait dix fois par séance. Depuis
que les tags existent (décision 16), c'est d'ailleurs le filtre qui répond au
besoin courant de dégager la vue. La fonction demeure, elle ne coûte simplement
plus de pixels à tout le monde en permanence. Le format de données est inchangé :
`hidden` reste un champ du projet.
**Le `+` apparaît.** Ajouter une phase est l'action la plus fréquente de l'outil,
et elle était enterrée dans un menu.
**Le nom du projet devient cliquable.** Il portait déjà `cursor: pointer` sans
qu'aucune action n'y réponde — un curseur qui promet et ne tient pas. Il ouvre
maintenant le dialogue des paramètres, ce que la main annonçait.
**Il n'y a plus de commande « Ajouter un jalon ».** Un jalon n'est pas un autre
objet : c'est une phase dont la fin rejoint le début, soit une case à cocher du
panneau au même titre que le statut. Deux entrées de menu obligeaient à trancher
avant d'avoir rien saisi, alors que la bascule reste possible ensuite dans les
deux sens. Une seule commande crée une phase ; on coche « Jalon » si c'en est un.
## 21. Un projet s'édite comme une phase
Les paramètres d'un projet — nom, couleur, tags — passaient par un `<dialog>`
modal à valider, quand ceux d'une phase s'éditaient dans un panneau latéral au
fil de la saisie. Deux formes pour la même opération à un niveau de granularité
près, avec deux modèles mentaux à tenir : ici on tape et ça s'applique, là on
tape et il faut confirmer.
Les projets ont rejoint le panneau (`js/projet.js`, jumeau de `js/detail.js`).
Même en-tête, même édition en direct, même bouton de suppression au bas du
formulaire. Les deux panneaux occupent le même bord de l'écran : ouvrir l'un
ferme l'autre.
**« Nouveau projet » ne demande donc plus rien.** Le projet est créé aussitôt
avec un nom provisoire, et le panneau s'ouvre avec ce nom présélectionné — on
tape par-dessus. C'est exactement le parti pris déjà retenu pour l'ajout d'une
phase (décision 13), qui cesse ainsi d'être une exception : plus aucune création
ne passe par un formulaire à valider.
Conséquences en cascade, toutes bienvenues :
- la couleur s'applique au clic sur sa pastille, donc la frise change sous les
yeux — le seul moyen de juger si la teinte se distingue de celle des couloirs
voisins, ce qu'un aperçu dans un dialogue ne montrait pas ;
- il n'y a plus de bouton « Annuler », donc plus le cas où fermer la fenêtre
perdait la saisie ;
- `creerDialogueProjet` disparaît de `menu.js`, qui ne garde que le menu
contextuel et la confirmation.
Le `<dialog>` natif reste pour cette dernière : sur une action irréversible,
interrompre franchement *est* le but, et le voile, le piège à focus et la
fermeture par Échap y sont gratuits.
Une contrepartie assumée : un projet créé par mégarde existe dans le fichier
même si l'on ferme le panneau sans rien saisir. Il porte alors le nom
« Nouveau projet », se voit sur la frise, et se supprime d'un clic depuis son
propre panneau — ce qui reste moins coûteux que d'imposer un dialogue à tout le
monde à chaque création.

View File

@@ -42,7 +42,21 @@
-->
<main class="frise" id="frise">
<div class="grille">
<div class="grille__coin"></div>
<div class="grille__coin">
<!--
Poignée de largeur de la colonne. Placée dans le coin, seul endroit
de la colonne qui reste visible quel que soit le défilement.
-->
<div
class="poignee-colonne"
id="poignee-colonne"
role="separator"
aria-orientation="vertical"
aria-label="Largeur de la colonne des projets"
tabindex="0"
title="Glisser pour élargir la colonne — double-clic pour la réinitialiser"
></div>
</div>
<div class="grille__entete" id="entete"></div>
<div class="grille__libelles" id="libelles"></div>
<div class="grille__couloirs" id="couloirs"></div>
@@ -61,8 +75,14 @@
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
</p>
<!-- Panneau de détail, ouvert au clic sur une phase. -->
<aside class="panneau" id="panneau" hidden aria-label="Détail de la phase">
<!--
Panneau de détail, ouvert au clic sur une phase. Volontairement **non
modal** : la frise reste vivante derrière lui, on passe d'une phase à
l'autre en cliquant sa barre. `tabindex="-1"` permet d'y poser le focus à
l'ouverture sans le placer dans un champ de saisie, ce qui laisse les
flèches piloter la phase sélectionnée.
-->
<aside class="panneau" id="panneau" hidden tabindex="-1" aria-label="Détail de la phase">
<div class="panneau__entete">
<h2 id="panneau-titre">Phase</h2>
<button type="button" id="fermer-panneau" aria-label="Fermer">&times;</button>
@@ -112,58 +132,57 @@
</form>
</aside>
<div class="voile" id="voile" hidden></div>
<!-- Menu contextuel, positionné à l'ouverture près de son point d'ancrage. -->
<div class="menu" id="menu" hidden role="menu"></div>
<!-- Création et renommage d'un projet. -->
<dialog class="dialogue" id="dialogue-projet">
<form method="dialog">
<h2 id="dialogue-projet-titre">Nouveau projet</h2>
<!--
Paramètres d'un projet. Même panneau, mêmes gestes et même édition en
direct que le détail d'une phase : renommer un projet et renommer une
phase sont la même opération à un niveau près, elles n'ont pas à se
présenter différemment.
-->
<aside
class="panneau"
id="panneau-projet"
hidden
tabindex="-1"
aria-label="Paramètres du projet"
>
<div class="panneau__entete">
<h2 id="panneau-projet-titre">Projet</h2>
<button type="button" id="fermer-panneau-projet" aria-label="Fermer">&times;</button>
</div>
<form class="panneau__corps" id="formulaire-projet">
<label>
Nom
<input type="text" id="dialogue-projet-nom" required autocomplete="off" />
<input type="text" name="name" required autocomplete="off" />
</label>
<fieldset class="couleurs">
<legend>Couleur du couloir</legend>
<div id="dialogue-projet-couleurs"></div>
<div id="panneau-projet-couleurs"></div>
</fieldset>
<label>
Tags, séparés par des virgules
<input
type="text"
id="dialogue-projet-tags"
autocomplete="off"
placeholder="client, refonte"
/>
<input type="text" name="tags" autocomplete="off" placeholder="client, refonte" />
</label>
<!-- Masqué tant qu'aucun tag n'existe ailleurs dans le planning. -->
<fieldset class="couleurs" id="dialogue-projet-suggestions-bloc" hidden>
<fieldset class="couleurs" id="panneau-projet-suggestions-bloc" hidden>
<legend>Tags déjà utilisés</legend>
<div class="tags tags--suggestions" id="dialogue-projet-suggestions"></div>
<div class="tags tags--suggestions" id="panneau-projet-suggestions"></div>
</fieldset>
<p class="dialogue__erreur" id="dialogue-projet-erreur" role="alert" hidden></p>
<p class="panneau__erreur" id="erreur-projet" role="alert" hidden></p>
<menu class="dialogue__actions">
<!--
`formnovalidate` est indispensable : le champ « Nom » est `required`,
et sans lui la validation HTML refuserait la soumission — donc la
fermeture du dialogue — tant qu'aucun nom n'est saisi. On se
retrouverait incapable d'annuler une création de projet.
-->
<button type="submit" value="annuler" formnovalidate>Annuler</button>
<button type="submit" value="ok" class="bouton--principal" id="dialogue-projet-ok">
Créer
<div class="panneau__actions">
<button type="button" id="supprimer-projet" class="bouton--danger">
Supprimer le projet
</button>
</menu>
</div>
</form>
</dialog>
</aside>
<!-- Confirmation d'une action irréversible. -->
<dialog class="dialogue" id="dialogue-confirmation">

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