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:
38
README.md
38
README.md
@@ -33,12 +33,26 @@ cp data/exemple.json data/projets.json
|
|||||||
|
|
||||||
## Prise en main
|
## 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 ;
|
Dans la colonne de gauche, le **triangle** plie et déplie le couloir d'un projet, et un clic sur
|
||||||
- **l'œil** le masque de la frise sans le supprimer ;
|
son **nom** ouvre ses paramètres : nom, couleur, tags. Deux commandes apparaissent au survol de
|
||||||
- **`⋯`** ouvre son menu : ajouter une phase ou un jalon, renommer, changer la couleur et les
|
la ligne, sur son bord droit :
|
||||||
tags, figer la référence, supprimer le projet. Un clic droit sur le libellé ouvre le même menu.
|
|
||||||
|
- **`+`** 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 :
|
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 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 |
|
| 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 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 |
|
| `←` `→` | Décale d'un jour la phase sélectionnée |
|
||||||
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
|
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
|
||||||
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
|
| `É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 |
|
| `Maj` + molette | Fait défiler la frise horizontalement |
|
||||||
| `↑` `↓` dans un menu | Parcourt les entrées |
|
| `↑` `↓` 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,
|
- **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).
|
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.
|
- **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
|
- **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la
|
||||||
barre unique segmentée en teintes, qui donne la forme d'ensemble sans le détail.
|
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
|
- **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.
|
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
|
- **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/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/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli |
|
||||||
| `js/drag.js` | Glisser et redimensionner les barres |
|
| `js/drag.js` | Glisser et redimensionner les barres |
|
||||||
| `js/detail.js` | Panneau de détail |
|
| `js/detail.js` | Panneau de détail d'une phase |
|
||||||
| `js/menu.js` | Menu contextuel et boîtes de dialogue |
|
| `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 |
|
| `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.
|
Le rendu utilise des éléments DOM positionnés en absolu, pas de SVG ni de bibliothèque Gantt.
|
||||||
|
|||||||
218
css/style.css
218
css/style.css
@@ -25,7 +25,9 @@
|
|||||||
--hauteur-barre: 26px;
|
--hauteur-barre: 26px;
|
||||||
--hauteur-ligne: 38px;
|
--hauteur-ligne: 38px;
|
||||||
--hauteur-entete: 66px; /* trois bandes de 22 px : année, mois, semaine */
|
--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;
|
--rayon: 6px;
|
||||||
--ombre: 0 1px 2px rgb(15 23 42 / 0.08);
|
--ombre: 0 1px 2px rgb(15 23 42 / 0.08);
|
||||||
@@ -268,6 +270,37 @@ button[aria-pressed="true"] {
|
|||||||
border-bottom: 1px solid var(--bordure);
|
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 {
|
.grille__entete {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -369,6 +402,9 @@ button[aria-pressed="true"] {
|
|||||||
/* --- libellés ------------------------------------------------------------ */
|
/* --- libellés ------------------------------------------------------------ */
|
||||||
|
|
||||||
.libelle-projet {
|
.libelle-projet {
|
||||||
|
--fond-libelle: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface));
|
||||||
|
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -376,25 +412,72 @@ button[aria-pressed="true"] {
|
|||||||
padding: 0 8px 0 6px;
|
padding: 0 8px 0 6px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom: 1px solid var(--bordure);
|
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 {
|
.libelle-projet__nom {
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
/* Le nom cède la place aux pastilles, mais jamais entièrement : sous quatre
|
min-width: 7em;
|
||||||
caractères il ne resterait qu'un « … » qui n'identifie plus rien. */
|
|
||||||
min-width: 4em;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
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 {
|
.libelle-projet .tags {
|
||||||
flex: 0 1 auto;
|
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 {
|
.libelle-projet__derive {
|
||||||
@@ -429,19 +512,6 @@ button[aria-pressed="true"] {
|
|||||||
transform: rotate(90deg);
|
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
|
/* `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
|
au texte d'un conteneur flex, et le nom serait coupé net. Le centrage
|
||||||
vertical passe donc par `line-height`. */
|
vertical passe donc par `line-height`. */
|
||||||
@@ -487,16 +557,36 @@ button[aria-pressed="true"] {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: var(--hauteur-barre);
|
line-height: var(--hauteur-barre);
|
||||||
padding: 0 7px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
box-shadow: var(--ombre);
|
box-shadow: var(--ombre);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
z-index: 2;
|
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 {
|
.barre:hover {
|
||||||
filter: brightness(1.08);
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
@@ -604,11 +694,11 @@ button[aria-pressed="true"] {
|
|||||||
z-index: 3;
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- projet plié --------------------------------------------------------- */
|
/* --- barre cumulative d'un projet ---------------------------------------- */
|
||||||
|
|
||||||
/* Barre unique segmentée par phase. Lecture seule : voir docs/decisions.md,
|
/* Barre unique segmentée par phase, affichée que le projet soit plié ou
|
||||||
section 8. */
|
déplié. Lecture seule : voir docs/decisions.md, section 8. */
|
||||||
.barre-pliee {
|
.barre-cumulative {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
|
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
|
||||||
height: var(--hauteur-barre);
|
height: var(--hauteur-barre);
|
||||||
@@ -645,6 +735,17 @@ button[aria-pressed="true"] {
|
|||||||
opacity: 0.85;
|
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 ------------------------------------------ */
|
/* --- barre fantôme de référence ------------------------------------------ */
|
||||||
|
|
||||||
.fantome {
|
.fantome {
|
||||||
@@ -665,8 +766,18 @@ button[aria-pressed="true"] {
|
|||||||
z-index: 1;
|
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 --------------------------------------------------- */
|
/* --- 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 {
|
.panneau {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -682,6 +793,13 @@ button[aria-pressed="true"] {
|
|||||||
flex-direction: column;
|
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 {
|
.panneau__entete {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -771,13 +889,6 @@ button[aria-pressed="true"] {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voile {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgb(15 23 42 / 0.15);
|
|
||||||
z-index: 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- menu contextuel ------------------------------------------------------ */
|
/* --- menu contextuel ------------------------------------------------------ */
|
||||||
|
|
||||||
.menu {
|
.menu {
|
||||||
@@ -830,7 +941,8 @@ button[aria-pressed="true"] {
|
|||||||
border-top: 1px solid var(--bordure);
|
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;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
@@ -841,13 +953,16 @@ button[aria-pressed="true"] {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-projet:hover {
|
.commande-projet:hover {
|
||||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
color: var(--texte);
|
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 {
|
.dialogue {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--bordure-forte);
|
border: 1px solid var(--bordure-forte);
|
||||||
@@ -880,24 +995,6 @@ button[aria-pressed="true"] {
|
|||||||
line-height: 1.5;
|
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 {
|
.couleurs {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -931,11 +1028,6 @@ button[aria-pressed="true"] {
|
|||||||
box-shadow: inset 0 0 0 2px var(--surface);
|
box-shadow: inset 0 0 0 2px var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialogue__erreur {
|
|
||||||
color: var(--danger) !important;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialogue__actions {
|
.dialogue__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. On
|
||||||
glissable pour décaler tout le projet en bloc.
|
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
|
É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
|
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'un coup, sans que l'utilisateur voie ce qui bouge. Elle sert à regarder ; pour modifier, on agit
|
||||||
déplie.
|
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
|
## 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
|
**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
|
(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 :
|
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é |
|
| 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 |
|
| 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 » |
|
| 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
|
**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
|
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é.
|
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 :
|
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
|
sans cela, le nouveau venu naîtrait invisible, hors de la vue depuis laquelle on vient de le
|
||||||
demander.
|
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.
|
||||||
|
|||||||
85
index.html
85
index.html
@@ -42,7 +42,21 @@
|
|||||||
-->
|
-->
|
||||||
<main class="frise" id="frise">
|
<main class="frise" id="frise">
|
||||||
<div class="grille">
|
<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__entete" id="entete"></div>
|
||||||
<div class="grille__libelles" id="libelles"></div>
|
<div class="grille__libelles" id="libelles"></div>
|
||||||
<div class="grille__couloirs" id="couloirs"></div>
|
<div class="grille__couloirs" id="couloirs"></div>
|
||||||
@@ -61,8 +75,14 @@
|
|||||||
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
|
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
|
||||||
</p>
|
</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">
|
<div class="panneau__entete">
|
||||||
<h2 id="panneau-titre">Phase</h2>
|
<h2 id="panneau-titre">Phase</h2>
|
||||||
<button type="button" id="fermer-panneau" aria-label="Fermer">×</button>
|
<button type="button" id="fermer-panneau" aria-label="Fermer">×</button>
|
||||||
@@ -112,58 +132,57 @@
|
|||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="voile" id="voile" hidden></div>
|
|
||||||
|
|
||||||
<!-- Menu contextuel, positionné à l'ouverture près de son point d'ancrage. -->
|
<!-- Menu contextuel, positionné à l'ouverture près de son point d'ancrage. -->
|
||||||
<div class="menu" id="menu" hidden role="menu"></div>
|
<div class="menu" id="menu" hidden role="menu"></div>
|
||||||
|
|
||||||
<!-- Création et renommage d'un projet. -->
|
<!--
|
||||||
<dialog class="dialogue" id="dialogue-projet">
|
Paramètres d'un projet. Même panneau, mêmes gestes et même édition en
|
||||||
<form method="dialog">
|
direct que le détail d'une phase : renommer un projet et renommer une
|
||||||
<h2 id="dialogue-projet-titre">Nouveau projet</h2>
|
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">×</button>
|
||||||
|
</div>
|
||||||
|
<form class="panneau__corps" id="formulaire-projet">
|
||||||
<label>
|
<label>
|
||||||
Nom
|
Nom
|
||||||
<input type="text" id="dialogue-projet-nom" required autocomplete="off" />
|
<input type="text" name="name" required autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<fieldset class="couleurs">
|
<fieldset class="couleurs">
|
||||||
<legend>Couleur du couloir</legend>
|
<legend>Couleur du couloir</legend>
|
||||||
<div id="dialogue-projet-couleurs"></div>
|
<div id="panneau-projet-couleurs"></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Tags, séparés par des virgules
|
Tags, séparés par des virgules
|
||||||
<input
|
<input type="text" name="tags" autocomplete="off" placeholder="client, refonte" />
|
||||||
type="text"
|
|
||||||
id="dialogue-projet-tags"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="client, refonte"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Masqué tant qu'aucun tag n'existe ailleurs dans le planning. -->
|
<!-- 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>
|
<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>
|
</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">
|
<div class="panneau__actions">
|
||||||
<!--
|
<button type="button" id="supprimer-projet" class="bouton--danger">
|
||||||
`formnovalidate` est indispensable : le champ « Nom » est `required`,
|
Supprimer le projet
|
||||||
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
|
|
||||||
</button>
|
</button>
|
||||||
</menu>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</aside>
|
||||||
|
|
||||||
<!-- Confirmation d'une action irréversible. -->
|
<!-- Confirmation d'une action irréversible. -->
|
||||||
<dialog class="dialogue" id="dialogue-confirmation">
|
<dialog class="dialogue" id="dialogue-confirmation">
|
||||||
|
|||||||
330
js/app.js
330
js/app.js
@@ -8,7 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { creerPanneau } from './detail.js';
|
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 { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
||||||
import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js';
|
import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js';
|
||||||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||||||
@@ -52,6 +53,7 @@ const refs = {
|
|||||||
effacerFiltres: $('effacer-filtres'),
|
effacerFiltres: $('effacer-filtres'),
|
||||||
alerte: $('alerte'),
|
alerte: $('alerte'),
|
||||||
alerteMessage: $('alerte-message'),
|
alerteMessage: $('alerte-message'),
|
||||||
|
panneau: $('panneau'),
|
||||||
};
|
};
|
||||||
|
|
||||||
let planning = planningVide();
|
let planning = planningVide();
|
||||||
@@ -98,22 +100,6 @@ const PORTEE_MAX_ANNEES = 10;
|
|||||||
const stockage = creerStockage(afficherEtat);
|
const stockage = creerStockage(afficherEtat);
|
||||||
const menu = creerMenu($('menu'));
|
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({
|
const demanderConfirmation = creerDialogueConfirmation({
|
||||||
dialogue: $('dialogue-confirmation'),
|
dialogue: $('dialogue-confirmation'),
|
||||||
titre: $('dialogue-confirmation-titre'),
|
titre: $('dialogue-confirmation-titre'),
|
||||||
@@ -124,7 +110,6 @@ const demanderConfirmation = creerDialogueConfirmation({
|
|||||||
const panneau = creerPanneau(
|
const panneau = creerPanneau(
|
||||||
{
|
{
|
||||||
panneau: $('panneau'),
|
panneau: $('panneau'),
|
||||||
voile: $('voile'),
|
|
||||||
formulaire: $('formulaire-phase'),
|
formulaire: $('formulaire-phase'),
|
||||||
titre: $('panneau-titre'),
|
titre: $('panneau-titre'),
|
||||||
erreur: $('erreur-phase'),
|
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
|
// État
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -202,6 +219,12 @@ function dessiner() {
|
|||||||
const trouve = localiser(selection.projet, selection.phase);
|
const trouve = localiser(selection.projet, selection.phase);
|
||||||
if (trouve) panneau.rafraichir(trouve.projet, trouve.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) {
|
function localiser(idProjet, idPhase) {
|
||||||
@@ -286,6 +309,101 @@ function effacerFiltres() {
|
|||||||
refs.effacerFiltres.addEventListener('click', effacerFiltres);
|
refs.effacerFiltres.addEventListener('click', effacerFiltres);
|
||||||
$('effacer-filtres-vide').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
|
// 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);
|
const trouve = localiser(idProjet, idPhase);
|
||||||
if (!trouve) return;
|
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 };
|
selection = { projet: idProjet, phase: idPhase };
|
||||||
panneau.ouvrir(trouve.projet, trouve.phase);
|
panneau.ouvrir(trouve.projet, trouve.phase, options);
|
||||||
dessiner();
|
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.
|
// 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) {
|
if (!cible) {
|
||||||
const ligne = evenement.target.closest('.libelle-phase');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +573,18 @@ refs.libelles.addEventListener('click', (evenement) => {
|
|||||||
|
|
||||||
const idProjet = cible.dataset.projet;
|
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') {
|
if (cible.dataset.action === 'plier') {
|
||||||
majPlanning(
|
majPlanning(
|
||||||
remplacerProjet(planning, idProjet, (projet) => ({
|
remplacerProjet(planning, idProjet, (projet) => ({
|
||||||
@@ -455,8 +621,7 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
|||||||
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
||||||
|
|
||||||
menu.ouvrir(ancre, [
|
menu.ouvrir(ancre, [
|
||||||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet, false) },
|
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
|
||||||
{ libelle: 'Ajouter un jalon', action: () => ajouterUnePhase(projet, true) },
|
|
||||||
{
|
{
|
||||||
libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet',
|
libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet',
|
||||||
separateurAvant: true,
|
separateurAvant: true,
|
||||||
@@ -470,7 +635,7 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
|||||||
action: () =>
|
action: () =>
|
||||||
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
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',
|
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
|
||||||
separateurAvant: true,
|
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
|
// 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
|
// 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
|
// phase tout de suite et on ouvre le panneau de détail avec le nom
|
||||||
// présélectionné : l'utilisateur tape directement par-dessus.
|
// présélectionné : l'utilisateur tape directement par-dessus.
|
||||||
const derniere = projet.phases[projet.phases.length - 1];
|
const derniere = projet.phases[projet.phases.length - 1];
|
||||||
const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
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, {
|
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
|
||||||
milestone: jalon,
|
|
||||||
});
|
|
||||||
|
|
||||||
majPlanning(
|
majPlanning(
|
||||||
remplacerProjet(planning, projet.id, (p) => ({
|
remplacerProjet(planning, projet.id, (p) => ({
|
||||||
@@ -510,7 +681,7 @@ function ajouterUnePhase(projet, jalon) {
|
|||||||
collapsed: false, // inutile d'ajouter une phase dans un couloir replié
|
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
|
// 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
|
// recentrage, une phase créée alors qu'on regardait 2029 apparaîtrait hors
|
||||||
@@ -518,28 +689,42 @@ function ajouterUnePhase(projet, jalon) {
|
|||||||
centrerSur(phase.start);
|
centrerSur(phase.start);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renommerProjet(projet) {
|
/** Ouvre le panneau des paramètres d'un projet : nom, couleur, tags. */
|
||||||
const reponse = await demanderProjet({
|
function ouvrirProjet(projet, options = {}) {
|
||||||
titreTexte: 'Modifier le projet',
|
panneau.fermer();
|
||||||
libelleValider: 'Enregistrer',
|
panneauProjet.ouvrir(projet, options);
|
||||||
nom: projet.name,
|
dessiner();
|
||||||
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,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* É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) {
|
async function figerLaReference(projet, dejaFigee) {
|
||||||
const ok = await demanderConfirmation({
|
const ok = await demanderConfirmation({
|
||||||
titreTexte: dejaFigee ? 'Refiger la référence' : 'Figer la référence',
|
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)));
|
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 nombre = projet.phases.length;
|
||||||
const ok = await demanderConfirmation({
|
return demanderConfirmation({
|
||||||
titreTexte: 'Supprimer le projet',
|
titreTexte: 'Supprimer le projet',
|
||||||
messageTexte:
|
messageTexte:
|
||||||
`« ${projet.name} »` +
|
`« ${projet.name} »` +
|
||||||
@@ -576,30 +762,33 @@ async function supprimerLeProjet(projet) {
|
|||||||
libelleValider: 'Supprimer',
|
libelleValider: 'Supprimer',
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
}
|
||||||
|
|
||||||
|
async function supprimerLeProjet(projet) {
|
||||||
|
if (!(await confirmerSuppressionProjet(projet))) return;
|
||||||
|
|
||||||
if (selection?.projet === projet.id) panneau.fermer();
|
if (selection?.projet === projet.id) panneau.fermer();
|
||||||
|
if (panneauProjet.projetOuvert() === projet.id) panneauProjet.fermer();
|
||||||
majPlanning(supprimerProjet(planning, projet.id));
|
majPlanning(supprimerProjet(planning, projet.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nouveauProjet() {
|
/**
|
||||||
const reponse = await demanderProjet({
|
* Crée un projet et ouvre ses paramètres, sans passer par un formulaire à
|
||||||
titreTexte: 'Nouveau projet',
|
* valider.
|
||||||
libelleValider: 'Créer',
|
*
|
||||||
couleur: COULEURS[planning.projects.length % COULEURS.length],
|
* Même parti pris que pour l'ajout d'une phase (docs/decisions.md, section 13) :
|
||||||
// Un filtre actif préremplit les tags : quand on crée un projet en ne
|
* le projet est créé tout de suite avec un nom provisoire, et le panneau s'ouvre
|
||||||
// regardant que les projets « client », c'en est presque toujours un — et
|
* avec ce nom présélectionné — on tape simplement par-dessus. Un dialogue de
|
||||||
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
* plus n'apportait rien, et il présentait les mêmes champs sous une autre forme
|
||||||
tags: tagsActifs,
|
* que celle qui sert ensuite à les modifier.
|
||||||
tagsConnus: tousLesTags(planning),
|
*/
|
||||||
});
|
function nouveauProjet() {
|
||||||
if (!reponse) return;
|
// 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
|
||||||
const projet = {
|
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
||||||
...creerProjet(planning, reponse.nom, reponse.tags),
|
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
|
||||||
color: reponse.couleur,
|
|
||||||
};
|
|
||||||
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
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
|
// 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é
|
// 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.
|
// 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;
|
const centreUtile = (largeurLibelles + refs.frise.clientWidth) / 2;
|
||||||
|
|
||||||
refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile);
|
refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile);
|
||||||
|
|||||||
33
js/detail.js
33
js/detail.js
@@ -8,12 +8,15 @@
|
|||||||
* C'est aussi ici qu'on saisit une date exacte : le glisser sur la frise
|
* 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
|
* s'accroche au lundi, ce panneau est le recours quand une phase doit démarrer
|
||||||
* un jeudi précis.
|
* 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';
|
import { STATUTS, dateValide, modifierPhase } from './model.js';
|
||||||
|
|
||||||
export function creerPanneau(refs, rappels) {
|
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 selection = null; // { projet, phase }
|
||||||
let phaseCourante = null;
|
let phaseCourante = null;
|
||||||
@@ -106,7 +109,6 @@ export function creerPanneau(refs, rappels) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fermer.addEventListener('click', () => api.fermer());
|
fermer.addEventListener('click', () => api.fermer());
|
||||||
voile.addEventListener('click', () => api.fermer());
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (evenement) => {
|
document.addEventListener('keydown', (evenement) => {
|
||||||
// Échap ferme le panneau, sauf pendant une saisie où il annule d'abord la
|
// É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 = {
|
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 };
|
selection = { projet: projet.id, phase: phase.id };
|
||||||
phaseCourante = phase;
|
phaseCourante = phase;
|
||||||
remplir(projet, phase);
|
remplir(projet, phase);
|
||||||
panneau.hidden = false;
|
panneau.hidden = false;
|
||||||
voile.hidden = false;
|
|
||||||
formulaire.name.focus();
|
if (nomAEcraser) {
|
||||||
formulaire.name.select();
|
formulaire.name.focus();
|
||||||
|
formulaire.name.select();
|
||||||
|
} else {
|
||||||
|
panneau.focus();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Recharge le contenu après une modification venue de la frise. */
|
/** Recharge le contenu après une modification venue de la frise. */
|
||||||
@@ -143,10 +160,12 @@ export function creerPanneau(refs, rappels) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
fermer() {
|
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;
|
selection = null;
|
||||||
phaseCourante = null;
|
phaseCourante = null;
|
||||||
panneau.hidden = true;
|
panneau.hidden = true;
|
||||||
voile.hidden = true;
|
|
||||||
rappels.surFermeture();
|
rappels.surFermeture();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
121
js/menu.js
121
js/menu.js
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Menu contextuel et boîtes de dialogue.
|
* Menu contextuel et dialogue de confirmation.
|
||||||
*
|
*
|
||||||
* Remplace les `prompt()` et `confirm()` natifs, qui bloquaient le fil
|
* 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
|
* d'exécution, ne se stylaient pas, et — pour le menu d'un projet — obligeaient
|
||||||
* à saisir un numéro d'action au clavier.
|
* à saisir un numéro d'action au clavier.
|
||||||
*
|
*
|
||||||
* Les dialogues s'appuient sur l'élément `<dialog>` natif, qui apporte
|
* Le dialogue s'appuie sur l'élément `<dialog>` natif, qui apporte gratuitement
|
||||||
* gratuitement le voile, le piège à focus, la fermeture par Échap et la
|
* le voile, le piège à focus, la fermeture par Échap et la restauration du
|
||||||
* restauration du focus à la fermeture.
|
* 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;
|
const MARGE_ECRAN = 8;
|
||||||
@@ -134,117 +136,6 @@ function positionner(menu, ancre) {
|
|||||||
// Dialogues
|
// 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.
|
* Demande confirmation avant une action irréversible.
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
|
|||||||
236
js/projet.js
Normal file
236
js/projet.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Panneau des paramètres d'un projet : nom, couleur, tags.
|
||||||
|
*
|
||||||
|
* Jumeau de `detail.js`, et volontairement : renommer un projet et renommer une
|
||||||
|
* phase sont la même opération à un niveau de granularité près. Elles se
|
||||||
|
* présentent donc pareil — même panneau latéral, même édition au fil de la
|
||||||
|
* saisie, même bouton de suppression au bas du formulaire.
|
||||||
|
*
|
||||||
|
* Ces paramètres passaient auparavant par un `<dialog>` modal à valider. Voir
|
||||||
|
* docs/decisions.md, section 21.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function creerPanneauProjet(refs, outils, rappels) {
|
||||||
|
const {
|
||||||
|
panneau,
|
||||||
|
formulaire,
|
||||||
|
titre,
|
||||||
|
erreur,
|
||||||
|
conteneurCouleurs,
|
||||||
|
suggestions,
|
||||||
|
blocSuggestions,
|
||||||
|
supprimer,
|
||||||
|
fermer,
|
||||||
|
} = refs;
|
||||||
|
const { couleurs, normaliserTags, cleTag, teinteTag } = outils;
|
||||||
|
|
||||||
|
let idCourant = null;
|
||||||
|
let projetCourant = null;
|
||||||
|
|
||||||
|
function afficherErreur(message) {
|
||||||
|
erreur.textContent = message || '';
|
||||||
|
erreur.hidden = !message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags saisis dans le champ, tels qu'ils seront enregistrés. */
|
||||||
|
function tagsSaisis() {
|
||||||
|
return normaliserTags(formulaire.tags.value.split(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La palette est bâtie une fois pour toutes, et seul l'état pressé change
|
||||||
|
* ensuite. La reconstruire à chaque frappe ferait perdre le focus à qui
|
||||||
|
* parcourt les couleurs au clavier — et l'édition en direct provoque un
|
||||||
|
* rafraîchissement à chaque caractère saisi dans le nom.
|
||||||
|
*/
|
||||||
|
function peindrePalette(choisie) {
|
||||||
|
if (!conteneurCouleurs.childElementCount) {
|
||||||
|
for (const couleur of couleurs) {
|
||||||
|
const bouton = document.createElement('button');
|
||||||
|
bouton.type = 'button';
|
||||||
|
bouton.className = 'pastille';
|
||||||
|
bouton.dataset.couleur = couleur;
|
||||||
|
bouton.style.background = couleur;
|
||||||
|
bouton.setAttribute('aria-label', `Couleur ${couleur}`);
|
||||||
|
// Une couleur se choisit d'un clic et s'applique aussitôt : la frise
|
||||||
|
// change sous les yeux, ce qui est le seul moyen de juger si la teinte
|
||||||
|
// se distingue bien de celle des couloirs voisins.
|
||||||
|
bouton.addEventListener('click', () => appliquer({ color: couleur }));
|
||||||
|
conteneurCouleurs.append(bouton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bouton of conteneurCouleurs.children) {
|
||||||
|
bouton.setAttribute('aria-pressed', String(bouton.dataset.couleur === choisie));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags déjà employés ailleurs dans le planning, proposés en un clic.
|
||||||
|
*
|
||||||
|
* Sans eux, chaque projet inventerait sa propre orthographe et le filtre se
|
||||||
|
* remplirait de « client », « Clients » et « client final ».
|
||||||
|
*/
|
||||||
|
function peindreSuggestions() {
|
||||||
|
const tagsConnus = rappels.tagsConnus();
|
||||||
|
// La liste change dès qu'un tag apparaît ou disparaît du planning : on la
|
||||||
|
// reconstruit, mais on rend le focus au bouton de même rang, sans quoi
|
||||||
|
// cliquer une suggestion au clavier éjecterait vers le début du document.
|
||||||
|
const focalise = [...suggestions.children].indexOf(document.activeElement);
|
||||||
|
suggestions.replaceChildren();
|
||||||
|
const posees = new Set(tagsSaisis().map(cleTag));
|
||||||
|
|
||||||
|
for (const tag of tagsConnus) {
|
||||||
|
const bouton = document.createElement('button');
|
||||||
|
bouton.type = 'button';
|
||||||
|
bouton.className = 'tag';
|
||||||
|
bouton.style.setProperty('--teinte-tag', String(teinteTag(tag)));
|
||||||
|
bouton.textContent = tag;
|
||||||
|
bouton.setAttribute('aria-pressed', String(posees.has(cleTag(tag))));
|
||||||
|
bouton.addEventListener('click', () => {
|
||||||
|
const saisis = tagsSaisis();
|
||||||
|
const restants = saisis.filter((pose) => cleTag(pose) !== cleTag(tag));
|
||||||
|
const suivants = restants.length === saisis.length ? [...saisis, tag] : restants;
|
||||||
|
formulaire.tags.value = normaliserTags(suivants).join(', ');
|
||||||
|
appliquer();
|
||||||
|
});
|
||||||
|
suggestions.append(bouton);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focalise >= 0) suggestions.children[focalise]?.focus();
|
||||||
|
blocSuggestions.hidden = tagsConnus.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remplir(projet) {
|
||||||
|
titre.textContent = projet.name;
|
||||||
|
formulaire.name.value = projet.name;
|
||||||
|
formulaire.tags.value = projet.tags.join(', ');
|
||||||
|
peindrePalette(projet.color);
|
||||||
|
peindreSuggestions();
|
||||||
|
afficherErreur(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le formulaire et remonte le changement.
|
||||||
|
*
|
||||||
|
* @param {object} forces champs imposés hors formulaire — la couleur, qui se
|
||||||
|
* choisit dans la palette et n'a pas de champ de saisie.
|
||||||
|
*/
|
||||||
|
function appliquer(forces = {}) {
|
||||||
|
if (!idCourant || !projetCourant) return;
|
||||||
|
|
||||||
|
const nom = formulaire.name.value.trim();
|
||||||
|
if (!nom) {
|
||||||
|
// Le projet garde son nom précédent : on signale sans rien écraser, comme
|
||||||
|
// le fait le panneau d'une phase.
|
||||||
|
afficherErreur('Le nom ne peut pas être vide.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
afficherErreur(null);
|
||||||
|
|
||||||
|
const suivant = {
|
||||||
|
...projetCourant,
|
||||||
|
name: nom,
|
||||||
|
tags: tagsSaisis(),
|
||||||
|
...forces,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Comparaison terme à terme plutôt que par concaténation : un tag peut
|
||||||
|
// contenir une espace, et « a b » ne doit pas passer pour « a », « b ».
|
||||||
|
const memesTags =
|
||||||
|
suivant.tags.length === projetCourant.tags.length &&
|
||||||
|
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
suivant.name === projetCourant.name &&
|
||||||
|
suivant.color === projetCourant.color &&
|
||||||
|
memesTags
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
projetCourant = suivant;
|
||||||
|
titre.textContent = suivant.name;
|
||||||
|
peindrePalette(suivant.color);
|
||||||
|
peindreSuggestions();
|
||||||
|
rappels.surModification(idCourant, suivant);
|
||||||
|
}
|
||||||
|
|
||||||
|
formulaire.addEventListener('input', () => appliquer());
|
||||||
|
formulaire.addEventListener('change', () => appliquer());
|
||||||
|
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
|
||||||
|
|
||||||
|
supprimer.addEventListener('click', async () => {
|
||||||
|
if (!idCourant) return;
|
||||||
|
const cible = idCourant;
|
||||||
|
const projet = projetCourant;
|
||||||
|
|
||||||
|
if (!(await rappels.demanderSuppression(cible, projet))) return;
|
||||||
|
|
||||||
|
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
|
||||||
|
if (idCourant !== cible) return;
|
||||||
|
|
||||||
|
api.fermer();
|
||||||
|
rappels.surSuppression(cible);
|
||||||
|
});
|
||||||
|
|
||||||
|
fermer.addEventListener('click', () => api.fermer());
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (evenement) => {
|
||||||
|
if (evenement.key === 'Escape' && !panneau.hidden) api.fermer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
/**
|
||||||
|
* @param {object} options
|
||||||
|
* - nomAEcraser : place le curseur dans le champ « Nom », texte
|
||||||
|
* présélectionné. Réservé à un projet qui vient d'être créé, dont le nom
|
||||||
|
* provisoire est justement là pour être remplacé.
|
||||||
|
*/
|
||||||
|
ouvrir(projet, { nomAEcraser = false } = {}) {
|
||||||
|
idCourant = projet.id;
|
||||||
|
projetCourant = projet;
|
||||||
|
remplir(projet);
|
||||||
|
panneau.hidden = false;
|
||||||
|
|
||||||
|
if (nomAEcraser) {
|
||||||
|
formulaire.name.focus();
|
||||||
|
formulaire.name.select();
|
||||||
|
} else {
|
||||||
|
panneau.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Recharge le contenu après une modification venue d'ailleurs. */
|
||||||
|
rafraichir(projet) {
|
||||||
|
if (idCourant !== projet.id) return;
|
||||||
|
projetCourant = projet;
|
||||||
|
titre.textContent = projet.name;
|
||||||
|
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur
|
||||||
|
// au milieu d'une frappe.
|
||||||
|
const actif = document.activeElement;
|
||||||
|
if (formulaire.name !== actif) formulaire.name.value = projet.name;
|
||||||
|
if (formulaire.tags !== actif) formulaire.tags.value = projet.tags.join(', ');
|
||||||
|
peindrePalette(projet.color);
|
||||||
|
peindreSuggestions();
|
||||||
|
},
|
||||||
|
|
||||||
|
fermer() {
|
||||||
|
if (panneau.hidden) return;
|
||||||
|
idCourant = null;
|
||||||
|
projetCourant = null;
|
||||||
|
panneau.hidden = true;
|
||||||
|
rappels.surFermeture();
|
||||||
|
},
|
||||||
|
|
||||||
|
estOuvert() {
|
||||||
|
return idCourant !== null;
|
||||||
|
},
|
||||||
|
|
||||||
|
projetOuvert() {
|
||||||
|
return idCourant;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
118
js/timeline.js
118
js/timeline.js
@@ -328,11 +328,16 @@ function libelleProjet(projet, tagsActifs = []) {
|
|||||||
const nom = document.createElement('span');
|
const nom = document.createElement('span');
|
||||||
nom.className = 'libelle-projet__nom';
|
nom.className = 'libelle-projet__nom';
|
||||||
nom.textContent = projet.name;
|
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
|
// Les pastilles rognent le nom quand la colonne se remplit : l'infobulle
|
||||||
// reste le seul endroit où les deux se lisent toujours en entier.
|
// reste le seul endroit où les deux se lisent toujours en entier.
|
||||||
nom.title = projet.tags.length
|
nom.title =
|
||||||
? `${projet.name}\nTags : ${projet.tags.join(', ')}`
|
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
|
||||||
: 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);
|
ligne.append(nom);
|
||||||
|
|
||||||
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
|
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
|
||||||
@@ -349,19 +354,30 @@ function libelleProjet(projet, tagsActifs = []) {
|
|||||||
ligne.append(etiquette);
|
ligne.append(etiquette);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oeil = document.createElement('button');
|
// Les commandes sont posées *au-dessus* de la ligne, révélées au survol,
|
||||||
oeil.className = 'icone-oeil';
|
// plutôt que rangées dans son flux. Elles y occupaient une soixantaine de
|
||||||
oeil.type = 'button';
|
// pixels en permanence — de quoi tronquer le nom de tous les projets dans une
|
||||||
oeil.dataset.action = 'masquer';
|
// colonne de largeur courante. En surimpression, le nom dispose de toute la
|
||||||
oeil.dataset.projet = projet.id;
|
// colonne et rien ne se déplace à l'approche du curseur.
|
||||||
oeil.textContent = projet.hidden ? '🚫' : '👁';
|
const commandes = document.createElement('span');
|
||||||
oeil.setAttribute('aria-label', projet.hidden ? `Afficher ${projet.name}` : `Masquer ${projet.name}`);
|
commandes.className = 'libelle-projet__commandes';
|
||||||
ligne.append(oeil);
|
|
||||||
|
// 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
|
// Bouton de menu explicite : les actions du projet passaient auparavant par
|
||||||
// un clic sur son nom, ce que rien n'indiquait.
|
// un clic sur son nom, ce que rien n'indiquait.
|
||||||
const menu = document.createElement('button');
|
const menu = document.createElement('button');
|
||||||
menu.className = 'menu-projet';
|
menu.className = 'commande-projet';
|
||||||
menu.type = 'button';
|
menu.type = 'button';
|
||||||
menu.dataset.action = 'menu';
|
menu.dataset.action = 'menu';
|
||||||
menu.dataset.projet = projet.id;
|
menu.dataset.projet = projet.id;
|
||||||
@@ -369,7 +385,9 @@ function libelleProjet(projet, tagsActifs = []) {
|
|||||||
menu.title = `Actions sur ${projet.name}`;
|
menu.title = `Actions sur ${projet.name}`;
|
||||||
menu.setAttribute('aria-label', `Actions sur ${projet.name}`);
|
menu.setAttribute('aria-label', `Actions sur ${projet.name}`);
|
||||||
menu.setAttribute('aria-haspopup', 'menu');
|
menu.setAttribute('aria-haspopup', 'menu');
|
||||||
ligne.append(menu);
|
commandes.append(menu);
|
||||||
|
|
||||||
|
ligne.append(commandes);
|
||||||
|
|
||||||
return ligne;
|
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
|
* 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
|
* repéré à « montre-moi tous ceux qui lui ressemblent », sans remonter à la
|
||||||
* barre de filtres.
|
* 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) {
|
function pastillesTags(tags, tagsActifs) {
|
||||||
const conteneur = document.createElement('span');
|
const conteneur = document.createElement('span');
|
||||||
conteneur.className = 'tags';
|
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.
|
// c'est la clé, pas la graphie, qui décide s'il est actif.
|
||||||
const clesActives = new Set(tagsActifs.map(cleTag));
|
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');
|
const pastille = document.createElement('button');
|
||||||
pastille.type = 'button';
|
pastille.type = 'button';
|
||||||
pastille.className = 'tag';
|
pastille.className = 'tag';
|
||||||
@@ -406,6 +441,14 @@ function pastillesTags(tags, tagsActifs) {
|
|||||||
conteneur.append(pastille);
|
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;
|
return conteneur;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,12 +464,14 @@ function construireCouloirs(projets, echelle, selection) {
|
|||||||
couloirProjet.className = 'couloir couloir--projet';
|
couloirProjet.className = 'couloir couloir--projet';
|
||||||
couloirProjet.style.setProperty('--couleur-projet', projet.color);
|
couloirProjet.style.setProperty('--couleur-projet', projet.color);
|
||||||
|
|
||||||
if (projet.collapsed) {
|
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
|
||||||
const barre = construireBarrePliee(projet, echelle);
|
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
|
||||||
if (barre) couloirProjet.append(barre);
|
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
|
||||||
const fantome = construireFantome(projet, echelle);
|
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
|
||||||
if (fantome) couloirProjet.append(fantome);
|
const barre = construireBarreCumulative(projet, echelle);
|
||||||
}
|
if (barre) couloirProjet.append(barre);
|
||||||
|
const fantome = construireFantome(projet, echelle);
|
||||||
|
if (fantome) couloirProjet.append(fantome);
|
||||||
|
|
||||||
elements.push(couloirProjet);
|
elements.push(couloirProjet);
|
||||||
|
|
||||||
@@ -486,12 +531,20 @@ function construirePhase(projet, phase, echelle, selection) {
|
|||||||
barre.dataset.projet = projet.id;
|
barre.dataset.projet = projet.id;
|
||||||
barre.dataset.phase = phase.id;
|
barre.dataset.phase = phase.id;
|
||||||
barre.dataset.role = 'barre';
|
barre.dataset.role = 'barre';
|
||||||
barre.textContent = phase.name;
|
|
||||||
barre.title =
|
barre.title =
|
||||||
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
||||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
`\n${LIBELLES_STATUT[phase.status]}` +
|
||||||
(phase.notes ? `\n\n${phase.notes}` : '');
|
(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']) {
|
for (const bord of ['debut', 'fin']) {
|
||||||
const poignee = document.createElement('div');
|
const poignee = document.createElement('div');
|
||||||
poignee.className = `poignee poignee--${bord}`;
|
poignee.className = `poignee poignee--${bord}`;
|
||||||
@@ -505,15 +558,20 @@ function construirePhase(projet, phase, echelle, selection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Barre unique du mode plié, segmentée par phase.
|
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
|
||||||
* Lecture seule : aucun `data-role`, donc drag.js l'ignore.
|
*
|
||||||
|
* 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);
|
const bornes = bornesProjet(projet);
|
||||||
if (!bornes) return null;
|
if (!bornes) return null;
|
||||||
|
|
||||||
const conteneur = document.createElement('div');
|
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.left = `${echelle.x(bornes.start)}px`;
|
||||||
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}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)}` +
|
`${projet.name}\n${formaterDateLongue(bornes.start)} → ${formaterDateLongue(bornes.end)}` +
|
||||||
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
|
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
|
||||||
(derive ? `\nÉcart à la référence : ${derive}` : '') +
|
(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);
|
const origine = echelle.x(bornes.start);
|
||||||
for (const phase of projet.phases) {
|
for (const phase of projet.phases) {
|
||||||
@@ -539,13 +599,15 @@ function construireBarrePliee(projet, echelle) {
|
|||||||
return conteneur;
|
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) {
|
function construireFantome(projet, echelle) {
|
||||||
const reference = bornesReference(projet);
|
const reference = bornesReference(projet);
|
||||||
if (!reference) return null;
|
if (!reference) return null;
|
||||||
|
|
||||||
const fantome = document.createElement('div');
|
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.left = `${echelle.x(reference.start)}px`;
|
||||||
fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`;
|
fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`;
|
||||||
fantome.title = `Référence : ${formaterDateLongue(reference.start)} → ${formaterDateLongue(reference.end)}`;
|
fantome.title = `Référence : ${formaterDateLongue(reference.start)} → ${formaterDateLongue(reference.end)}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user