Filtrer la frise par tag

Les tags s'affichent en pastilles colorées dans la colonne des libellés, et une
barre de filtres apparaît sous la barre d'outils dès qu'un tag existe. Cliquer un
tag — dans cette barre ou directement sur la pastille d'un projet — restreint la
frise ; recliquer le retire.

Le filtre est conjonctif : un projet doit porter tous les tags cochés. Cocher un
tag de plus resserre donc toujours la sélection, sans jamais faire se repeupler
la frise. Un message dédié prend la place de la frise quand plus rien ne passe le
filtre, sans quoi elle laisserait croire que les projets ont disparu.

Le filtre est un état de vue, jamais enregistré, au même titre que la fenêtre
temporelle. Il ne fait pas le même travail que le masquage par l'œil, qui reste
enregistré : le filtre retire le projet de la vue entière, l'œil garde son
libellé grisé et n'ôte que les barres.

Deux ajustements de confort : créer un projet sous filtre actif préremplit ses
tags avec ceux du filtre, sinon le nouveau venu naîtrait invisible ; et un tag
que plus aucun projet ne porte est retiré du filtre, sinon il masquerait tout.

Le dialogue d'un projet gagne un champ de saisie et propose en un clic les tags
déjà employés ailleurs, seul garde-fou contre la dérive orthographique.

Dans la colonne des libellés, les pastilles rognent le nom du projet mais lui
laissent quatre caractères, et s'effacent à leur tour plutôt que de repousser
l'œil et le menu hors de la colonne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 05:52:28 +02:00
parent 3be367488c
commit 39ea249cdb
5 changed files with 372 additions and 20 deletions

View File

