diff --git a/README.md b/README.md index ba360e2..0209914 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,26 @@ cp data/exemple.json data/projets.json ## Prise en main -Chaque projet porte trois commandes dans la colonne de gauche : +**Nouveau projet** crée le projet aussitôt, sous un nom provisoire, et ouvre ses paramètres avec +ce nom présélectionné : on tape simplement par-dessus. Il n'y a aucun formulaire à valider — ni +ici, ni pour une phase. Tout s'applique au fil de la saisie, et un projet créé par erreur se +supprime depuis son propre panneau. -- **le triangle** plie et déplie son couloir ; -- **l'œil** le masque de la frise sans le supprimer ; -- **`⋯`** ouvre son menu : ajouter une phase ou un jalon, renommer, changer la couleur et les - tags, figer la référence, supprimer le projet. Un clic droit sur le libellé ouvre le même menu. +Dans la colonne de gauche, le **triangle** plie et déplie le couloir d'un projet, et un clic sur +son **nom** ouvre ses paramètres : nom, couleur, tags. Deux commandes apparaissent au survol de +la ligne, sur son bord droit : + +- **`+`** ajoute une phase à la suite de la dernière, et ouvre son panneau de détail ; +- **`⋯`** ouvre le menu du projet : ajouter une phase, plier, masquer de la frise, renommer, + figer la référence, supprimer. Un clic droit sur le libellé ouvre le même menu. + +Il n'y a pas de commande séparée pour créer un **jalon** : c'est une phase dont on coche la case +« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens +à tout moment. + +La colonne se **redimensionne** en glissant la ligne qui la sépare de la frise, en haut à +gauche : un double-clic revient à la largeur d'origine. Cette largeur est propre au navigateur, +elle n'est pas enregistrée dans le planning. Une fois un projet en place : @@ -47,9 +61,12 @@ Une fois un projet en place : | Glisser le corps d'une barre | Décale la phase, durée conservée | | Glisser le bord d'une barre | Change la date de début ou de fin | | Clic sur une barre ou son nom | Ouvre le panneau de détail | +| Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase | +| Clic sur un nom de phase hors écran | Ramène la frise sur elle | | `←` `→` | Décale d'un jour la phase sélectionnée | | `Maj` + `←` `→` | Allonge ou raccourcit d'un jour | | `Échap` | Annule le glisser en cours, ou ferme le panneau | +| Clic dans une zone vide | Ferme le panneau ouvert | | `Maj` + molette | Fait défiler la frise horizontalement | | `↑` `↓` dans un menu | Parcourt les entrées | @@ -79,8 +96,10 @@ par les champs du panneau de détail. - **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin, un statut, des notes libres. Une phase peut être un **jalon** (une date unique, rendue en losange). - **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil. -- **Couloirs pliables** : déplié, on voit chaque phase sur sa ligne ; plié, le projet devient une - barre unique segmentée en teintes, qui donne la forme d'ensemble sans le détail. +- **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la + forme d'ensemble, toujours visible sur la ligne du projet. Elle ne se glisse pas. +- **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il + ne reste que celle-ci, en plus épais. - **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa durée. Les barres s'accrochent au lundi ; pour une date exacte, on passe par le panneau de détail. - **Planning de référence** : une fois le planning validé, on le fige. La frise affiche alors une @@ -142,8 +161,9 @@ Node suffit. | `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état | | `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli | | `js/drag.js` | Glisser et redimensionner les barres | -| `js/detail.js` | Panneau de détail | -| `js/menu.js` | Menu contextuel et boîtes de dialogue | +| `js/detail.js` | Panneau de détail d'une phase | +| `js/projet.js` | Panneau des paramètres d'un projet : nom, couleur, tags | +| `js/menu.js` | Menu contextuel et dialogue de confirmation | | `js/app.js` | Amorçage, état en mémoire, câblage des événements | Le rendu utilise des éléments DOM positionnés en absolu, pas de SVG ni de bibliothèque Gantt. diff --git a/css/style.css b/css/style.css index 89b49ce..b758326 100644 --- a/css/style.css +++ b/css/style.css @@ -25,7 +25,9 @@ --hauteur-barre: 26px; --hauteur-ligne: 38px; --hauteur-entete: 66px; /* trois bandes de 22 px : année, mois, semaine */ - --largeur-libelles: 250px; + /* Doit rester en accord avec `LARGEUR_LIBELLES.defaut` (app.js), sur lequel + retombe le double-clic de la poignée. */ + --largeur-libelles: 300px; --rayon: 6px; --ombre: 0 1px 2px rgb(15 23 42 / 0.08); @@ -268,6 +270,37 @@ button[aria-pressed="true"] { border-bottom: 1px solid var(--bordure); } +/* Zone de saisie large de 9 px pour un trait qui n'en fait qu'un : viser une + bordure d'un pixel à la souris est un exercice de patience. */ +.poignee-colonne { + position: absolute; + top: 0; + bottom: 0; + right: -4px; + width: 9px; + cursor: col-resize; + z-index: 7; +} + +.poignee-colonne::after { + content: ""; + position: absolute; + inset: 0 4px; + background: var(--accent); + opacity: 0; + transition: opacity 0.12s; +} + +.poignee-colonne:hover::after, +.poignee-colonne:focus-visible::after, +.poignee-colonne[data-glisse="true"]::after { + opacity: 1; +} + +.poignee-colonne:focus-visible { + outline: none; +} + .grille__entete { position: sticky; top: 0; @@ -369,6 +402,9 @@ button[aria-pressed="true"] { /* --- libellés ------------------------------------------------------------ */ .libelle-projet { + --fond-libelle: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface)); + + position: relative; display: flex; align-items: center; gap: 6px; @@ -376,25 +412,72 @@ button[aria-pressed="true"] { padding: 0 8px 0 6px; font-weight: 600; border-bottom: 1px solid var(--bordure); - background: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface)); + background: var(--fond-libelle); } +/* Commandes secondaires, en surimpression sur le bord droit de la ligne. Le + dégradé leur donne un fond opaque sans trancher : le nom qu'elles recouvrent + s'estompe au lieu d'être coupé net. */ +.libelle-projet__commandes { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + gap: 2px; + padding: 0 6px 0 18px; + opacity: 0; + background: linear-gradient(to right, transparent, var(--fond-libelle) 18px); + transition: opacity 0.12s; +} + +.libelle-projet:hover .libelle-projet__commandes, +.libelle-projet:focus-within .libelle-projet__commandes { + opacity: 1; +} + +/* Un projet masqué garde ses commandes visibles : le menu est le seul chemin + pour le rétablir, il ne doit pas falloir le deviner sous le curseur. */ +.libelle-projet--masque .libelle-projet__commandes { + opacity: 1; +} + +/* Le nom est ce qui identifie le projet : il cède en dernier, et jamais + au-delà d'une largeur où il reste reconnaissable. Sous l'ancienne règle + (`min-width: 4em`), les tags et l'étiquette de dérive le réduisaient à + « Refon… » dans une colonne pourtant deux fois trop large pour lui. */ .libelle-projet__nom { - flex: 1; - /* Le nom cède la place aux pastilles, mais jamais entièrement : sous quatre - caractères il ne resterait qu'un « … » qui n'identifie plus rien. */ - min-width: 4em; + flex: 1 1 auto; + min-width: 7em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } -/* Les pastilles rognent le nom, pas l'inverse — mais elles s'effacent à leur - tour plutôt que de repousser l'œil et le menu hors de la colonne. */ .libelle-projet .tags { flex: 0 1 auto; - max-width: 45%; + max-width: 40%; +} + +/* Une pastille écrasée à « c. » occupe la place d'un tag sans en donner + l'information. Elle garde donc une largeur minimale et cède en bloc : + `pastillesTags` n'en affiche qu'un nombre fixe, suivies d'un compteur. */ +.libelle-projet .tag { + flex: 0 0 auto; + min-width: 3.4em; + text-align: center; +} + +.tag--reste { + flex: 0 0 auto; + align-self: center; + font-size: 10px; + font-weight: 500; + color: var(--texte-doux); + white-space: nowrap; + cursor: default; } .libelle-projet__derive { @@ -429,19 +512,6 @@ button[aria-pressed="true"] { transform: rotate(90deg); } -.icone-oeil { - border: 0; - background: none; - padding: 2px 4px; - cursor: pointer; - opacity: 0.45; - font-size: 12px; -} - -.icone-oeil:hover { - opacity: 1; -} - /* `display: block` et non `flex` : `text-overflow: ellipsis` ne s'applique pas au texte d'un conteneur flex, et le nom serait coupé net. Le centrage vertical passe donc par `line-height`. */ @@ -487,16 +557,36 @@ button[aria-pressed="true"] { color: #fff; font-size: 12px; line-height: var(--hauteur-barre); - padding: 0 7px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; cursor: grab; box-shadow: var(--ombre); user-select: none; z-index: 2; } +/* Le nom se décale pour rester au bord de la vue quand la barre s'enfonce sous + la colonne des libellés — le cas d'une phase longue dont on ne voit que la + fin. Trois conditions le rendent possible, et aucune n'est décorative : + - `sticky` s'ancre au conteneur défilant le plus proche. Un `overflow: + hidden` sur `.barre` en ferait ce conteneur, et le décalage n'aurait plus + lieu : le rognage du texte trop long est donc porté ici, pas sur la barre. + - le décalage est plafonné par la boîte du parent, donc le nom ne sort jamais + de sa barre — d'où `inline-block`, qui laisse au texte la marge de manœuvre + qu'un bloc pleine largeur n'aurait pas. + - `left` vaut la largeur de la colonne des libellés : c'est là que commence + la portion réellement visible de la frise. */ +.barre__nom { + position: sticky; + left: var(--largeur-libelles); + display: inline-block; + max-width: 100%; + padding: 0 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: top; + pointer-events: none; +} + .barre:hover { filter: brightness(1.08); } @@ -604,11 +694,11 @@ button[aria-pressed="true"] { z-index: 3; } -/* --- projet plié --------------------------------------------------------- */ +/* --- barre cumulative d'un projet ---------------------------------------- */ -/* Barre unique segmentée par phase. Lecture seule : voir docs/decisions.md, - section 8. */ -.barre-pliee { +/* Barre unique segmentée par phase, affichée que le projet soit plié ou + déplié. Lecture seule : voir docs/decisions.md, section 8. */ +.barre-cumulative { position: absolute; top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2); height: var(--hauteur-barre); @@ -645,6 +735,17 @@ button[aria-pressed="true"] { opacity: 0.85; } +/* Dépliée, la barre cumulative coiffe les phases du projet : elle s'amincit + pour ne pas se faire passer pour l'une d'elles — elle ne se glisse pas. */ +.barre-cumulative--fine { + --hauteur-cumulative: 9px; + + top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2); + height: var(--hauteur-cumulative); + border-radius: 3px; + box-shadow: none; +} + /* --- barre fantôme de référence ------------------------------------------ */ .fantome { @@ -665,8 +766,18 @@ button[aria-pressed="true"] { z-index: 1; } +/* Le fantôme suit la barre qu'il double : sous une barre cumulative amincie, + il flotterait sinon dans le vide. Déclaré après `.fantome`, dont il partage + la spécificité — l'ordre est ce qui le fait gagner. */ +.fantome--fin { + top: calc((var(--hauteur-ligne) + 9px) / 2); + height: 4px; +} + /* --- panneau de détail --------------------------------------------------- */ +/* Non modal : aucun voile derrière lui, la frise reste manipulable pendant + l'édition et cliquer une autre barre y bascule la sélection. */ .panneau { position: fixed; top: 0; @@ -682,6 +793,13 @@ button[aria-pressed="true"] { flex-direction: column; } +/* Le panneau reçoit le focus à l'ouverture pour que le clavier le suive, mais + il n'est pas un contrôle : un anneau de focus autour de tout le panneau + n'apprendrait rien et attirerait l'œil loin du champ à remplir. */ +.panneau:focus { + outline: none; +} + .panneau__entete { display: flex; align-items: center; @@ -771,13 +889,6 @@ button[aria-pressed="true"] { margin-top: 4px; } -.voile { - position: fixed; - inset: 0; - background: rgb(15 23 42 / 0.15); - z-index: 15; -} - /* --- menu contextuel ------------------------------------------------------ */ .menu { @@ -830,7 +941,8 @@ button[aria-pressed="true"] { border-top: 1px solid var(--bordure); } -.menu-projet { +/* Commandes de la ligne d'un projet : ajouter une phase, ouvrir le menu. */ +.commande-projet { border: 0; background: none; padding: 2px 5px; @@ -841,13 +953,16 @@ button[aria-pressed="true"] { border-radius: 4px; } -.menu-projet:hover { +.commande-projet:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--texte); } -/* --- dialogues ------------------------------------------------------------ */ +/* --- dialogue de confirmation --------------------------------------------- */ +/* Seul `` restant : les paramètres d'un projet ont rejoint un panneau + (voir `.panneau`). Modal ici à dessein — interrompre est le but quand une + action est irréversible. */ .dialogue { padding: 0; border: 1px solid var(--bordure-forte); @@ -880,24 +995,6 @@ button[aria-pressed="true"] { line-height: 1.5; } -.dialogue label { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - font-weight: 500; - color: var(--texte-doux); -} - -.dialogue input[type="text"] { - font: inherit; - padding: 7px 9px; - border: 1px solid var(--bordure-forte); - border-radius: var(--rayon); - background: var(--fond); - color: var(--texte); -} - .couleurs { margin: 0; padding: 0; @@ -931,11 +1028,6 @@ button[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--surface); } -.dialogue__erreur { - color: var(--danger) !important; - font-size: 12px; -} - .dialogue__actions { display: flex; justify-content: flex-end; diff --git a/docs/decisions.md b/docs/decisions.md index 3434c57..62b22c4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -92,15 +92,20 @@ Gérer les jours ouvrés impose de faire passer chaque calcul de date par un hel aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation. -## 8. Le mode plié est en lecture seule +## 8. La barre cumulative est en lecture seule -Un couloir plié affiche une barre unique segmentée par phase. On aurait pu rendre cette barre -glissable pour décaler tout le projet en bloc. +La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. On +aurait pu la rendre glissable pour décaler tout le projet en bloc. Écarté pour une raison de lisibilité du geste : la même barre représenterait tantôt une phase, tantôt un projet entier, et un glissement de quelques pixels déplacerait alors plusieurs mois de travail -d'un coup, sans que l'utilisateur voie ce qui bouge. Le mode plié sert à regarder ; pour modifier, on -déplie. +d'un coup, sans que l'utilisateur voie ce qui bouge. Elle sert à regarder ; pour modifier, on agit +sur les phases. + +**Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce +qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était +en train d'éditer. Dépliée, elle voisine avec les barres de phases : elle s'amincit alors pour qu'on +ne la prenne pas pour l'une d'elles, et le fantôme de référence s'amincit avec elle. ## 9. Accroche à la semaine @@ -239,15 +244,17 @@ liste des tags actifs pour savoir dans quel sens la prochaine case va jouer. **Le filtre n'est pas enregistré.** C'est un état de vue, comme la fenêtre temporelle (décision 15) : un filtre qui survivrait au rechargement rouvrirait le planning amputé sans -qu'on se souvienne pourquoi. Le masquage par l'œil (`hidden`), lui, est bien enregistré — +qu'on se souvienne pourquoi. Le masquage d'un projet (`hidden`), lui, est bien enregistré — et les deux ne font pas la même chose : -| | Filtre par tags | Œil (`hidden`) | +| | Filtre par tags | Masquage (`hidden`) | |---|---|---| | Portée | La vue entière, libellé compris | Les barres seules, le libellé reste grisé | | Durée de vie | La session | Enregistré dans le fichier | | Question posée | « Je ne regarde que les projets clients » | « Ce projet existe, mais il encombre ma frise » | +Le masquage n'a plus de bouton dédié dans la colonne : voir décision 20. + **La couleur d'un tag est calculée, pas choisie** : une teinte HSL dérivée du nom par hachage. Rien à stocker, rien à saisir, et le même tag garde sa couleur partout. Des collisions de teinte sont possibles et sans gravité — le nom est toujours écrit à côté. @@ -261,3 +268,168 @@ appliquée en normalisant, ce qui la rend vraie par construction — aussi bien Créer un projet alors qu'un filtre est actif **préremplit ses tags** avec ceux du filtre : sans cela, le nouveau venu naîtrait invisible, hors de la vue depuis laquelle on vient de le demander. + +## 17. Le panneau de détail n'est pas modal + +Le panneau s'ouvrait derrière un voile couvrant toute la page. L'intention était +raisonnable — concentrer l'attention sur la phase en cours d'édition —, mais elle +allait contre ce que fait un outil de frise : on y compare, on passe d'une phase à +la suivante, on ajuste en regardant le voisinage. + +Trois conséquences, dont deux invisibles à la lecture du code : + +- passer d'une phase à l'autre imposait fermer, cliquer, rouvrir, alors que c'est + le geste le plus fréquent de l'outil ; +- le raccourci `←` `→` était **inatteignable**. Le panneau plaçait le focus dans + le champ « Nom », et `app.js` ignore les flèches quand la frappe part dans un + `input` — pour en sortir il aurait fallu cliquer ailleurs, ce que le voile + interdisait précisément ; +- le rafraîchissement du panneau depuis la frise, prévu pour qu'un glisser se + reflète dans les champs, ne pouvait jamais se produire à la souris. + +Le voile a donc disparu. Le panneau reste posé sur le bord droit, la frise +continue de vivre derrière lui, et cliquer une autre barre y bascule la +sélection. Le focus se pose sur le panneau lui-même — assez pour que le clavier +le suive, pas assez pour capturer les flèches. Le champ « Nom » n'est +présélectionné que pour une phase fraîchement créée, dont le nom provisoire est +justement là pour être écrasé. + +**Cliquer à côté ferme quand même.** C'était le seul service que rendait le +voile, et il ne valait pas la frise inerte qu'il coûtait. Un écouteur sur +`document` le rend sans calque : il ferme les panneaux au `pointerdown`, sauf +sur deux familles de cibles. + +- Les **surfaces protégées** — un panneau, le menu, un dialogue, la poignée de + colonne — où l'on est manifestement au travail. +- Les **cibles ouvrantes** : une barre, une poignée de barre, un libellé de + phase, tout élément portant `data-action`. Leur propre gestionnaire ouvre déjà + le bon panneau, et les faire passer par la fermeture générale serait pire + qu'inutile : celle-ci redessine la frise, ce qui détacherait du DOM l'élément + visé avant que son gestionnaire — ou le glisser qui commence peut-être — n'ait + fini. C'est aussi pourquoi `fermer()` sort tout de suite quand le panneau est + déjà fermé : sans cette garde, chaque clic dans le vide relancerait un rendu + complet. + +Échap et la croix restent les deux autres façons de fermer. + +## 18. La colonne des libellés est redimensionnable + +La colonne portait, sur 250 pixels fixes, le triangle de pli, le nom, les tags, +l'étiquette de dérive, l'œil et le menu. Sur le jeu d'exemple, il ne restait que +**56 pixels au nom** : « Refonte du site web » s'affichait « Refon… », pendant que +« client » se réduisait à « c. ». Le pire des deux mondes — des pastilles qui +occupaient la place d'un tag sans en livrer le nom. + +Trois corrections, dans l'ordre de leur rendement : + +- **Les commandes passent en surimpression**, révélées au survol de la ligne. + Ce sont des actions occasionnelles ; elles coûtaient soixante pixels en + permanence à une information lue en continu. Elles sont posées en `absolute`, + et non retirées du flux au repos, pour que rien ne se déplace à l'approche du + curseur. Elles restent visibles sans survol sur un projet masqué, seul chemin + pour le rétablir. +- **Les tags cèdent en bloc plutôt que de rétrécir.** Deux pastilles au plus, + puis un compteur `+N` dont l'infobulle donne les manquants. Un tag présent dans + le filtre courant passe devant : c'est lui qui explique la présence du projet à + l'écran, il ne doit pas tomber dans le compteur. +- **Le nom cède en dernier**, et pas en deçà de sept caractères. + +Restait qu'aucune largeur ne convient à la fois à « Site web » et à « Refonte du +portail fournisseurs — lot 2 ». D'où une poignée dans le coin de la grille — +seul point de la colonne qui reste visible quel que soit le défilement —, avec +double-clic pour revenir au défaut et flèches au clavier. + +**Cette largeur est conservée, dans `localStorage`.** C'est la première entorse à +la règle des décisions 15 et 16, où les états de vue meurent avec la session, et +elle est délibérée : la fenêtre temporelle et le filtre décrivent *ce qu'on +regarde*, une largeur de colonne décrit *l'écran sur lequel on regarde*. La +placer dans le planning imposerait à un portable la largeur choisie sur un +27 pouces ; ne pas la garder du tout obligerait à réajuster la poignée à chaque +ouverture, ce qui la rendrait inutile. `localStorage` est exactement le bon +rangement : local à la machine, hors des données. + +## 19. Le nom d'une phase reste accroché au bord visible + +Le libellé d'une barre était écrit à son bord gauche. Une phase de plusieurs mois +dont le début sortait de l'écran occupait donc toute la largeur de la vue sans +qu'on puisse lire son nom, parti sous la colonne des libellés. + +Le nom vit désormais dans son propre élément, en `position: sticky`, calé sur la +largeur de la colonne — c'est-à-dire sur le premier pixel réellement visible de +la frise. Trois détails le rendent possible, et aucun n'est cosmétique : + +- un `overflow: hidden` sur la barre en aurait fait le conteneur défilant de + référence, et le décalage n'aurait plus eu lieu. Le rognage du texte trop long + est donc porté par le nom lui-même, pas par la barre ; +- le décalage est plafonné par la boîte du parent, ce qui garantit que le nom ne + sort jamais de sa barre — d'où `inline-block`, qui laisse au texte la marge de + manœuvre qu'un bloc pleine largeur n'aurait pas ; +- quand le nom occupe presque toute sa barre, la marge s'épuise et il finit par + s'enfoncer sous la colonne. On lit alors sa fin plutôt que rien, ce qui reste + un progrès, et la colonne des libellés donne de toute façon le nom entier. + +## 20. Ce que porte la ligne d'un projet + +Trois commandes s'y étaient accumulées sans qu'on se demande si chacune méritait +sa place. Le tri s'est fait sur une seule question : cette action est-elle assez +fréquente pour valoir un bouton permanent ? + +**L'œil disparaît.** Il faisait doublon avec l'entrée « Masquer de la frise » du +menu, qui reste. Le masquage est une décision durable sur un projet — on le pose +une fois et on l'oublie — pas un geste qu'on refait dix fois par séance. Depuis +que les tags existent (décision 16), c'est d'ailleurs le filtre qui répond au +besoin courant de dégager la vue. La fonction demeure, elle ne coûte simplement +plus de pixels à tout le monde en permanence. Le format de données est inchangé : +`hidden` reste un champ du projet. + +**Le `+` apparaît.** Ajouter une phase est l'action la plus fréquente de l'outil, +et elle était enterrée dans un menu. + +**Le nom du projet devient cliquable.** Il portait déjà `cursor: pointer` sans +qu'aucune action n'y réponde — un curseur qui promet et ne tient pas. Il ouvre +maintenant le dialogue des paramètres, ce que la main annonçait. + +**Il n'y a plus de commande « Ajouter un jalon ».** Un jalon n'est pas un autre +objet : c'est une phase dont la fin rejoint le début, soit une case à cocher du +panneau au même titre que le statut. Deux entrées de menu obligeaient à trancher +avant d'avoir rien saisi, alors que la bascule reste possible ensuite dans les +deux sens. Une seule commande crée une phase ; on coche « Jalon » si c'en est un. + +## 21. Un projet s'édite comme une phase + +Les paramètres d'un projet — nom, couleur, tags — passaient par un `` +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 `` 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. diff --git a/index.html b/index.html index 7491d7f..2e89473 100644 --- a/index.html +++ b/index.html @@ -42,7 +42,21 @@ -->
-
+
+ + +
@@ -61,8 +75,14 @@

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