[IMP] plan and diagnostic: grouped toolbar, card panels, bounded tooltip

The plan's chrome mixed view controls with panels of the same weight.
The toolbar groups round, zoom and views; side panels, the reserve and
the work lists become cards. A seat tooltip, like a refusal on a seat,
stays inside the plan frame and flips side rather than cover the next
panel. The diagnostic groups its statements into one card per colliding
membership, in the same order. The drawing keeps its colours and shapes.
Checked: node 231, browser 137, 6 mutations caught.

--- FR ---

[IMP] plan et diagnostic : barre groupée, panneaux en cartes, infobulle

L'habillage du plan mêlait commandes de vue et panneaux de même poids.
La barre groupe tour, zoom et vues ; panneaux, réserve et listes de
travail deviennent des cartes. L'infobulle d'un siège, comme un refus
sur une place, reste dans le cadre du plan et change de côté plutôt que
de couvrir le panneau voisin. Le diagnostic groupe ses énoncés en une
carte par appartenance en collision, sans changer l'ordre. Le dessin
garde couleurs et formes. Vérifié : node 231, navigateur 137.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 00:58:07 -04:00
parent c4130d3b5e
commit 901095a1ef
21 changed files with 1185 additions and 319 deletions

View file

@ -31,9 +31,10 @@ import { VERSION } from '../../version.genere.js';
import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { visible } from '../../../test/navigateur/rendu.js';
import { composantes, visible } from '../../../test/navigateur/rendu.js';
import { teintesAppartenances } from '../appartenances.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { enonces, numeroDeCharge } from './phrases.js';
import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const V1 = VERSION.affichee;
@ -131,11 +132,16 @@ const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim();
const charge = (m) => m.seance.etat().charge;
const revision = (m) => m.seance.etat().evenement.revision;
// Chaque énoncé rendu sous la racine : sa clé, son appartenance, son texte,
// et son remède, null quand il n'en porte pas.
// L'intitulé de la liste qui porte un énoncé : la liste d'une carte est
// nommée par son titre (aria-labelledby).
const intituleDe = (li) => document.getElementById(li.closest('ul').getAttribute('aria-labelledby'));
// Chaque énoncé rendu sous la racine : sa clé, son appartenance — la donnée
// du titre de sa carte —, son texte, et son remède, null quand il n'en porte
// pas.
function lus(racine) {
return [...racine.querySelectorAll('[data-enonce]')].map((li) => {
const donnee = li.querySelector('[data-donnee]');
const donnee = intituleDe(li).querySelector('[data-donnee]');
const remede = li.querySelector('[data-remede]');
return {
cle: li.dataset.enonce,
@ -159,6 +165,51 @@ function attendus(chargeLue, { avecRemede }) {
}));
}
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
function jeton(nom) {
const sonde = document.createElement('span');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const couleur = composantes(getComputedStyle(sonde).color);
sonde.remove();
return couleur;
}
// La carte qui porte un élément : son plus proche ancêtre peint du fond de
// la carte, bordé d'un filet et arrondi.
function carteDe(element) {
const fond = jeton('--couleur-carte-fond');
for (let porteur = element.parentElement; porteur !== null; porteur = porteur.parentElement) {
const style = getComputedStyle(porteur);
const peinte = composantes(style.backgroundColor).join() === fond.join();
if (peinte && parseFloat(style.borderTopWidth) > 0 && parseFloat(style.borderTopLeftRadius) > 0) return porteur;
}
return null;
}
// Les cartes attendues : genre, groupe et clés de chaque carte, du moteur
// sollicité une seconde fois.
function cartesAttendues(chargeLue) {
const diagnostic = diagnostiquer(configurationDepuisCharge(chargeLue));
const liste = enonces(diagnostic, { numeroDe: numeroDeCharge(chargeLue), tours: chargeLue.evenement.tours });
return cartesDiagnostic(liste).map(({ genre, groupe, enonces: dedans }) => ({ genre, groupe, cles: dedans.map(({ cle }) => cle) }));
}
// Les cartes rendues sous la racine, lues comme cartesAttendues : chaque
// liste d'énoncés et son titre ; groupe, la donnée du titre.
function cartesLues(racine) {
return [...racine.querySelectorAll('ul')]
.filter((ul) => ul.querySelector('[data-enonce]') !== null)
.map((ul) => {
const donnee = document.getElementById(ul.getAttribute('aria-labelledby')).querySelector('[data-donnee]');
return {
groupe: donnee === null ? null : texteDe(donnee),
cles: [...ul.querySelectorAll('[data-enonce]')].map((li) => li.dataset.enonce),
};
});
}
// --- Les épreuves ------------------------------------------------------------------
describe('le diagnostic se lit sans rien demander (§ 2.1 étape 8, § 5.6)', () => {
@ -312,6 +363,86 @@ describe('la configuration refusée (§ 5.6, § 5.9)', () => {
});
});
describe('le diagnostic en cartes (§ 5.6, § 12.7)', () => {
test('toutes en conflit : la section pose une carte par appartenance, titrée de sa pastille, de la teinte de son rang ; le panneau compact titre chaque groupe de même', async () => {
const m = await ouvrirTexte(TEXTE_TOUTES_EN_CONFLIT, 'toutes_en_conflit');
const rangs = teintesAppartenances(charge(m).participants);
const attendues = cartesAttendues(charge(m));
const appartenances = attendues.filter(({ genre }) => genre === 'appartenance');
expect(appartenances.length).toBe(2);
const sansGenre = attendues.map(({ groupe, cles }) => ({ groupe, cles }));
for (const [nom, racine] of [
['panneau', () => panneau(m)],
['section', () => (m.ui.allerA('diagnostic'), section(m))],
]) {
await attendre(() => expect(cartesLues(racine())).toEqual(sansGenre));
const listes = [...racine().querySelectorAll('ul')].filter((ul) => ul.querySelector('[data-enonce]') !== null);
for (const { groupe } of appartenances) {
const ul = listes.find((liste) => document.getElementById(liste.getAttribute('aria-labelledby')).textContent.includes(groupe));
const titre = document.getElementById(ul.getAttribute('aria-labelledby'));
expect(/^H[2-6]$/.test(titre.tagName), `${nom} ${groupe} : un intitulé`).toBe(true);
const donnee = titre.querySelector('[data-donnee]');
expect(texteDe(donnee)).toBe(groupe);
// La pastille : un point de la marque du rang, le nom de l'encre de la page.
const point = donnee.querySelector('[aria-hidden="true"]');
expect(point, `${nom} ${groupe} : le point de la pastille`).not.toBeNull();
const rang = rangs.get(groupe);
expect(composantes(getComputedStyle(point).backgroundColor)).toEqual(jeton(`--couleur-appartenance-${rang}-marque`));
expect(visible(donnee)).toBe(true);
}
if (nom === 'section') {
// Chaque liste a sa propre carte.
const cartes = listes.map(carteDe);
expect(cartes.every((carte) => carte !== null)).toBe(true);
expect(new Set(cartes).size).toBe(listes.length);
}
}
});
});
describe('le rang de teinte d’une carte se compte sur tous les participants, exclus compris (§ 12.7)', () => {
test('une personne exclue dont l’appartenance se range avant les autres décale les deux cartes aux rangs 2 et 3', async () => {
// La charge « toutes en conflit » et une treizième personne, exclue,
// d'une appartenance qui se range avant les deux autres : le moteur ne
// la voit pas, les collisions restent celles des deux groupes.
const chargeAvecExclue = analyser(TEXTE_TOUTES_EN_CONFLIT).charge;
chargeAvecExclue.participants.push({
id: 13,
nom: 'Varech',
prenom: null,
appartenance: 'Atelier des Alisiers',
courriel: null,
titrePressenti: null,
notes: null,
exclu: true,
});
chargeAvecExclue.prochainsIds = { ...chargeAvecExclue.prochainsIds, participant: 14 };
const m = await ouvrirCharge(chargeAvecExclue);
const attendues = cartesAttendues(charge(m)).filter(({ genre }) => genre === 'appartenance');
expect(attendues.map(({ groupe }) => groupe).sort()).toEqual(['Cercle des Hérons', 'Club des Merles']);
for (const [nom, racine] of [
['panneau', () => panneau(m)],
['section', () => (m.ui.allerA('diagnostic'), section(m))],
]) {
await attendre(() => expect(racine().querySelectorAll('[data-donnee]').length).toBe(2));
for (const [groupe, rang] of [
['Cercle des Hérons', 2],
['Club des Merles', 3],
]) {
const donnee = [...racine().querySelectorAll('[data-donnee]')].find((d) => texteDe(d) === groupe);
expect(donnee, `${nom} ${groupe} : la pastille`).not.toBeUndefined();
const point = donnee.querySelector('[aria-hidden="true"]');
expect(composantes(getComputedStyle(point).backgroundColor), `${nom} ${groupe} : la marque du rang ${rang}`).toEqual(
jeton(`--couleur-appartenance-${rang}-marque`),
);
}
}
});
});
describe('le bandeau du plan énonce le diagnostic (§ 7.5)', () => {
// Les entrées attendues du bandeau, du moteur sollicité une seconde fois :
// la phrase, collisionPartout, puis chaque plancher de groupe.

View file

@ -9,29 +9,40 @@
// fait que traduire. Regarder n'écrit jamais (§ 8.4) : il se lit en
// lecture comme sur un plan bloqué.
//
// Les énoncés se regroupent par cartesDiagnostic, sans changer d'ordre :
// la séparation vidée de son sens, une carte par appartenance en
// collision, puis le reste de l'événement. Chaque groupe a son intitulé,
// qui nomme sa liste (aria-labelledby) : celui d'une appartenance est sa
// pastille, de la teinte que teintesAppartenances donne à son rang sur
// toute la liste des participants, exclus compris, et porte la donnée
// [data-donnee] ; les autres, un intertitre.
//
// compact : la forme du panneau latéral du plan, que SectionPlan monte
// dans [data-panneau-diagnostic] — son titre et les énoncés seuls. Sinon, la section,
// [data-section="diagnostic"] : chaque énoncé suivi du texte de son
// remède et, quand une commande le réalise, de la Commande qui l'exécute
// par la séance ; un refus de la séance — mode lecture, plan bloqué —
// s'affiche à côté du bouton, avec son propre remède.
// dans [data-panneau-diagnostic] — une carte titrée, dont les groupes se
// suivent sous un filet, et les énoncés seuls. Sinon, la section,
// [data-section="diagnostic"] : une carte par groupe, chaque énoncé suivi
// du texte de son remède et, quand une commande le réalise, de la Commande
// qui l'exécute par la séance ; un refus de la séance — mode lecture, plan
// bloqué — s'affiche à côté du bouton, avec son propre remède.
//
// Une configuration que le moteur refuse n'a pas de diagnostic : son
// code, traduit, se pose à sa place, jamais le message de l'erreur.
// L'appartenance d'un groupe est une donnée : elle occupe son propre
// nœud, avant le texte.
import { getContext } from 'svelte';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { teintesAppartenances } from '../appartenances.js';
import Carte from '../primitives/Carte.svelte';
import { actionRemede } from '../primitives/commande.js';
import Commande from '../primitives/Commande.svelte';
import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.svelte';
import { enonces, numeroDeCharge } from './phrases.js';
import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js';
let { compact = false } = $props();
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui') ?? null;
const uid = $props.id();
const charge = $derived($seance.charge);
const resultat = $derived(charge === null ? null : diagnostiquerCharge(charge));
@ -40,53 +51,71 @@
? []
: enonces(resultat.diagnostic, { numeroDe: numeroDeCharge(charge), tours: charge.evenement.tours }),
);
const cartes = $derived(cartesDiagnostic(liste));
// Le rang de teinte de chaque appartenance, sur tous les participants.
const rangs = $derived(charge === null ? new Map() : teintesAppartenances(charge.participants));
// L'intitulé d'un groupe est un h3 dans la section, un h4 sous le titre
// du panneau.
const balise = $derived(compact ? 'h4' : 'h3');
// L'action d'un remède que réalise une commande ; null pour un remède de
// texte seul.
const actionDe = (remede) => (remede?.commande === undefined ? null : actionRemede(remede, { seance, ui }));
</script>
{#snippet contenu()}
{#if resultat !== null && resultat.erreur !== null}
<p>{t('diagnostic.nonCalcule')}</p>
<Refus refus={resultat.erreur} />
{:else if liste.length > 0}
<ul aria-label={t('diagnostic.enonces')}>
{#each liste as enonce, rang (rang)}
<li data-enonce={enonce.cle}>
<p class="enonce">
{#if enonce.details.groupe !== undefined}<span class="donnee" data-donnee>{enonce.details.groupe}</span>{/if}<span
data-phrase>{t(enonce.cle, enonce.details)}</span
>
</p>
{#if !compact && enonce.remede !== null}
{@const action = actionDe(enonce.remede)}
<p class="remede" data-remede>{t(enonce.remede.cle, enonce.remede.details)}</p>
{#if action !== null}
<Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
{/if}
{#snippet groupe(carte, rang)}
{@const id = `${uid}-groupe-${rang}`}
<svelte:element this={balise} {id} class="intitule" class:intertitre={carte.genre !== 'appartenance'}>
{#if carte.genre === 'appartenance'}<span class="donnee" data-donnee
><Pastille sorte="appartenance" valeur={rangs.get(carte.groupe) ?? null} libelle={carte.groupe} /></span
>{:else if carte.genre === 'separation'}{t('diagnostic.carte.separation')}{:else}{t('diagnostic.carte.ensemble')}{/if}
</svelte:element>
<ul aria-labelledby={id}>
{#each carte.enonces as enonce, n (n)}
<li data-enonce={enonce.cle}>
<p class="enonce"><span data-phrase>{t(enonce.cle, enonce.details)}</span></p>
{#if !compact && enonce.remede !== null}
{@const action = actionDe(enonce.remede)}
<p class="remede" data-remede>{t(enonce.remede.cle, enonce.remede.details)}</p>
{#if action !== null}
<Commande cle={action.cle} details={action.details} nom={action.nom} confirmer={action.confirmer} executer={action.executer} />
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</li>
{/each}
</ul>
{/snippet}
{#snippet erreur()}
<p>{t('diagnostic.nonCalcule')}</p>
<Refus refus={resultat.erreur} />
{/snippet}
{#if compact}
<div class="diagnostic compact">
<h3>{t('coquille.section.diagnostic')}</h3>
{@render contenu()}
</div>
<section class="diagnostic compact" aria-labelledby="{uid}-titre">
<h3 id="{uid}-titre" class="titre-panneau">{t('coquille.section.diagnostic')}</h3>
{#if resultat !== null && resultat.erreur !== null}
<div class="corps">{@render erreur()}</div>
{:else if cartes.length > 0}
<div class="corps groupes" role="group" aria-label={t('diagnostic.enonces')}>
{#each cartes as carte, rang (rang)}
<div class="groupe">{@render groupe(carte, rang)}</div>
{/each}
</div>
{/if}
</section>
{:else}
<section class="diagnostic" data-section="diagnostic">
<h2>{t('coquille.section.diagnostic')}</h2>
{@render contenu()}
{#if resultat !== null && resultat.erreur !== null}
<Carte>{@render erreur()}</Carte>
{:else if cartes.length > 0}
<div class="cartes" role="group" aria-label={t('diagnostic.enonces')}>
{#each cartes as carte, rang (rang)}
<Carte>{@render groupe(carte, rang)}</Carte>
{/each}
</div>
{/if}
</section>
{/if}
@ -94,7 +123,7 @@
.diagnostic {
display: flex;
flex-direction: column;
gap: var(--espace-2);
gap: var(--espace-3);
min-width: 0;
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
@ -105,9 +134,30 @@
font-size: var(--taille-titre);
}
h3 {
margin: 0;
.cartes {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
/* L'intitulé d'un groupe : la pastille de son appartenance, ou un
intertitre en petites capitales d'encre secondaire. */
.intitule {
margin: 0 0 var(--espace-2);
font-size: var(--taille-texte);
font-weight: bold;
}
.intertitre {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
.donnee {
display: flex;
min-width: 0;
}
ul {
@ -119,10 +169,6 @@
list-style: none;
}
.compact ul {
gap: var(--espace-2);
}
li {
display: flex;
flex-direction: column;
@ -134,17 +180,43 @@
margin: 0;
}
.enonce {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.donnee {
font-weight: 700;
}
.remede {
color: var(--couleur-page-texte-secondaire);
}
/* Le panneau compact : une carte titrée ; ses groupes se suivent, chacun
sous un filet. */
.compact {
gap: 0;
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
box-shadow: var(--ombre-carte);
}
.titre-panneau {
margin: 0;
padding: var(--espace-3) var(--espace-4);
border-bottom: 1px solid var(--couleur-carte-filet);
font-size: var(--taille-titre-carte);
}
.compact .corps {
padding: var(--espace-3) var(--espace-4);
}
.compact .groupes {
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
.compact .groupe + .groupe {
padding-top: var(--espace-3);
border-top: 1px solid var(--couleur-carte-filet);
}
.compact ul {
gap: var(--espace-2);
}
</style>

View file

@ -190,3 +190,38 @@ export function numeroDeCharge(charge) {
return numero;
};
}
// Les énoncés de la séparation vidée de son sens, que porte la carte de la
// séparation.
const CLES_SEPARATION = new Set(['diagnostic.uneSeuleAppartenance', 'diagnostic.collisionPartout']);
// Le genre et le groupe de la carte d'un énoncé.
function carteDe({ cle, details }) {
if (details.groupe !== undefined) return { genre: 'appartenance', groupe: details.groupe };
return { genre: CLES_SEPARATION.has(cle) ? 'separation' : 'ensemble', groupe: null };
}
/**
* Les cartes du diagnostic (§ 5.6) : les énoncés regroupés, sans en changer
* l'ordre, par suites d'énoncés voisins de même carte — la séparation vidée
* de son sens ('separation'), une appartenance, celle de details.groupe
* ('appartenance'), ou le reste de l'événement ('ensemble'). Dans l'ordre
* d'enonces, la séparation vient d'abord, puis une carte par appartenance
* en collision — ses collisions et son excédent —, puis l'événement : les
* cartes mises bout à bout redonnent la liste reçue, énoncé pour énoncé, les
* mêmes objets. Ne modifie pas la liste.
*
* @param {Array<{cle: string, details: Object}>} liste celle que rend enonces
* @returns {Array<{genre: 'separation'|'appartenance'|'ensemble', groupe: string|null,
* enonces: Array<Object>}>}
*/
export function cartesDiagnostic(liste) {
const cartes = [];
for (const enonce of liste) {
const { genre, groupe } = carteDe(enonce);
const derniere = cartes.at(-1);
if (derniere !== undefined && derniere.genre === genre && derniere.groupe === groupe) derniere.enonces.push(enonce);
else cartes.push({ genre, groupe, enonces: [enonce] });
}
return cartes;
}

View file

@ -16,7 +16,7 @@ import { texteLivre } from '../../demo/livrees.js';
import { diagnostiquer } from '../../moteur/diagnostic.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
import { enonces, numeroDeCharge } from './phrases.js';
import { cartesDiagnostic, enonces, numeroDeCharge } from './phrases.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const TIRET = '\u{2014}';
@ -492,3 +492,64 @@ describe('enonces : aux bords', () => {
assert.deepEqual(diagnostic, avant);
});
});
describe('cartesDiagnostic : une carte par appartenance (§ 5.6, § 12.7)', () => {
// La forme lisible d'une suite de cartes : genre, groupe, clés.
const formes = (cartes) => cartes.map(({ genre, groupe, enonces: liste }) => [genre, groupe, cles(liste)]);
test('deux appartenances en collision partout : la séparation, une carte par groupe, puis le reste de l\u{2019}événement', () => {
const { diagnostic, liste } = lire(CONFIGURATIONS.toutesEnConflit());
assert.equal(diagnostic.collisionPartout, true);
const cartes = cartesDiagnostic(liste);
const groupes = diagnostic.collisions.map(({ groupe }) => groupe);
assert.equal(groupes.length, 2);
const reste = cles(liste).filter((cle) => !['diagnostic.collisionPartout', 'diagnostic.collisions', 'diagnostic.excedent'].includes(cle));
assert.ok(reste.length > 0);
assert.deepEqual(formes(cartes), [
['separation', null, ['diagnostic.collisionPartout']],
...groupes.map((groupe) => [
'appartenance',
groupe,
cles(liste.filter(({ details }) => details.groupe === groupe)),
]),
['ensemble', null, reste],
]);
});
test('l\u{2019}excédent suit les collisions de son groupe, dans la même carte', () => {
const { liste } = lire(CONFIGURATIONS.excedent());
const [carte] = cartesDiagnostic(liste).filter(({ genre }) => genre === 'appartenance');
assert.deepEqual([carte.groupe, cles(carte.enonces)], ['Grisaille', ['diagnostic.collisions', 'diagnostic.excedent']]);
});
test('l\u{2019}ordre des énoncés est celui d\u{2019}enonces, carte après carte, et chaque énoncé est le même objet', () => {
for (const [nom, fabrique] of Object.entries(CONFIGURATIONS)) {
const { liste } = lire(fabrique());
const cartes = cartesDiagnostic(liste);
const aplati = cartes.flatMap((carte) => carte.enonces);
assert.equal(aplati.length, liste.length, nom);
aplati.forEach((enonce, rang) => assert.equal(enonce, liste[rang], `${nom} : énoncé ${rang}`));
// Deux cartes voisines ne se confondent pas.
for (let rang = 1; rang < cartes.length; rang += 1) {
const [a, b] = [cartes[rang - 1], cartes[rang]];
assert.ok(a.genre !== b.genre || a.groupe !== b.groupe, `${nom} : cartes ${rang - 1} et ${rang}`);
}
for (const carte of cartes) assert.ok(carte.enonces.length > 0, nom);
}
});
test('sans appartenance, une seule carte, celle de l\u{2019}événement ; aucun énoncé, aucune carte ; la liste reçue n\u{2019}est pas modifiée', () => {
const { liste } = lire(CONFIGURATIONS.manque());
const copie = structuredClone(liste);
assert.deepEqual(formes(cartesDiagnostic(liste)), [['ensemble', null, cles(liste)]]);
assert.deepEqual(liste, copie);
assert.deepEqual(cartesDiagnostic([]), []);
});
test('une seule appartenance : sa phrase ouvre la carte de la séparation', () => {
const { liste } = lire(CONFIGURATIONS.uneSeule());
const [premiere] = cartesDiagnostic(liste);
assert.equal(premiere.genre, 'separation');
assert.equal(premiere.enonces[0].cle, 'diagnostic.uneSeuleAppartenance');
});
});

View file

@ -17,6 +17,10 @@ export const TABLE = new Map([
// --- Le cadre de l'écran ---------------------------------------------------
['diagnostic.enonces', 'Ce que la configuration impose'],
['diagnostic.nonCalcule', 'La configuration ne se normalise pas : le diagnostic ne se calcule pas.'],
// Les intitulés des cartes que ne nomme pas une appartenance
// (cartesDiagnostic) : la séparation vidée de son sens, puis le reste.
['diagnostic.carte.separation', 'Séparation des appartenances'],
['diagnostic.carte.ensemble', "Tout l'événement"],
// --- La séparation vidée de son sens (§ 7.5, § 15.4) -----------------------
[

View file

@ -83,6 +83,9 @@ export const TABLE = new Map([
['plan.vue.ajuster', 'Ajuster'],
['plan.vue.listes', 'Listes de noms'],
['plan.vue.pleinEcran', 'Plein écran'],
// Les noms des groupes de la barre de vue (role="group").
['plan.vue.groupeZoom', 'Zoom'],
['plan.vue.groupeAffichage', 'Affichage'],
['plan.vue.dessin', 'Plan de salle'],
// Le placement que le plan montre, et la commande qui quitte une
// proposition montrée.

View file

@ -9,7 +9,12 @@
// d'un plancher de groupe est une donnée : elle occupe son propre nœud,
// avant le texte. Le bandeau d'application garde la version, la
// provenance et le mode.
//
// Il se lit comme un avis : fond, filet et trait de gauche de l'avis, et
// son signe, le triangle, de la marque de l'avis ; le texte garde l'encre
// de la page.
import { getContext } from 'svelte';
import Icone from '../primitives/Icone.svelte';
let { bandeau } = $props();
@ -18,7 +23,7 @@
{#if bandeau.length > 0}
<div class="bandeau-plan" data-bandeau-plan role="status">
<ul>
<span class="signe"><Icone nom="triangle" /></span><ul>
{#each bandeau as entree, rang (rang)}
<li>
{#if entree.details.groupe !== undefined}<span class="donnee">{entree.details.groupe}</span>{/if}<span
@ -32,13 +37,27 @@
<style>
.bandeau-plan {
display: flex;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-2) var(--espace-3);
border-left: 4px solid var(--couleur-avis);
background: var(--couleur-page-fond);
border: 1px solid var(--couleur-avis-filet);
border-left: 3px solid var(--couleur-avis);
border-radius: var(--rayon-2);
background: var(--couleur-avis-fond);
color: var(--couleur-page-texte);
font-size: var(--taille-texte);
}
/* Le signe : la hauteur d'une ligne, centré sur elle. */
.signe {
display: inline-flex;
flex: none;
align-items: center;
height: calc(var(--police-interligne) * 1em);
color: var(--couleur-avis-marque);
}
ul {
margin: 0;
padding: 0;

View file

@ -25,6 +25,14 @@
// cet état : ouvrir la légende n'écrit rien. Le plein écran est la dernière
// commande de vue ; une mise en évidence pose après lui son nom et « ôter ».
//
// La barre se lit en groupes, de gauche à droite : le placement montré, le
// tour affiché, le zoom — ses deux boutons et « Ajuster » —, puis
// l'affichage — listes, légende, plein écran —, chacun un role="group"
// nommé, aux boutons accolés dans un même filet ; la mise en évidence
// ferme la barre. Une bascule active, ou la légende dévoilée, se peint de
// la teinte de l'action et garde sa forme distincte : trait intérieur
// épais, texte souligné.
//
// source : ce que la barre dit du placement montré, que rend sourceMontree
// (vue_plan.js) — {libelle, revenir} — : la barre le nomme, [data-source],
// et, quand une proposition est demandée, offre de ne plus la montrer
@ -42,53 +50,78 @@
</script>
<div class="barre-vue" role="toolbar" aria-label={t('plan.vue.barre')}>
<p class="source" data-source>{t(source.libelle.cle, source.libelle.details)}</p>
{#if source.revenir !== null}
<button type="button" data-commande="quitterProposition" onclick={() => ui.montrerProposition(null)}
>{t(source.revenir)}</button
>
{/if}
<div class="placement">
<p class="source" data-source>{t(source.libelle.cle, source.libelle.details)}</p>
{#if source.revenir !== null}
<button type="button" class="commande" data-commande="quitterProposition" onclick={() => ui.montrerProposition(null)}
>{t(source.revenir)}</button
>
{/if}
</div>
<label class="tour">
<span>{t('plan.vue.tour')}</span>
<span class="intertitre">{t('plan.vue.tour')}</span>
<select data-champ="tour" value={tour} onchange={(evenement) => ui.montrerTour(Number(evenement.currentTarget.value))}>
{#each offerts as numero (numero)}
<option value={numero}>{numero}</option>
{/each}
</select>
</label>
<button type="button" data-commande="zoomer" onclick={() => zoomer(1)}>{t('plan.vue.zoomer')}</button>
<button type="button" data-commande="dezoomer" onclick={() => zoomer(-1)}>{t('plan.vue.dezoomer')}</button>
<button type="button" data-commande="ajuster" onclick={() => ajuster()}>{t('plan.vue.ajuster')}</button>
<button type="button" data-commande="listes" aria-pressed={$ui.listes} onclick={() => ui.basculerListes()}
>{t('plan.vue.listes')}</button
>
<button
type="button"
data-commande="legende"
aria-expanded={legende}
aria-controls={legende ? idLegende : undefined}
onclick={() => basculerLegende()}
>{t('plan.legende.ouvrir')}</button
>
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
>{t('plan.vue.pleinEcran')}</button
>
<div class="groupe" role="group" aria-label={t('plan.vue.groupeZoom')}>
<button type="button" data-commande="zoomer" onclick={() => zoomer(1)}>{t('plan.vue.zoomer')}</button>
<button type="button" data-commande="dezoomer" onclick={() => zoomer(-1)}>{t('plan.vue.dezoomer')}</button>
<button type="button" data-commande="ajuster" onclick={() => ajuster()}>{t('plan.vue.ajuster')}</button>
</div>
<div class="groupe" role="group" aria-label={t('plan.vue.groupeAffichage')}>
<button type="button" data-commande="listes" aria-pressed={$ui.listes} onclick={() => ui.basculerListes()}
>{t('plan.vue.listes')}</button
>
<button
type="button"
data-commande="legende"
aria-expanded={legende}
aria-controls={legende ? idLegende : undefined}
onclick={() => basculerLegende()}
>{t('plan.legende.ouvrir')}</button
>
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
>{t('plan.vue.pleinEcran')}</button
>
</div>
{#if evidence !== null}
<p class="evidence" data-evidence={evidence.id}>
<span>{t('plan.evidence.personne')}</span>{#if evidence.nom !== null}<span>{evidence.nom}</span>{:else}<span
<span>{t('plan.evidence.personne')}</span>{#if evidence.nom !== null}<span class="donnee">{evidence.nom}</span>{:else}<span
>{t('plan.evidence.absente')}</span
>{/if}
<button type="button" data-commande="oterEvidence" onclick={() => ui.mettreEnEvidence(null)}>{t('plan.evidence.oter')}</button>
<button type="button" class="commande" data-commande="oterEvidence" onclick={() => ui.mettreEnEvidence(null)}
>{t('plan.evidence.oter')}</button
>
</p>
{/if}
</div>
<style>
/* La barre : une carte basse, ses groupes alignés sur une ligne, repliés
quand la place manque. */
.barre-vue {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2) var(--espace-4);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-carte);
}
.placement,
.tour,
.evidence {
display: flex;
align-items: center;
gap: var(--espace-2);
margin: 0;
}
.source {
@ -96,25 +129,30 @@
font-weight: 700;
}
.evidence {
display: flex;
align-items: center;
gap: var(--espace-1);
margin: 0;
.donnee {
font-weight: 700;
}
.tour {
display: flex;
align-items: center;
gap: var(--espace-1);
/* L'étiquette du tour : un intertitre en petites capitales. */
.intertitre {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
font-weight: 700;
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
button,
select {
padding: var(--espace-1) var(--espace-3);
select,
.commande,
.groupe {
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
}
select,
button {
padding: var(--espace-1) var(--espace-3);
color: var(--couleur-page-texte);
font: inherit;
}
@ -123,17 +161,47 @@
cursor: pointer;
}
/* Un groupe : ses boutons accolés dans un même filet, séparés d'un trait. */
.groupe {
display: flex;
align-items: stretch;
}
.groupe button {
border: 0;
border-radius: 0;
background-color: transparent;
}
.groupe button + button {
border-left: 1px solid var(--couleur-page-filet);
}
.groupe button:first-child {
border-radius: calc(var(--rayon-2) - 1px) 0 0 calc(var(--rayon-2) - 1px);
}
.groupe button:last-child {
border-radius: 0 calc(var(--rayon-2) - 1px) calc(var(--rayon-2) - 1px) 0;
}
button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
/* Une bascule active, ou la légende dévoilée, se distingue par sa forme :
trait intérieur épais, texte souligné ; sa boîte ne change pas, et la
barre ne bouge pas. */
trait intérieur épais, texte souligné, sur la teinte de l'action ; sa
boîte ne change pas, et la barre ne bouge pas. */
button[aria-pressed='true'],
button[aria-expanded='true'] {
background-color: var(--couleur-action-teinte);
box-shadow: inset 0 0 0 2px var(--couleur-page-texte);
text-decoration: underline;
}
button:focus-visible,
select:focus-visible {
position: relative;
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}

View file

@ -14,14 +14,27 @@
// les appartenances — la nomme dans son propre nœud, entre son libellé et
// finAppartenance.
//
// infobulle : null, ou {detail, x, y} ; id : l'identifiant que la racine
// du plan désigne par aria-describedby quand l'infobulle s'ouvre par le
// focus.
// infobulle : null, ou {detail, x, y, xRepli} — x et y, le coin haut droit
// de l'emprise de la table, xRepli son bord gauche, en px de la surface ;
// zone : le cadre du <svg> dans la surface ; id : l'identifiant que la
// racine du plan désigne par aria-describedby quand l'infobulle s'ouvre
// par le focus. L'infobulle se pose par placerBulle : à droite de
// l'emprise quand elle y tient, sinon à sa gauche, et dans la zone — elle
// ne couvre pas le panneau voisin. Sa taille se lit par un observateur de
// taille (bind:offsetWidth), jamais dans un pointermove ; elle ne dépend
// pas de sa position, sa largeur étant celle de son contenu, bornée.
import { getContext } from 'svelte';
import { placerBulle } from './vue_plan.js';
let { infobulle, id } = $props();
let { infobulle, zone, id } = $props();
const t = getContext('t');
let largeur = $state(0);
let hauteur = $state(0);
const position = $derived(
infobulle === null ? null : placerBulle({ x: infobulle.x, y: infobulle.y, xRepli: infobulle.xRepli }, { largeur, hauteur }, zone),
);
</script>
<div
@ -29,8 +42,10 @@
role="tooltip"
{id}
hidden={infobulle === null}
style:left={infobulle === null ? null : `${infobulle.x}px`}
style:top={infobulle === null ? null : `${infobulle.y}px`}
style:left={position === null ? null : `${position.x}px`}
style:top={position === null ? null : `${position.y}px`}
bind:offsetWidth={largeur}
bind:offsetHeight={hauteur}
>
{#if infobulle !== null}
<p class="entete">{t('plan.infobulle.entete', { numero: infobulle.detail.numero, nombre: infobulle.detail.nombre })}</p>
@ -49,17 +64,21 @@
</div>
<style>
/* Une carte flottante : fond et filet de la carte, ombre portée. Sa
largeur est celle de son contenu, bornée à 24rem et à la surface : elle
ne dépend pas de sa position, que placerBulle choisit sur sa taille. */
.infobulle {
position: absolute;
z-index: 2;
box-sizing: border-box;
max-width: 24rem;
padding: var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background: var(--couleur-page-fond);
width: max-content;
max-width: min(24rem, 100%);
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
background: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-1);
box-shadow: var(--ombre-2);
font-size: var(--taille-texte);
pointer-events: none;
}
@ -73,6 +92,7 @@
}
.entete {
margin-bottom: var(--espace-1);
font-weight: 700;
}
</style>

View file

@ -60,12 +60,15 @@
</section>
<style>
/* Une carte : fond et filet de la carte ; les échantillons gardent le
dessin du plan. */
.legende {
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-carte);
}
.motifs {
@ -75,7 +78,7 @@
h3 {
margin: 0 0 var(--espace-2);
font-size: var(--taille-texte);
font-size: var(--taille-titre-carte);
}
p {

View file

@ -72,84 +72,108 @@
<!-- Échap remonte des contrôles des listes jusqu'à elles, qui oublient le choix. -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section aria-labelledby="{uid}-titre" onkeydown={surTouche}>
<section class="carte" aria-labelledby="{uid}-titre" onkeydown={surTouche}>
<h3 id="{uid}-titre">{t('plan.listesTravail.titre')}</h3>
{#if charge !== null}
<h4 id="{uid}-titres">{t('plan.listesTravail.titres')}</h4>
<ul data-liste-travail="titres" aria-labelledby="{uid}-titres">
{#each titres as titre, rang (rang)}
<li>
<button type="button" data-titre="{titre.table}:{titre.siege}" onclick={(evenement) => allerAuTitre(evenement, titre)}
><span>{t('plan.listesTravail.titreNonPourvu', { numero: titre.numero })}</span><span class="donnee"
>{titre.libelle}</span
><span>{t('plan.listesTravail.nonPourvu')}</span
>{#if titre.pourvuAuxTours.length > 0}<span>{t('plan.listesTravail.pourvuAuxTours', { tours: titre.pourvuAuxTours })}</span
>{/if}</button
>
</li>
{/each}
</ul>
{#if titres.length === 0}
<p>{t('plan.listesTravail.aucun')}</p>
{/if}
<h4 id="{uid}-pressentis">{t('plan.listesTravail.pressentis')}</h4>
<ul data-liste-travail="pressentis" aria-labelledby="{uid}-pressentis">
{#each pressentis as { participant, titrePressenti } (participant)}
<li>
<button type="button" data-participant={participant} aria-pressed={choisie(participant)} onclick={() => basculer(participant)}
><span class="donnee">{nomDe(participant)}</span><span>{t('plan.listesTravail.pressenti')}</span><span class="donnee"
>{titrePressenti}</span
></button
>
</li>
{/each}
</ul>
{#if pressentis.length === 0}
<p>{t('plan.listesTravail.aucun')}</p>
{/if}
{#if conversions.length > 0}
<h4 id="{uid}-conversions">{t('plan.listesTravail.conversions')}</h4>
<ul aria-labelledby="{uid}-conversions">
{#each conversions as conversion (conversion.cle)}
<li data-conversion={conversion.cle}>
<p>
<span class="donnee">{nomDe(conversion.participant)}</span><span
>{t('plan.listesTravail.conversion', { numero: numeroDe(conversion.table) })}</span
>
</p>
<Commande
cle="plan.listesTravail.convertir"
nom="convertir"
executer={() => seance.executer('convertirEnTous', { participant: conversion.participant, table: conversion.table })}
/>
<button type="button" data-commande="ignorer" onclick={() => (ignorees = [...ignorees, conversion.cle])}
>{t('plan.listesTravail.ignorer')}</button
<div class="corps">
<h4 id="{uid}-titres">{t('plan.listesTravail.titres')}</h4>
<ul data-liste-travail="titres" aria-labelledby="{uid}-titres">
{#each titres as titre, rang (rang)}
<li>
<button type="button" data-titre="{titre.table}:{titre.siege}" onclick={(evenement) => allerAuTitre(evenement, titre)}
><span>{t('plan.listesTravail.titreNonPourvu', { numero: titre.numero })}</span><span class="donnee"
>{titre.libelle}</span
><span>{t('plan.listesTravail.nonPourvu')}</span
>{#if titre.pourvuAuxTours.length > 0}<span>{t('plan.listesTravail.pourvuAuxTours', { tours: titre.pourvuAuxTours })}</span
>{/if}</button
>
</li>
{/each}
</ul>
{/if}
{#if titres.length === 0}
<p>{t('plan.listesTravail.aucun')}</p>
{/if}
<h4 id="{uid}-pressentis">{t('plan.listesTravail.pressentis')}</h4>
<ul data-liste-travail="pressentis" aria-labelledby="{uid}-pressentis">
{#each pressentis as { participant, titrePressenti } (participant)}
<li>
<button type="button" data-participant={participant} aria-pressed={choisie(participant)} onclick={() => basculer(participant)}
><span class="donnee">{nomDe(participant)}</span><span>{t('plan.listesTravail.pressenti')}</span><span class="donnee"
>{titrePressenti}</span
></button
>
</li>
{/each}
</ul>
{#if pressentis.length === 0}
<p>{t('plan.listesTravail.aucun')}</p>
{/if}
{#if conversions.length > 0}
<h4 id="{uid}-conversions">{t('plan.listesTravail.conversions')}</h4>
<ul aria-labelledby="{uid}-conversions">
{#each conversions as conversion (conversion.cle)}
<li data-conversion={conversion.cle}>
<p>
<span class="donnee">{nomDe(conversion.participant)}</span><span
>{t('plan.listesTravail.conversion', { numero: numeroDe(conversion.table) })}</span
>
</p>
<Commande
cle="plan.listesTravail.convertir"
nom="convertir"
executer={() => seance.executer('convertirEnTous', { participant: conversion.participant, table: conversion.table })}
/>
<button type="button" class="ignorer" data-commande="ignorer" onclick={() => (ignorees = [...ignorees, conversion.cle])}
>{t('plan.listesTravail.ignorer')}</button
>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</section>
<style>
section {
/* Le panneau est une carte : fond, filet, rayon et ombre de la carte ;
son titre au-dessus d'un filet, son contenu en retrait. */
.carte {
display: flex;
flex-direction: column;
gap: var(--espace-1);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-carte);
}
.carte > h3 {
margin: 0;
padding: var(--espace-3) var(--espace-4);
border-bottom: 1px solid var(--couleur-carte-filet);
font-size: var(--taille-titre-carte);
}
.corps {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding: var(--espace-3) var(--espace-4);
}
h3,
h4,
p {
margin: 0;
}
h3,
/* Un intertitre en petites capitales, d'encre secondaire. */
h4 {
font-size: var(--taille-texte);
margin: var(--espace-1) 0 0;
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
ul {
@ -161,14 +185,50 @@
list-style: none;
}
li > button {
li[data-conversion] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1) var(--espace-2);
}
li[data-conversion] p {
flex: 1 0 100%;
}
/* Un bouton de liste : la commande secondaire, au filet de la page. */
li > button,
.ignorer {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
li > button:hover,
.ignorer:hover {
background-color: var(--couleur-carte-fond-sourd);
}
li > button:focus-visible,
.ignorer:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
[data-liste-travail] li > button {
width: 100%;
text-align: start;
}
/* La personne choisie se dit par aria-pressed et par sa forme. */
button[aria-pressed='true'] {
border-width: 3px;
/* La personne choisie se dit par aria-pressed et par sa forme : un trait
épais et du gras, sur la teinte de l'action. */
li > button[aria-pressed='true'] {
border: 3px solid var(--couleur-page-texte);
background-color: var(--couleur-action-teinte);
font-weight: 700;
}
</style>

View file

@ -109,12 +109,15 @@
{/if}
<style>
/* Le menu suit les actions de la place, dans la même carte, sous un filet
de la carte ; son titre nomme la personne. */
.menu {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding-top: var(--espace-2);
border-top: 1px solid var(--couleur-page-filet);
margin-top: var(--espace-1);
padding-top: var(--espace-3);
border-top: 1px solid var(--couleur-carte-filet);
}
h4 {
@ -122,6 +125,10 @@
font-size: var(--taille-texte);
}
.donnee {
font-weight: 700;
}
.actions {
display: flex;
flex-wrap: wrap;

View file

@ -166,78 +166,125 @@
{#if chaise !== null}
<!-- Échap remonte des contrôles du panneau jusqu'à lui, qui se referme. -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section class="panneau" data-panneau="place" tabindex="-1" aria-labelledby="{uid}-titre" onkeydown={surTouche}>
<section class="panneau carte" data-panneau="place" tabindex="-1" aria-labelledby="{uid}-titre" onkeydown={surTouche}>
<h3 id="{uid}-titre">{t(nommee.cle, nommee.details)}</h3>
<p class="ligne">
{#each parties as { partie, texte } (partie)}<span class:titre={partie === 'titre'}>{texte}</span>{/each}
</p>
{#if choix !== null}
<p class="choix" role="status">
<span>{t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}</span><span class="donnee"
>{nomDe(choix.participant)}</span
>
<div class="corps">
<p class="ligne">
{#each parties as { partie, texte } (partie)}<span class:titre={partie === 'titre'}>{texte}</span>{/each}
</p>
{:else if actions.includes('assigner')}
<p class="invite">{t('plan.choix.inviter')}</p>
{/if}
{#if actions.includes('reserver')}
<label class="champ">
<span>{t('plan.portee.etiquette')}</span>
<select data-champ="portee" value={portee} onchange={(evenement) => (portee = evenement.currentTarget.value)}>
<option value="tour">{t('plan.portee.tour', { tour })}</option>
<option value="tous">{t('plan.portee.tous')}</option>
</select>
</label>
{/if}
{#if actions.includes('donnerTitre') || actions.includes('designer')}
<label class="champ">
<span>{t('plan.titre.etiquette')}</span>
<input type="text" data-champ="libelle" value={libelleSaisi} oninput={(evenement) => (saisi = evenement.currentTarget.value)} />
</label>
{/if}
<div class="actions">
{#each actions as action (action)}
<Commande cle="plan.action.{action}" nom={action} executer={EXECUTIONS[action]} />
{/each}
{#if choix !== null}
<Commande cle="plan.choix.oublier" nom="oublierChoix" executer={() => ui.choisir(null)} />
<p class="choix" role="status">
<span>{t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}</span><span class="donnee"
>{nomDe(choix.participant)}</span
>
</p>
{:else if actions.includes('assigner')}
<p class="invite">{t('plan.choix.inviter')}</p>
{/if}
{#if actions.includes('reserver')}
<label class="champ">
<span>{t('plan.portee.etiquette')}</span>
<select data-champ="portee" value={portee} onchange={(evenement) => (portee = evenement.currentTarget.value)}>
<option value="tour">{t('plan.portee.tour', { tour })}</option>
<option value="tous">{t('plan.portee.tous')}</option>
</select>
</label>
{/if}
{#if actions.includes('donnerTitre') || actions.includes('designer')}
<label class="champ">
<span>{t('plan.titre.etiquette')}</span>
<input type="text" data-champ="libelle" value={libelleSaisi} oninput={(evenement) => (saisi = evenement.currentTarget.value)} />
</label>
{/if}
<div class="actions">
{#each actions as action (action)}
<Commande
cle="plan.action.{action}"
nom={action}
executer={EXECUTIONS[action]}
variante={action === 'assigner' ? 'principale' : 'secondaire'}
/>
{/each}
{#if choix !== null}
<Commande cle="plan.choix.oublier" nom="oublierChoix" executer={() => ui.choisir(null)} />
{/if}
</div>
{#if menuDe !== null}
<MenuPersonne participant={menuDe} {etatTour} />
{/if}
</div>
{#if menuDe !== null}
<MenuPersonne participant={menuDe} {etatTour} />
{/if}
</section>
{/if}
<style>
.panneau {
/* Le panneau est une carte : fond, filet, rayon et ombre de la carte ;
son titre au-dessus d'un filet, son contenu en retrait. */
.carte {
display: flex;
flex-direction: column;
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-carte);
}
.carte > h3 {
margin: 0;
padding: var(--espace-3) var(--espace-4);
border-bottom: 1px solid var(--couleur-carte-filet);
font-size: var(--taille-titre-carte);
}
.corps {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding: var(--espace-3) var(--espace-4);
}
/* Le panneau reçoit le focus d'Échap et du clavier : son contour suit le
rayon de la carte. */
.panneau:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
h3,
p {
margin: 0;
}
h3 {
font-size: var(--taille-texte);
}
.titre {
font-style: italic;
}
.invite {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
.champ {
display: flex;
flex-direction: column;
gap: var(--espace-1);
}
.champ > span {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
font-weight: 700;
}
.champ select,
.champ input {
padding: var(--espace-1) var(--espace-2);
font: inherit;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2);
}
</style>

View file

@ -115,73 +115,109 @@
{#if etatTable !== undefined}
<!-- Échap remonte des contrôles du panneau jusqu'à lui, qui se referme. -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section class="panneau" data-panneau="table" data-table-panneau={table} tabindex="-1" aria-labelledby="{uid}-titre" onkeydown={surTouche}>
<section
class="panneau carte"
data-panneau="table"
data-table-panneau={table}
tabindex="-1"
aria-labelledby="{uid}-titre"
onkeydown={surTouche}
>
<h3 id="{uid}-titre">{t(nommee.cle, nommee.details)}</h3>
<p>{t('plan.panneauTable.titres', comptes)}</p>
{#if etatTable.gelee}
<p class="gel">{t('plan.liste.gelee')}</p>
{/if}
<h4 id="{uid}-sieges">{t('plan.panneauTable.sieges')}</h4>
<ul class="sieges" aria-labelledby="{uid}-sieges">
{#each lignes as parties, rang (rang)}
<li>
<button
type="button"
data-choix-siege={rang + 1}
data-focus-initial={rang === 0 ? '' : undefined}
onclick={(evenement) => choisirSiege(evenement, rang + 1)}
<div class="corps">
<p>{t('plan.panneauTable.titres', comptes)}</p>
{#if etatTable.gelee}
<p class="gel">{t('plan.liste.gelee')}</p>
{/if}
<h4 id="{uid}-sieges">{t('plan.panneauTable.sieges')}</h4>
<ul class="sieges" aria-labelledby="{uid}-sieges">
{#each lignes as parties, rang (rang)}
<li>
<button
type="button"
data-choix-siege={rang + 1}
data-focus-initial={rang === 0 ? '' : undefined}
onclick={(evenement) => choisirSiege(evenement, rang + 1)}
>
{#each parties as { partie, texte } (partie)}<span class:titre={partie === 'titre'}>{texte}</span>{/each}
</button>
</li>
{/each}
</ul>
{#if choix !== null}
<p class="choix" role="status">
<span>{t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}</span><span class="donnee"
>{nomDe(choix.participant)}</span
>
{#each parties as { partie, texte } (partie)}<span class:titre={partie === 'titre'}>{texte}</span>{/each}
</button>
</li>
{/each}
</ul>
{#if choix !== null}
<p class="choix" role="status">
<span>{t(choix.genre === 'personne' ? 'plan.choix.personne' : 'plan.choix.reservation')}</span><span class="donnee"
>{nomDe(choix.participant)}</span
>
</p>
{:else}
<p class="invite">{t('plan.choix.inviter')}</p>
{/if}
{#if etat.siegesAttribues}
</p>
{:else}
<p class="invite">{t('plan.choix.inviter')}</p>
{/if}
{#if etat.siegesAttribues}
<label class="champ">
<span>{t('plan.panneauTable.siege')}</span>
<select data-champ="siege" value={siegeChoisi} onchange={(evenement) => (siege = Number(evenement.currentTarget.value))}>
{#each lignes as _, rang (rang)}
<option value={rang + 1}>{rang + 1}</option>
{/each}
</select>
</label>
{/if}
<label class="champ">
<span>{t('plan.panneauTable.siege')}</span>
<select data-champ="siege" value={siegeChoisi} onchange={(evenement) => (siege = Number(evenement.currentTarget.value))}>
{#each lignes as _, rang (rang)}
<option value={rang + 1}>{rang + 1}</option>
{/each}
</select>
<span>{t('plan.titre.etiquette')}</span>
<input type="text" data-champ="libelle" value={libelleSaisi} oninput={(evenement) => (saisi = evenement.currentTarget.value)} />
</label>
{/if}
<label class="champ">
<span>{t('plan.titre.etiquette')}</span>
<input type="text" data-champ="libelle" value={libelleSaisi} oninput={(evenement) => (saisi = evenement.currentTarget.value)} />
</label>
<div class="actions">
<Commande cle="plan.action.donnerTitre" nom="donnerTitre" executer={donnerTitre} />
<Commande cle="plan.action.designer" nom="designer" executer={designer} />
<div class="actions">
<Commande cle="plan.action.donnerTitre" nom="donnerTitre" executer={donnerTitre} />
<Commande cle="plan.action.designer" nom="designer" executer={designer} />
</div>
</div>
</section>
{/if}
<style>
.panneau {
/* Le panneau est une carte : fond, filet, rayon et ombre de la carte ;
son titre au-dessus d'un filet, son contenu en retrait. */
.carte {
display: flex;
flex-direction: column;
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-carte);
}
.carte > h3 {
margin: 0;
padding: var(--espace-3) var(--espace-4);
border-bottom: 1px solid var(--couleur-carte-filet);
font-size: var(--taille-titre-carte);
}
.corps {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding: var(--espace-3) var(--espace-4);
}
.panneau:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
h3,
h4,
p {
margin: 0;
}
h3,
/* Un intertitre en petites capitales, d'encre secondaire. */
h4 {
font-size: var(--taille-texte);
margin: var(--espace-1) 0 0;
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
.gel {
@ -197,6 +233,26 @@
list-style: none;
}
/* Un bouton de liste : la commande secondaire, au filet de la page. */
li > button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
li > button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
li > button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.sieges button {
width: 100%;
text-align: start;
@ -206,12 +262,29 @@
font-style: italic;
}
.invite {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
.champ {
display: flex;
flex-direction: column;
gap: var(--espace-1);
}
.champ > span {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
font-weight: 700;
}
.champ select,
.champ input {
padding: var(--espace-1) var(--espace-2);
font: inherit;
}
.actions {
display: flex;
flex-wrap: wrap;

View file

@ -620,6 +620,18 @@ describe('l’infobulle nomme l’appartenance d’une paire réunie ; la légen
}
});
test('la barre de vue se lit en groupes nommés : le zoom, puis l’affichage, leurs commandes dans l’ordre', async () => {
const m = await ouvrir(deuxAppartenances({ nouvelleTable: false }));
const barre = dans(m, '[role="toolbar"]');
expect(barre).not.toBeNull();
const groupes = [...barre.querySelectorAll('[role="group"]')];
expect(groupes.map((g) => g.getAttribute('aria-label'))).toEqual([t('plan.vue.groupeZoom'), t('plan.vue.groupeAffichage')]);
expect(groupes.map((g) => [...g.querySelectorAll('[data-commande]')].map((b) => b.dataset.commande))).toEqual([
['zoomer', 'dezoomer', 'ajuster'],
['listes', 'legende', 'pleinEcran'],
]);
});
for (const mode of ['ecriture', 'lecture']) {
test(`mode ${mode} : le bouton « Légende » de la barre de vue ouvre la légende des seules règles actives, et la referme ; rien ne s’écrit`, async () => {
const m = await ouvrir(deuxAppartenances({ nouvelleTable: false }), { mode });
@ -887,6 +899,58 @@ describe('Échap oublie le choix en attente (§ 7.1, § 7.3)', () => {
});
});
// --- Les bulles posées sur le plan (§ 7.1, § 7.3, capture 20) -------------------
describe('une bulle posée près du bord reste dans le cadre du plan (§ 7.1, § 7.3)', () => {
// Le panneau latéral du plan, que la bulle ne doit pas couvrir.
const panneaux = (m) => dans(m, '[data-section="plan"] aside');
test('le refus d\u{2019}une chaise au coin bas droit du cadre passe à gauche et au-dessus : dans le cadre, hors des panneaux', async () => {
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
const cadre = cadreDe(m);
const chaise = geometrieDe(m, 8).chaises[1];
await avecVue(m, { k: 1, tx: cadre.largeur - 24 - chaise.x, ty: cadre.hauteur - 24 - chaise.y });
await cliquerChaise(m, 8, 2);
expect(m.ui.etat().selection).toEqual({ table: 8, siege: 2 });
await cliquer(panneau(m, 'place').querySelector('[data-commande="donnerTitre"]'));
const place = chaiseEl(m, 8, 2);
const refus = await attendre(() => {
const trouve = decrit(place);
expect(trouve?.querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull();
return trouve;
});
await attendre(() => expect(dansLaFenetre(m, refus)).toBe(true));
expect(recoupe(refus, panneaux(m))).toBe(false);
// Le refus redit sa place, et la chaise reste à son bord : la bulle
// s'est déplacée, pas la vue.
expect(pointChaise(m, 8, 2).x).toBeCloseTo(cadreDe(m).gauche + cadreDe(m).largeur - 24, 0);
});
test('l\u{2019}infobulle d\u{2019}une table en conflit au bord droit passe à gauche de son emprise : dans le cadre, hors des panneaux', async () => {
const m = await ouvrir(analyser(TEXTE_CONFLIT).charge);
m.ui.montrerTour(2);
flushSync();
await images(2);
const cadre = cadreDe(m);
const geometrie = geometrieDe(m, 1);
await avecVue(m, {
k: 1,
tx: cadre.largeur - 8 - geometrie.centre.x - geometrie.rayonEmprise,
ty: cadre.hauteur / 2 - geometrie.centre.y,
});
const centre = pointTable(m, 1);
await pointer('bouger', centre.x, centre.y);
const infobulle = infobulles()[0];
await attendre(() => expect(infobulleVisible(infobulle)).toBe(true));
await attendre(() => expect(dansLaFenetre(m, infobulle)).toBe(true));
expect(recoupe(infobulle, panneaux(m))).toBe(false);
// Passée à gauche, elle ne couvre pas le centre de la table qu'elle décrit.
const boite = infobulle.getBoundingClientRect();
expect(boite.right).toBeLessThanOrEqual(centre.x);
await pointer('bouger', pointSol(m).x, pointSol(m).y);
});
});
describe('un clic ne déplace pas la vue (§ 7.3, § 8.4)', () => {
test('une chaise à 12 px du bord gauche du cadre, cliquée : la place est sélectionnée, le transform du groupe et le cadrage restent', async () => {
const m = await ouvrir(sansProposition());

View file

@ -139,7 +139,8 @@
let fantome = $state.raw(null);
// Le coin du <svg> dans la surface, en cadre dont le bord gauche et le
// haut sont relatifs à la surface : relu à chaque changement de cadre et à
// chaque appui, jamais dans un pointermove.
// chaque appui, jamais dans un pointermove. C'est aussi la zone où
// l'infobulle et le refus posé sur une place se tiennent.
let origineSvg = $state.raw({ gauche: 0, haut: 0, largeur: 0, hauteur: 0 });
// L'élément actif du clavier, {table, siege|null}, ou null ; la racine a
// le focus ; le focus vient du clavier.
@ -204,7 +205,8 @@
});
// L'infobulle : la table que désignent la pression longue, le survol, puis
// l'élément actif sous un focus venu du clavier ; rien quand elle n'a pas
// de conflit. Posée au coin haut droit de l'emprise.
// de conflit. Posée au coin haut droit de l'emprise ; xRepli, le bord
// gauche de l'emprise, où finit l'infobulle passée à gauche (placerBulle).
const infobulleTable = $derived(pression ?? survol ?? (actifMontre === null ? null : actifMontre.table));
const infobulle = $derived.by(() => {
if (infobulleTable === null || vue === null || modele === null) return null;
@ -215,7 +217,8 @@
if (detail === null) return null;
const { centre, rayonEmprise } = dessin.geometrie;
const { x, y } = ecranDepuisDessin(vue, origineSvg, centre.x + rayonEmprise, centre.y - rayonEmprise);
return { detail, x, y };
const xRepli = ecranDepuisDessin(vue, origineSvg, centre.x - rayonEmprise, centre.y).x;
return { detail, x, y, xRepli };
});
const infobulleDuFocus = $derived(infobulle !== null && pression === null && survol === null);
@ -769,9 +772,9 @@
<Fantome {fantome} bind:element={elementFantome} />
</svg>
{#if poseAffichee !== null}
<RefusPlace pose={poseAffichee} fermer={fermerPose} gestes={gestesPose} />
<RefusPlace pose={poseAffichee} zone={origineSvg} fermer={fermerPose} gestes={gestesPose} />
{/if}
<Infobulle {infobulle} id={idInfobulle} />
<Infobulle {infobulle} zone={origineSvg} id={idInfobulle} />
<!-- L'échelle occupe sa place avant que la vue n'existe, cachée : sa
venue ne change pas le cadre du dessin. -->
<div class="pied" class:cache={vue === null}>

View file

@ -16,21 +16,40 @@
// dessous, dont l'appui retire la pose et saisit ce qui est visé — le
// refus se ferme au geste suivant sans lui prendre sa cible.
//
// Le message se pose par placerBulle : à droite et sous la place quand il
// y tient, sinon de l'autre côté, et dans la zone — il ne couvre pas le
// panneau voisin et ne sort pas du cadre. Sa taille se lit par un
// observateur de taille (bind:offsetWidth) ; elle ne dépend pas de sa
// position, sa largeur étant celle de son contenu, bornée.
//
// pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y
// en px de la surface ; fermer : retire la pose ; gestes : ceux qu'un
// remède emprunte à l'écran — le choix d'une place sur le plan —, que Refus
// reçoit tels quels. « Fermer » se désigne par data-commande="fermerPose".
// en px de la surface ; zone : le cadre du <svg> dans la surface ; fermer :
// retire la pose ; gestes : ceux qu'un remède emprunte à l'écran — le choix
// d'une place sur le plan —, que Refus reçoit tels quels. « Fermer » se
// désigne par data-commande="fermerPose".
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
import { libellePlace } from './gestes.js';
import { placerBulle } from './vue_plan.js';
let { pose, fermer, gestes = {} } = $props();
let { pose, zone, fermer, gestes = {} } = $props();
const t = getContext('t');
const libelle = $derived(libellePlace(pose));
let largeur = $state(0);
let hauteur = $state(0);
const position = $derived(placerBulle({ x: pose.x, y: pose.y }, { largeur, hauteur }, zone));
</script>
<div class="refus-place" id={pose.id} style:left="{pose.x}px" style:top="{pose.y}px">
<div
class="refus-place"
id={pose.id}
style:left="{position.x}px"
style:top="{position.y}px"
bind:offsetWidth={largeur}
bind:offsetHeight={hauteur}
>
<p class="place">
{t(libelle.cle, libelle.details)}
</p>
@ -44,6 +63,8 @@
</div>
<style>
/* Une carte flottante : fond et filet de la carte, ombre portée. Sa
largeur est celle de son contenu, bornée à 22rem et à la surface. */
.refus-place {
position: absolute;
z-index: 1;
@ -51,15 +72,16 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
gap: var(--espace-2);
box-sizing: border-box;
max-width: 22rem;
padding: var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background: var(--couleur-page-fond);
width: max-content;
max-width: min(22rem, 100%);
padding: var(--espace-3);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-2);
background: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-1);
box-shadow: var(--ombre-2);
font-size: var(--taille-texte);
}
@ -78,4 +100,25 @@
.avertissement {
color: var(--couleur-avis);
}
/* « Fermer » : une commande discrète, au filet de la page. */
.fermer {
align-self: flex-end;
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
.fermer:hover {
background-color: var(--couleur-carte-fond-sourd);
}
.fermer:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -44,38 +44,60 @@
<!-- Échap remonte des boutons de la réserve jusqu'à elle, qui oublie le choix. -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section data-reserve={tour} aria-labelledby="{uid}-titre" onkeydown={surTouche}>
<section class="carte" data-reserve={tour} aria-labelledby="{uid}-titre" onkeydown={surTouche}>
<h3 id="{uid}-titre">{t('plan.reserve.titre')}</h3>
{#if charge !== null}
<p>{t('plan.reserve.tour', { tour })}</p>
{#if reserve.length === 0}
<p>{t('plan.reserve.vide')}</p>
{:else}
<ul>
{#each reserve as id (id)}
<li>
<button type="button" data-participant={id} aria-pressed={choisie(id)} onclick={() => basculer(id)}>{nomDe(id)}</button>
</li>
{/each}
</ul>
{/if}
<div class="corps">
<p class="sous-titre">{t('plan.reserve.tour', { tour })}</p>
{#if reserve.length === 0}
<p>{t('plan.reserve.vide')}</p>
{:else}
<ul>
{#each reserve as id (id)}
<li>
<button type="button" data-participant={id} aria-pressed={choisie(id)} onclick={() => basculer(id)}>{nomDe(id)}</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</section>
<style>
section {
/* Le panneau est une carte : fond, filet, rayon et ombre de la carte ;
son titre au-dessus d'un filet, son contenu en retrait. */
.carte {
display: flex;
flex-direction: column;
gap: var(--espace-1);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-carte);
}
.carte > h3 {
margin: 0;
padding: var(--espace-3) var(--espace-4);
border-bottom: 1px solid var(--couleur-carte-filet);
font-size: var(--taille-titre-carte);
}
.corps {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding: var(--espace-3) var(--espace-4);
}
h3,
p {
margin: 0;
}
h3 {
font-size: var(--taille-texte);
.sous-titre {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
ul {
@ -87,10 +109,31 @@
list-style: none;
}
/* Un bouton de liste : la commande secondaire, au filet de la page. */
li > button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
li > button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
li > button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
/* La personne choisie se dit par aria-pressed et par sa forme : un trait
épais et du gras. */
button[aria-pressed='true'] {
border-width: 3px;
épais et du gras, sur la teinte de l'action. */
li > button[aria-pressed='true'] {
border: 3px solid var(--couleur-page-texte);
background-color: var(--couleur-action-teinte);
font-weight: 700;
}
</style>

View file

@ -16,6 +16,10 @@
// Le plan se remonte pour chaque événement : un autre événement repart de
// sa propre vue, mémorisée ou ajustée.
//
// Les panneaux sont des cartes, empilées dans une colonne qui défile seule ;
// la colonne garde un léger retrait, pour que l'ombre des cartes ne soit
// pas rognée par son défilement.
//
// En plein écran, le titre et les panneaux s'effacent : le plan prend la
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
// cadre l'entoure toujours (§ 8.4).
@ -99,7 +103,9 @@
flex: 0 0 18rem;
flex-direction: column;
gap: var(--espace-3);
box-sizing: border-box;
min-height: 0;
padding: 0 0.125rem var(--espace-1);
overflow-y: auto;
}
</style>

View file

@ -172,3 +172,42 @@ export function sourceMontree(etat, demandee, retenu) {
}
throw new TypeError(`source de l’état des places hors du contrat : ${JSON.stringify(etat.source)}`);
}
// La position d'une bulle sur un axe : debut, le bord de départ voulu ;
// repli, le bord où finit la bulle passée de l'autre côté ; taille, sa
// longueur ; de et a, les bords de la zone. Le côté choisi est ensuite
// borné à [de, a - taille] : une ancre avant de — une table coupée par le
// bord du cadre — ramène la bulle sur de ; une bulle plus longue que la
// zone se cale sur de.
function placerSurAxe(debut, repli, taille, de, a) {
const fin = Math.max(de, a - taille);
const choisi = debut + taille <= a ? debut : repli - taille >= de ? repli - taille : fin;
return Math.min(Math.max(choisi, de), fin);
}
/**
* La position d'une bulle posée sur le plan — l'infobulle d'une table, le
* refus posé sur une place — dans la zone qui la contient, en px du
* conteneur : à son ancre quand elle y tient, à droite et dessous ; passée
* à gauche, son bord droit sur xRepli, quand elle déborde à droite ;
* au-dessus, son bord bas sur y, quand elle déborde dessous ; calée sur le
* bord opposé de la zone quand aucun côté ne suffit, jamais avant le bord
* gauche ni le bord haut : une ancre au-dessus ou à gauche de la zone ramène
* la bulle sur ce bord. Une taille nulle — pas encore mesurée — tient
* toujours : la bulle reste à son ancre quand celle-ci est dans la zone.
* Aucun argument n'est modifié.
*
* @param {{x: number, y: number, xRepli?: number}} ancre xRepli vaut x par défaut
* @param {{largeur: number, hauteur: number}} taille
* @param {{gauche: number, haut: number, largeur: number, hauteur: number}} zone
* @returns {{x: number, y: number}} le coin haut gauche de la bulle
* @throws {TypeError} pour un nombre non fini
*/
export function placerBulle({ x, y, xRepli = x }, { largeur, hauteur }, zone) {
const nombres = [x, y, xRepli, largeur, hauteur, zone.gauche, zone.haut, zone.largeur, zone.hauteur];
if (!nombres.every(Number.isFinite)) throw new TypeError(`bulle : nombre non fini : ${JSON.stringify(nombres)}`);
return {
x: placerSurAxe(x, xRepli, largeur, zone.gauche, zone.gauche + zone.largeur),
y: placerSurAxe(y, y, hauteur, zone.haut, zone.haut + zone.hauteur),
};
}

View file

@ -28,6 +28,7 @@ import {
cadreDepuisRect,
englobantDuTour,
nomDeCharge,
placerBulle,
sourceMontree,
tourAffiche,
toursOfferts,
@ -304,3 +305,67 @@ describe('sourceMontree : ce que la barre de vue dit du placement montré (§ 2.
assert.throws(() => sourceMontree(etat('autre'), null, false), TypeError);
});
});
describe('placerBulle : une bulle reste dans la zone du plan (§ 7.1, § 7.3)', () => {
// Une zone de 600 × 400 px dont le coin est à (20, 10) : bords droit 620
// et bas 410.
const ZONE = { gauche: 20, haut: 10, largeur: 600, hauteur: 400 };
test('une bulle qui tient à droite et dessous reste à son ancre', () => {
assert.deepEqual(placerBulle({ x: 100, y: 50, xRepli: 60 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 50 });
// Au ras des bords droit et bas, elle tient encore.
assert.deepEqual(placerBulle({ x: 420, y: 310 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 420, y: 310 });
});
test('qui déborde à droite, elle passe à gauche de xRepli, son bord droit sur lui', () => {
assert.deepEqual(placerBulle({ x: 500, y: 50, xRepli: 440 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 240, y: 50 });
// Sans xRepli, le repli se fait autour de x.
assert.deepEqual(placerBulle({ x: 500, y: 50 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 300, y: 50 });
});
test('qui déborde dessous, elle passe au-dessus de son ancre, son bord bas sur elle', () => {
assert.deepEqual(placerBulle({ x: 100, y: 380 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 280 });
});
test('sans place d’un côté ni de l’autre, elle se cale sur le bord opposé, jamais avant le début de la zone', () => {
// 200 px à droite de 500 débordent ; à gauche de 150, il manque 30 px.
assert.deepEqual(placerBulle({ x: 500, y: 50, xRepli: 150 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 420, y: 50 });
// Plus large que la zone : calée au bord gauche.
assert.deepEqual(placerBulle({ x: 500, y: 50, xRepli: 150 }, { largeur: 700, hauteur: 100 }, ZONE), { x: 20, y: 50 });
// Plus haute que la zone, sous une ancre basse : calée au bord haut.
assert.deepEqual(placerBulle({ x: 100, y: 300 }, { largeur: 200, hauteur: 500 }, ZONE), { x: 100, y: 10 });
});
test('une ancre avant le bord gauche ou le bord haut ramène la bulle sur ce bord, même quand elle tient', () => {
// L'ancre d'une table coupée par le haut du cadre : au-dessus de la zone.
assert.deepEqual(placerBulle({ x: 100, y: -50 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 10 });
// Au-dessus et à gauche : calée dans le coin haut gauche.
assert.deepEqual(placerBulle({ x: -40, y: -50, xRepli: -90 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 20, y: 10 });
// Une taille encore inconnue ne la laisse pas non plus hors de la zone.
assert.deepEqual(placerBulle({ x: -40, y: -50 }, { largeur: 0, hauteur: 0 }, ZONE), { x: 20, y: 10 });
});
test('une ancre après le bord droit ou le bord bas ramène la bulle dans la zone, son bord sur ce bord', () => {
// À droite de la zone, son repli encore après le bord : le bord droit
// de la bulle sur celui de la zone, 620 − 50.
assert.deepEqual(placerBulle({ x: 700, y: 50, xRepli: 690 }, { largeur: 50, hauteur: 100 }, ZONE), { x: 570, y: 50 });
// Sous la zone, 100 px au-dessus de l'ancre ne suffisent pas : le bord
// bas de la bulle sur celui de la zone, 410 − 100.
assert.deepEqual(placerBulle({ x: 100, y: 450 }, { largeur: 200, hauteur: 100 }, ZONE), { x: 100, y: 310 });
});
test('une taille encore inconnue — zéro, avant la première mesure — laisse la bulle à son ancre', () => {
assert.deepEqual(placerBulle({ x: 615, y: 405, xRepli: 580 }, { largeur: 0, hauteur: 0 }, ZONE), { x: 615, y: 405 });
});
test('les arguments ne sont pas modifiés ; un nombre non fini lève', () => {
const ancre = { x: 500, y: 380, xRepli: 440 };
const taille = { largeur: 200, hauteur: 100 };
const zone = { ...ZONE };
placerBulle(ancre, taille, zone);
assert.deepEqual([ancre, taille, zone], [{ x: 500, y: 380, xRepli: 440 }, { largeur: 200, hauteur: 100 }, ZONE]);
assert.throws(() => placerBulle({ x: Number.NaN, y: 0 }, taille, zone), TypeError);
assert.throws(() => placerBulle({ x: 0, y: 0 }, { largeur: Infinity, hauteur: 0 }, zone), TypeError);
assert.throws(() => placerBulle({ x: 0, y: 0 }, taille, { ...ZONE, hauteur: undefined }), TypeError);
});
});