@@ -140,6 +140,101 @@ button[aria-pressed="true"] {
color: #fff; color: #fff;
} }
/* --- barre de filtres ----------------------------------------------------- */
.filtres {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 16px;
background: var(--surface);
border-bottom: 1px solid var(--bordure);
flex-shrink: 0;
}
.filtres__titre {
font-size: 12px;
font-weight: 500;
color: var(--texte-doux);
white-space: nowrap;
}
.filtres button:not(.tag) {
font-size: 12px;
padding: 3px 9px;
margin-left: auto;
white-space: nowrap;
}
/* --- tags ----------------------------------------------------------------- */
/* Chaque pastille porte sa teinte dans `--teinte-tag`, calculée par hachage du
nom du tag (model.js). La saturation et la clarté restent fixes, elles : ce
sont elles qui garantissent le contraste, quelle que soit la teinte tirée. */
.tags {
display: flex;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.tags--filtres,
.tags--suggestions {
flex-wrap: wrap;
overflow: visible;
}
.tag {
font: inherit;
font-size: 10px;
font-weight: 500;
line-height: 1.6;
padding: 0 6px;
max-width: 84px;
border: 1px solid transparent;
border-radius: 9px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: hsl(var(--teinte-tag) 62% 92%);
color: hsl(var(--teinte-tag) 55% 27%);
}
.tags--filtres .tag,
.tags--suggestions .tag {
font-size: 11px;
padding: 1px 9px;
max-width: none;
border-radius: 11px;
}
.tag:hover {
border-color: hsl(var(--teinte-tag) 45% 55%);
}
/* Un tag actif s'inverse : le fond prend la teinte pleine. Il faut donc
neutraliser la règle générale `button[aria-pressed="true"]`, qui repeindrait
toutes les pastilles de la même couleur d'accent. */
.tag[aria-pressed="true"] {
background: hsl(var(--teinte-tag) 52% 38%);
border-color: hsl(var(--teinte-tag) 52% 38%);
color: #fff;
}
@media (prefers-color-scheme: dark) {
.tag {
background: hsl(var(--teinte-tag) 34% 24%);
color: hsl(var(--teinte-tag) 62% 76%);
}
.tag[aria-pressed="true"] {
background: hsl(var(--teinte-tag) 58% 62%);
border-color: hsl(var(--teinte-tag) 58% 62%);
color: #0f172a;
}
}
/* --- structure de la frise ------------------------------------------------ */ /* --- structure de la frise ------------------------------------------------ */
/* Un seul conteneur défilant, sur les deux axes. L'en-tête et la colonne des /* Un seul conteneur défilant, sur les deux axes. L'en-tête et la colonne des
@@ -286,12 +381,22 @@ button[aria-pressed="true"] {
.libelle-projet__nom { .libelle-projet__nom {
flex: 1; 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;
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 {
flex: 0 1 auto;
max-width: 45%;
}
.libelle-projet__derive { .libelle-projet__derive {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;

View File

@@ -24,6 +24,16 @@
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p> <p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
</header> </header>
<!--
Barre de filtres, masquée tant qu'aucun projet ne porte de tag : elle
n'apparaît que le jour où elle sert à quelque chose.
-->
<div class="filtres" id="filtres" hidden role="group" aria-label="Filtrer par tag">
<span class="filtres__titre">Filtrer</span>
<div class="tags tags--filtres" id="filtres-liste"></div>
<button type="button" id="effacer-filtres" hidden>Tout afficher</button>
</div>
<!-- <!--
Un seul conteneur défilant, découpé en grille 2×2 : l'en-tête de dates est Un seul conteneur défilant, découpé en grille 2×2 : l'en-tête de dates est
collé en haut (`sticky top`), la colonne des libellés collée à gauche collé en haut (`sticky top`), la colonne des libellés collée à gauche
@@ -46,6 +56,11 @@
</button> </button>
</p> </p>
<p class="vide" id="message-filtre" hidden>
Aucun projet ne porte tous les tags sélectionnés.<br />
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
</p>
<!-- Panneau de détail, ouvert au clic sur une phase. --> <!-- Panneau de détail, ouvert au clic sur une phase. -->
<aside class="panneau" id="panneau" hidden aria-label="Détail de la phase"> <aside class="panneau" id="panneau" hidden aria-label="Détail de la phase">
<div class="panneau__entete"> <div class="panneau__entete">
@@ -117,6 +132,22 @@
<div id="dialogue-projet-couleurs"></div> <div id="dialogue-projet-couleurs"></div>
</fieldset> </fieldset>
<label>
Tags, séparés par des virgules
<input
type="text"
id="dialogue-projet-tags"
autocomplete="off"
placeholder="client, refonte"
/>
</label>
<!-- Masqué tant qu'aucun tag n'existe ailleurs dans le planning. -->
<fieldset class="couleurs" id="dialogue-projet-suggestions-bloc" hidden>
<legend>Tags déjà utilisés</legend>
<div class="tags tags--suggestions" id="dialogue-projet-suggestions"></div>
</fieldset>
<p class="dialogue__erreur" id="dialogue-projet-erreur" role="alert" hidden></p> <p class="dialogue__erreur" id="dialogue-projet-erreur" role="alert" hidden></p>
<menu class="dialogue__actions"> <menu class="dialogue__actions">

115
js/app.js
View File

@@ -18,16 +18,21 @@ import {
ajouterPhase, ajouterPhase,
aujourdhui, aujourdhui,
bornesPlanning, bornesPlanning,
cleTag,
creerPhase, creerPhase,
creerProjet, creerProjet,
figerReference, figerReference,
libererReference, libererReference,
normaliserTags,
planningVide, planningVide,
pourEcriture, pourEcriture,
projetFiltre,
remplacerPhase, remplacerPhase,
remplacerProjet, remplacerProjet,
supprimerPhase, supprimerPhase,
supprimerProjet, supprimerProjet,
teinteTag,
tousLesTags,
validerPlanning, validerPlanning,
versUTC, versUTC,
} from './model.js'; } from './model.js';
@@ -41,6 +46,10 @@ const refs = {
frise: $('frise'), frise: $('frise'),
etat: $('etat-sauvegarde'), etat: $('etat-sauvegarde'),
messageVide: $('message-vide'), messageVide: $('message-vide'),
messageFiltre: $('message-filtre'),
filtres: $('filtres'),
filtresListe: $('filtres-liste'),
effacerFiltres: $('effacer-filtres'),
alerte: $('alerte'), alerte: $('alerte'),
alerteMessage: $('alerte-message'), alerteMessage: $('alerte-message'),
}; };
@@ -49,6 +58,18 @@ let planning = planningVide();
let echelle = null; let echelle = null;
let selection = null; // { projet, phase } let selection = null; // { projet, phase }
/**
* Tags sélectionnés dans la barre de filtres. Comme la fenêtre temporelle,
* c'est un état de **vue** : jamais enregistré, jamais partagé.
*
* Un filtre qui survivrait au rechargement rouvrirait le planning amputé sans
* qu'on se souvienne pourquoi. `hidden`, lui, est bien enregistré — c'est une
* décision durable sur un projet, pas un coup d'œil.
*
* La sélection est conjonctive : un projet doit porter *tous* les tags cochés.
*/
let tagsActifs = [];
/** /**
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré. * Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
* *
@@ -83,10 +104,14 @@ const demanderProjet = creerDialogueProjet(
titre: $('dialogue-projet-titre'), titre: $('dialogue-projet-titre'),
champNom: $('dialogue-projet-nom'), champNom: $('dialogue-projet-nom'),
conteneurCouleurs: $('dialogue-projet-couleurs'), conteneurCouleurs: $('dialogue-projet-couleurs'),
champTags: $('dialogue-projet-tags'),
suggestionsTags: $('dialogue-projet-suggestions'),
blocSuggestions: $('dialogue-projet-suggestions-bloc'),
erreur: $('dialogue-projet-erreur'), erreur: $('dialogue-projet-erreur'),
valider: $('dialogue-projet-ok'), valider: $('dialogue-projet-ok'),
}, },
COULEURS COULEURS,
{ normaliserTags, cleTag, teinteTag }
); );
const demanderConfirmation = creerDialogueConfirmation({ const demanderConfirmation = creerDialogueConfirmation({
@@ -142,14 +167,26 @@ const panneau = creerPanneau(
/** Unique point d'entrée des modifications : rend puis sauvegarde. */ /** Unique point d'entrée des modifications : rend puis sauvegarde. */
function majPlanning(suivant) { function majPlanning(suivant) {
planning = suivant; planning = suivant;
// Retirer son dernier tag à un projet peut vider le filtre de sa substance :
// sans cet élagage, un tag que plus personne ne porte masquerait tout.
tagsActifs = tagsActifs.filter((tag) =>
planning.projects.some((projet) => projet.tags.some((pose) => cleTag(pose) === cleTag(tag)))
);
dessiner(); dessiner();
stockage.sauvegarder(pourEcriture(planning)); stockage.sauvegarder(pourEcriture(planning));
} }
function dessiner() { function dessiner() {
const aDesProjets = planning.projects.length > 0; const aDesProjets = planning.projects.length > 0;
refs.frise.hidden = !aDesProjets; const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
refs.messageVide.hidden = aDesProjets; refs.messageVide.hidden = aDesProjets;
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
// message, la frise vide laisserait croire que les projets ont disparu.
refs.messageFiltre.hidden = !aDesProjets || retenus.length > 0;
refs.frise.hidden = !aDesProjets || retenus.length === 0;
peindreFiltres();
if (!aDesProjets) { if (!aDesProjets) {
echelle = null; echelle = null;
@@ -157,7 +194,7 @@ function dessiner() {
} }
elargirPourContenir(); elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, selection }); echelle = rendre(refs, planning, { fenetre, selection, tagsActifs });
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser // Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
// sur la frise doit se voir dans les champs. // sur la frise doit se voir dans les champs.
@@ -193,8 +230,62 @@ function afficherAlerte(message) {
// derrière elle brouillerait le diagnostic si le style venait à changer. // derrière elle brouillerait le diagnostic si le style venait à changer.
refs.frise.hidden = true; refs.frise.hidden = true;
refs.messageVide.hidden = true; refs.messageVide.hidden = true;
refs.messageFiltre.hidden = true;
refs.filtres.hidden = true;
} }
// ---------------------------------------------------------------------------
// Filtre par tags
// ---------------------------------------------------------------------------
/**
* Reconstruit la barre de filtres depuis les tags réellement employés.
*
* La liste n'est pas un référentiel à tenir à jour : elle se déduit des projets,
* si bien qu'un tag apparaît dès qu'on le pose et disparaît dès qu'il n'orne
* plus rien.
*/
function peindreFiltres() {
const tags = tousLesTags(planning);
refs.filtres.hidden = tags.length === 0;
refs.effacerFiltres.hidden = tagsActifs.length === 0;
const clesActives = new Set(tagsActifs.map(cleTag));
refs.filtresListe.replaceChildren(
...tags.map((tag) => {
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'tag';
bouton.style.setProperty('--teinte-tag', String(teinteTag(tag)));
bouton.textContent = tag;
const actif = clesActives.has(cleTag(tag));
bouton.setAttribute('aria-pressed', String(actif));
bouton.title = actif ? `Retirer « ${tag} » du filtre` : `N'afficher que les projets « ${tag} »`;
bouton.addEventListener('click', () => basculerTag(tag));
return bouton;
})
);
}
/** Ajoute le tag au filtre, ou l'en retire s'il y était déjà. */
function basculerTag(tag) {
const cle = cleTag(tag);
const restants = tagsActifs.filter((actif) => cleTag(actif) !== cle);
tagsActifs =
restants.length === tagsActifs.length ? normaliserTags([...tagsActifs, tag]) : restants;
dessiner();
}
function effacerFiltres() {
tagsActifs = [];
dessiner();
}
refs.effacerFiltres.addEventListener('click', effacerFiltres);
$('effacer-filtres-vide').addEventListener('click', effacerFiltres);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fenêtre temporelle et défilement sans fin // Fenêtre temporelle et défilement sans fin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -321,6 +412,11 @@ refs.libelles.addEventListener('click', (evenement) => {
return; return;
} }
if (cible.dataset.action === 'tag') {
basculerTag(cible.dataset.tag);
return;
}
const idProjet = cible.dataset.projet; const idProjet = cible.dataset.projet;
if (cible.dataset.action === 'plier') { if (cible.dataset.action === 'plier') {
@@ -428,6 +524,8 @@ async function renommerProjet(projet) {
libelleValider: 'Enregistrer', libelleValider: 'Enregistrer',
nom: projet.name, nom: projet.name,
couleur: projet.color, couleur: projet.color,
tags: projet.tags,
tagsConnus: tousLesTags(planning),
}); });
if (!reponse) return; if (!reponse) return;
@@ -437,6 +535,7 @@ async function renommerProjet(projet) {
...p, ...p,
name: reponse.nom, name: reponse.nom,
color: reponse.couleur, color: reponse.couleur,
tags: reponse.tags,
})) }))
); );
} }
@@ -488,10 +587,18 @@ async function nouveauProjet() {
titreTexte: 'Nouveau projet', titreTexte: 'Nouveau projet',
libelleValider: 'Créer', libelleValider: 'Créer',
couleur: COULEURS[planning.projects.length % COULEURS.length], 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; if (!reponse) return;
const projet = { ...creerProjet(planning, reponse.nom), color: reponse.couleur }; const projet = {
...creerProjet(planning, reponse.nom, reponse.tags),
color: reponse.couleur,
};
majPlanning({ ...planning, projects: [...planning.projects, projet] }); majPlanning({ ...planning, projects: [...planning.projects, projet] });
} }

View File

@@ -135,11 +135,22 @@ function positionner(menu, ancre) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Demande le nom et la couleur d'un projet. * Demande le nom, la couleur et les tags d'un projet.
* @returns {Promise<{nom: string, couleur: string} | null>} null si annulé. * @returns {Promise<{nom: string, couleur: string, tags: string[]} | null>} null si annulé.
*/ */
export function creerDialogueProjet(refs, couleurs) { export function creerDialogueProjet(refs, couleurs, outilsTags) {
const { dialogue, titre, champNom, conteneurCouleurs, erreur, valider } = refs; const {
dialogue,
titre,
champNom,
conteneurCouleurs,
champTags,
suggestionsTags,
blocSuggestions,
erreur,
valider,
} = refs;
const { normaliserTags, cleTag, teinteTag } = outilsTags;
let choisie = couleurs[0]; let choisie = couleurs[0];
function peindrePalette() { function peindrePalette() {
@@ -159,13 +170,59 @@ export function creerDialogueProjet(refs, couleurs) {
} }
} }
return function demander({ titreTexte, libelleValider, nom = '', couleur = couleurs[0] }) { /** 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; titre.textContent = titreTexte;
valider.textContent = libelleValider; valider.textContent = libelleValider;
champNom.value = nom; champNom.value = nom;
champTags.value = tags.join(', ');
choisie = couleur; choisie = couleur;
erreur.hidden = true; erreur.hidden = true;
peindrePalette(); peindrePalette();
peindreSuggestions(tagsConnus);
const rafraichirSuggestions = () => peindreSuggestions(tagsConnus);
champTags.addEventListener('input', rafraichirSuggestions);
dialogue.showModal(); dialogue.showModal();
champNom.focus(); champNom.focus();
@@ -175,11 +232,12 @@ export function creerDialogueProjet(refs, couleurs) {
dialogue.addEventListener( dialogue.addEventListener(
'close', 'close',
() => { () => {
champTags.removeEventListener('input', rafraichirSuggestions);
// `returnValue` vaut '' quand le dialogue est fermé par Échap. // `returnValue` vaut '' quand le dialogue est fermé par Échap.
if (dialogue.returnValue !== 'ok') return resoudre(null); if (dialogue.returnValue !== 'ok') return resoudre(null);
const saisi = champNom.value.trim(); const saisi = champNom.value.trim();
if (!saisi) return resoudre(null); if (!saisi) return resoudre(null);
resoudre({ nom: saisi, couleur: choisie }); resoudre({ nom: saisi, couleur: choisie, tags: tagsSaisis() });
}, },
{ once: true } { once: true }
); );

View File

@@ -17,12 +17,15 @@ import {
aujourdhui, aujourdhui,
bornesProjet, bornesProjet,
bornesReference, bornesReference,
cleTag,
deriveProjet, deriveProjet,
dimancheDeLaSemaine, dimancheDeLaSemaine,
ecartJours, ecartJours,
formulerDerive, formulerDerive,
lundiDeLaSemaine, lundiDeLaSemaine,
projetFiltre,
semaineISO, semaineISO,
teinteTag,
versUTC, versUTC,
} from './model.js'; } from './model.js';
@@ -89,19 +92,26 @@ export function creerEchelle(fenetre) {
/** /**
* Reconstruit toute la frise. * Reconstruit toute la frise.
* *
* Deux tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* **filtre par tags** retire le projet de la vue entière, libellé compris : il
* répond à « je ne regarde que les projets clients ». L'**œil** (`hidden`) garde
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
* que ce projet existe, mais il encombre ma frise ».
*
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs) * @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
* @param {object} planning l'état courant * @param {object} planning l'état courant
* @param {object} options { fenetre, selection } * @param {object} options { fenetre, selection, tagsActifs }
*/ */
export function rendre(refs, planning, { fenetre, selection }) { export function rendre(refs, planning, { fenetre, selection, tagsActifs = [] }) {
const echelle = creerEchelle(fenetre); const echelle = creerEchelle(fenetre);
const visibles = planning.projects.filter((projet) => !projet.hidden); const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const visibles = retenus.filter((projet) => !projet.hidden);
refs.entete.style.width = `${echelle.largeur}px`; refs.entete.style.width = `${echelle.largeur}px`;
refs.couloirs.style.width = `${echelle.largeur}px`; refs.couloirs.style.width = `${echelle.largeur}px`;
refs.entete.replaceChildren(...construireEntete(echelle)); refs.entete.replaceChildren(...construireEntete(echelle));
refs.libelles.replaceChildren(...construireLibelles(planning)); refs.libelles.replaceChildren(...construireLibelles(retenus, tagsActifs));
refs.couloirs.replaceChildren( refs.couloirs.replaceChildren(
...construireTraits(echelle, hauteurTotale(visibles)), ...construireTraits(echelle, hauteurTotale(visibles)),
...construireCouloirs(visibles, echelle, selection) ...construireCouloirs(visibles, echelle, selection)
@@ -271,16 +281,16 @@ export function formaterDateLongue(date) {
// Colonne des libellés // Colonne des libellés
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function construireLibelles(planning) { function construireLibelles(projets, tagsActifs) {
const elements = []; const elements = [];
for (const projet of planning.projects) { for (const projet of projets) {
if (projet.hidden) { if (projet.hidden) {
elements.push(libelleProjet(projet)); elements.push(libelleProjet(projet, tagsActifs));
continue; continue;
} }
elements.push(libelleProjet(projet)); elements.push(libelleProjet(projet, tagsActifs));
if (projet.collapsed) continue; if (projet.collapsed) continue;
for (const phase of projet.phases) { for (const phase of projet.phases) {
@@ -297,7 +307,7 @@ function construireLibelles(planning) {
return elements; return elements;
} }
function libelleProjet(projet) { function libelleProjet(projet, tagsActifs = []) {
const ligne = document.createElement('div'); const ligne = document.createElement('div');
ligne.className = `libelle-projet${projet.hidden ? ' libelle-projet--masque' : ''}`; ligne.className = `libelle-projet${projet.hidden ? ' libelle-projet--masque' : ''}`;
ligne.style.setProperty('--couleur-projet', projet.color); ligne.style.setProperty('--couleur-projet', projet.color);
@@ -318,9 +328,15 @@ function libelleProjet(projet) {
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.title = projet.name; // 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;
ligne.append(nom); ligne.append(nom);
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
const derive = formulerDerive(deriveProjet(projet)); const derive = formulerDerive(deriveProjet(projet));
if (derive) { if (derive) {
const etiquette = document.createElement('span'); const etiquette = document.createElement('span');
@@ -358,6 +374,41 @@ function libelleProjet(projet) {
return ligne; return ligne;
} }
/**
* Pastilles de tags d'un projet, dans sa ligne de libellé.
*
* Chaque pastille est un bouton : cliquer un tag l'ajoute au filtre, le
* 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.
*/
function pastillesTags(tags, tagsActifs) {
const conteneur = document.createElement('span');
conteneur.className = 'tags';
// Deux projets peuvent écrire le même tag différemment (« Client », « client ») :
// 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) {
const pastille = document.createElement('button');
pastille.type = 'button';
pastille.className = 'tag';
pastille.style.setProperty('--teinte-tag', String(teinteTag(tag)));
pastille.textContent = tag;
pastille.dataset.action = 'tag';
pastille.dataset.tag = tag;
const actif = clesActives.has(cleTag(tag));
pastille.setAttribute('aria-pressed', String(actif));
pastille.title = actif ? `Retirer « ${tag} » du filtre` : `Filtrer sur « ${tag} »`;
conteneur.append(pastille);
}
return conteneur;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Couloirs // Couloirs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------