[ADD] interface: plan keyboard, tooltip, side panels, person menu, lists
Every gesture the pointer offers on the plan is reachable from the keyboard (§ 7.3): one stop enters the plan, arrows move between tables and seats, and the table and place panels choose a seat and keep the focus. A tooltip names who sits where; a per-person menu moves, assigns or reserves without dragging; the reserve and the work lists choose a person to designate on the plan. Escape, a tour change and the executed command forget the choice, and a click never moves the view. Checked: 2537 node, 466 browser (one expected failure kept), 60 node-long. --- FR --- [ADD] interface : clavier du plan, infobulle, panneaux, menu, listes Chaque geste que le pointeur offre sur le plan s'atteint au clavier (§ 7.3) : un seul arrêt entre dans le plan, les flèches vont de table en table et de siège en siège, et les panneaux de table et de place choisissent un siège en gardant le focus. Une infobulle nomme qui est assis où ; un menu par personne déplace, assigne ou réserve sans glisser ; la réserve et les listes de travail choisissent une personne à désigner sur le plan. Échap, un changement de tour et la commande exécutée oublient le choix, et un clic ne déplace jamais la vue. Vérifié : 2537 node, 466 navigateur (un échec attendu gardé), 60 node-long. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
7f3dad24af
commit
811cd16b76
18 changed files with 3652 additions and 65 deletions
|
|
@ -4,7 +4,8 @@
|
|||
// L'état de navigation de l'interface : la section ouverte, le panneau « À
|
||||
// propos », le tour montré, la personne mise en évidence, la proposition
|
||||
// demandée au plan, le texte d'un import en attente, les listes de noms, le
|
||||
// plein écran, la place sélectionnée, la fiche ouverte. Regarder n'écrit jamais (§ 8.4) : cet
|
||||
// plein écran, la place sélectionnée, la fiche ouverte, le choix en attente
|
||||
// du plan et le refus posé sur une place. Regarder n'écrit jamais (§ 8.4) : cet
|
||||
// état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et
|
||||
// rien de ce qu'il tient ne quitte la mémoire.
|
||||
//
|
||||
|
|
@ -26,6 +27,17 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
|
|||
* @property {boolean} pleinEcran
|
||||
* @property {Object|null} selection la place sélectionnée
|
||||
* @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte
|
||||
* @property {null|{genre: 'personne'|'reservation', participant: number,
|
||||
* depuis: {table: number, siege: number|null}|null,
|
||||
* de?: {table: number, portee: 'tous'|'tour', tour: number|null}}} choix
|
||||
* le choix en attente du plan (§ 5.8, § 7.1) : la personne choisie, ou sa
|
||||
* réservation qui tient la place depuis ; depuis, sa place, null hors du
|
||||
* dessin ; de, pour une réservation, ce qui la désigne dans la commande
|
||||
* deplacerReservation
|
||||
* @property {null|{place: {table: number, siege?: number|null}, refus: Object|null,
|
||||
* avertissements: Object[]}} pose
|
||||
* le refus, ou les avertissements, posés sur une place du plan, que
|
||||
* Plan.svelte montre sur la place (§ 5.9)
|
||||
*/
|
||||
|
||||
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
|
||||
|
|
@ -40,8 +52,42 @@ const DEPART = Object.freeze({
|
|||
pleinEcran: false,
|
||||
selection: null,
|
||||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
});
|
||||
|
||||
const GENRES_CHOIX = new Set(['personne', 'reservation']);
|
||||
const PORTEES = new Set(['tous', 'tour']);
|
||||
|
||||
const entierPositif = (n) => Number.isInteger(n) && n >= 1;
|
||||
|
||||
// Vrai pour une place {table, siege|null} : table entière positive, siège
|
||||
// entier positif ou null.
|
||||
const estPlace = (place) =>
|
||||
place !== null && typeof place === 'object' && entierPositif(place.table) && (place.siege === null || entierPositif(place.siege));
|
||||
|
||||
// Le choix du contrat, figé ; RangeError hors du contrat.
|
||||
function choixValide(choix) {
|
||||
const forme = choix !== null && typeof choix === 'object';
|
||||
const valide =
|
||||
forme &&
|
||||
GENRES_CHOIX.has(choix.genre) &&
|
||||
entierPositif(choix.participant) &&
|
||||
Object.hasOwn(choix, 'depuis') &&
|
||||
(choix.depuis === null || estPlace(choix.depuis)) &&
|
||||
(choix.genre === 'personne' ||
|
||||
(choix.de !== null &&
|
||||
typeof choix.de === 'object' &&
|
||||
entierPositif(choix.de.table) &&
|
||||
PORTEES.has(choix.de.portee) &&
|
||||
(choix.de.portee === 'tous' ? choix.de.tour === null : entierPositif(choix.de.tour))));
|
||||
if (!valide) throw new RangeError(`choix hors du contrat : ${JSON.stringify(choix)}`);
|
||||
const depuis = choix.depuis === null ? null : Object.freeze({ table: choix.depuis.table, siege: choix.depuis.siege });
|
||||
const fige = { genre: choix.genre, participant: choix.participant, depuis };
|
||||
if (choix.genre === 'reservation') fige.de = Object.freeze({ table: choix.de.table, portee: choix.de.portee, tour: choix.de.tour });
|
||||
return Object.freeze(fige);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un état de navigation neuf, à son départ.
|
||||
*
|
||||
|
|
@ -51,7 +97,8 @@ const DEPART = Object.freeze({
|
|||
* montrerProposition: Function, ouvrirImport: Function,
|
||||
* prendreImport: () => null|{texte: string, origine: string},
|
||||
* basculerListes: Function, basculerPleinEcran: Function,
|
||||
* selectionner: Function, ouvrirFiche: Function}}
|
||||
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
|
||||
* poserSurPlace: Function}}
|
||||
*/
|
||||
export function creerEtatUi() {
|
||||
let etat = Object.freeze({ ...DEPART, apropos: false });
|
||||
|
|
@ -75,10 +122,11 @@ export function creerEtatUi() {
|
|||
};
|
||||
},
|
||||
etat: () => etat,
|
||||
/** Ouvre une section ; RangeError hors de SECTIONS. */
|
||||
/** Ouvre une section ; RangeError hors de SECTIONS. Une autre section
|
||||
* que la courante retire la pose : elle ne revient pas avec le plan. */
|
||||
allerA(section) {
|
||||
if (!SECTIONS.includes(section)) throw new RangeError(`section inconnue : ${JSON.stringify(section)}`);
|
||||
changer({ section });
|
||||
changer(section === etat.section ? {} : { section, pose: null });
|
||||
},
|
||||
/** Le départ d'un événement qui s'ouvre ; le panneau « À propos » reste tel. */
|
||||
reinitialiser() {
|
||||
|
|
@ -90,16 +138,19 @@ export function creerEtatUi() {
|
|||
fermerApropos() {
|
||||
changer({ apropos: false });
|
||||
},
|
||||
/** Montre le tour r, entier positif ; RangeError sinon. */
|
||||
/** Montre le tour r, entier positif ; RangeError sinon. Le choix en
|
||||
* attente s'oublie, et la pose, qui tient à l'état des places d'un tour. */
|
||||
montrerTour(r) {
|
||||
if (!Number.isInteger(r) || r < 1) throw new RangeError(`tour : entier positif attendu, reçu ${String(r)}`);
|
||||
changer({ tour: r });
|
||||
changer({ tour: r, choix: null, pose: null });
|
||||
},
|
||||
mettreEnEvidence(id) {
|
||||
changer({ miseEnEvidence: id });
|
||||
},
|
||||
/** Demande la proposition id au plan, ou aucune (null) ; la pose, lue
|
||||
* sur l'état des places d'avant, se retire. */
|
||||
montrerProposition(id) {
|
||||
changer({ proposition: id });
|
||||
changer({ proposition: id, pose: null });
|
||||
},
|
||||
/** Garde un texte à importer et ouvre la section de l'import. */
|
||||
ouvrirImport({ texte, origine }) {
|
||||
|
|
@ -117,8 +168,26 @@ export function creerEtatUi() {
|
|||
basculerPleinEcran() {
|
||||
changer({ pleinEcran: !etat.pleinEcran });
|
||||
},
|
||||
/** Sélectionne une place ; null retire la sélection et oublie le choix en attente. */
|
||||
selectionner(place) {
|
||||
changer({ selection: place });
|
||||
changer(place === null ? { selection: null, choix: null } : { selection: place });
|
||||
},
|
||||
/** Pose le choix en attente (EtatUi.choix), ou l'oublie (null) ; RangeError hors du contrat. */
|
||||
choisir(choix) {
|
||||
changer({ choix: choix === null ? null : choixValide(choix) });
|
||||
},
|
||||
/** Pose sur une place un refus, ou des avertissements, que le plan montre ; null les retire.
|
||||
* RangeError sans place. */
|
||||
poserSurPlace(pose) {
|
||||
if (pose === null) {
|
||||
changer({ pose: null });
|
||||
return;
|
||||
}
|
||||
const { place, refus = null, avertissements = [] } = pose;
|
||||
if (place === null || typeof place !== 'object' || !entierPositif(place.table)) {
|
||||
throw new RangeError(`pose sans place : ${JSON.stringify(pose)}`);
|
||||
}
|
||||
changer({ pose: Object.freeze({ place, refus, avertissements }) });
|
||||
},
|
||||
/** Ouvre la fiche de la personne id, entier positif, dans la section des
|
||||
* participants ; null referme la fiche, la section reste. RangeError
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ const DEPART = {
|
|||
pleinEcran: false,
|
||||
selection: null,
|
||||
fiche: null,
|
||||
choix: null,
|
||||
pose: null,
|
||||
};
|
||||
|
||||
// Un état et le relevé de ce que reçoit son abonné.
|
||||
|
|
@ -89,6 +91,8 @@ describe('état de navigation : chaque transition', () => {
|
|||
ui.basculerPleinEcran();
|
||||
ui.selectionner({ table: 1, siege: 2 });
|
||||
ui.ouvrirFiche(4);
|
||||
ui.choisir({ genre: 'personne', participant: 2, depuis: null });
|
||||
ui.poserSurPlace({ place: { table: 1, siege: 2 }, refus: { code: 'SANS_EFFET', details: {} }, avertissements: [] });
|
||||
ui.ouvrirApropos();
|
||||
ui.reinitialiser();
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
|
||||
|
|
@ -174,3 +178,93 @@ describe('état de navigation : chaque transition', () => {
|
|||
assert.equal(recus.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('état de navigation : le choix en attente (§ 5.8, § 7.1)', () => {
|
||||
const PERSONNE = { genre: 'personne', participant: 2, depuis: { table: 4, siege: 1 } };
|
||||
const DEPUIS_LA_RESERVE = { genre: 'personne', participant: 3, depuis: null };
|
||||
const RESERVATION = {
|
||||
genre: 'reservation',
|
||||
participant: 5,
|
||||
depuis: { table: 3, siege: 2 },
|
||||
de: { table: 3, portee: 'tous', tour: null },
|
||||
};
|
||||
|
||||
test('choisir pose un choix de chaque genre, figé, puis null l’oublie', () => {
|
||||
const ui = creerEtatUi();
|
||||
for (const choix of [PERSONNE, DEPUIS_LA_RESERVE, RESERVATION]) {
|
||||
ui.choisir(choix);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, choix });
|
||||
assert.ok(Object.isFrozen(ui.etat().choix));
|
||||
ui.choisir(null);
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
}
|
||||
});
|
||||
|
||||
test('un choix hors du contrat lève RangeError sans rien changer', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.choisir(PERSONNE);
|
||||
const avant = ui.etat();
|
||||
const fautes = [
|
||||
{ genre: 'table', participant: 2, depuis: null },
|
||||
{ genre: 'personne', participant: 0, depuis: null },
|
||||
{ genre: 'personne', participant: '2', depuis: null },
|
||||
{ genre: 'personne', participant: 2 },
|
||||
{ genre: 'personne', participant: 2, depuis: { siege: 1 } },
|
||||
{ genre: 'personne', participant: 2, depuis: { table: 1, siege: 0 } },
|
||||
{ genre: 'reservation', participant: 5, depuis: { table: 3, siege: 2 } },
|
||||
{ genre: 'reservation', participant: 5, depuis: null, de: { table: 3, portee: 'parfois', tour: null } },
|
||||
undefined,
|
||||
];
|
||||
for (const faute of fautes) assert.throws(() => ui.choisir(faute), RangeError, JSON.stringify(faute));
|
||||
assert.equal(ui.etat(), avant);
|
||||
});
|
||||
|
||||
test('montrerTour et selectionner(null) oublient le choix ; selectionner une place le garde', () => {
|
||||
const ui = creerEtatUi();
|
||||
ui.choisir(PERSONNE);
|
||||
ui.selectionner({ table: 2, siege: 3 });
|
||||
assert.deepEqual(ui.etat().choix, PERSONNE);
|
||||
ui.selectionner(null);
|
||||
assert.equal(ui.etat().choix, null);
|
||||
ui.choisir(RESERVATION);
|
||||
ui.montrerTour(2);
|
||||
assert.equal(ui.etat().choix, null);
|
||||
ui.choisir(PERSONNE);
|
||||
ui.montrerTour(2);
|
||||
assert.equal(ui.etat().choix, null, 'le même tour remontré oublie aussi');
|
||||
});
|
||||
|
||||
test('poserSurPlace pose un refus ou des avertissements sur une place, puis null les retire ; aucune transition ne reçoit la séance', () => {
|
||||
const ui = creerEtatUi();
|
||||
const pose = {
|
||||
place: { table: 3, siege: null },
|
||||
refus: { code: 'PERSONNE_EXCLUE', details: { participant: 1 } },
|
||||
avertissements: [{ code: 'TABLE_GELEE', details: { table: 3 } }],
|
||||
};
|
||||
ui.poserSurPlace(pose);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, pose });
|
||||
assert.ok(Object.isFrozen(ui.etat().pose));
|
||||
ui.poserSurPlace(null);
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
assert.throws(() => ui.poserSurPlace({ place: null, refus: null, avertissements: [] }), RangeError);
|
||||
assert.equal(ui.choisir.length, 1);
|
||||
assert.equal(ui.poserSurPlace.length, 1);
|
||||
});
|
||||
|
||||
test('montrerTour, montrerProposition et un changement de section retirent la pose ; la section remontrée la garde', () => {
|
||||
const ui = creerEtatUi();
|
||||
const pose = { place: { table: 7, siege: 1 }, refus: { code: 'PLACE_RESERVEE', details: { participant: 4 } }, avertissements: [] };
|
||||
ui.poserSurPlace(pose);
|
||||
ui.montrerTour(2);
|
||||
assert.equal(ui.etat().pose, null, 'montrerTour');
|
||||
ui.poserSurPlace(pose);
|
||||
ui.montrerProposition(1);
|
||||
assert.equal(ui.etat().pose, null, 'montrerProposition');
|
||||
ui.allerA('plan');
|
||||
ui.poserSurPlace(pose);
|
||||
ui.allerA('plan');
|
||||
assert.deepEqual(ui.etat().pose, pose, 'la même section');
|
||||
ui.allerA('tables');
|
||||
assert.equal(ui.etat().pose, null, 'une autre section');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
// entre ce qui le précède et ce qui le suit. Le nom et le libellé du titre
|
||||
// sont des données : aucun texte de cette table ne les porte. La tête finit
|
||||
// par une espace quand un nom la suit (avecNom).
|
||||
import { compte } from '../../application/libelles/aides.js';
|
||||
import { compte, enumeration } from '../../application/libelles/aides.js';
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- Le dessin d'une table (§ 7.5, § 7.6) ----------------------------------
|
||||
|
|
@ -97,8 +97,86 @@ export const TABLE = new Map([
|
|||
['plan.refus.siege', ({ numero, siege }) => `Table ${numero}, siège ${siege}`],
|
||||
['plan.refus.fermer', 'Fermer'],
|
||||
|
||||
// --- Le clavier et l'infobulle (§ 7.1, § 7.3, § 13.5) ----------------------
|
||||
// Le plan est un seul arrêt de tabulation : sa description dit comment s'y
|
||||
// déplacer. L'infobulle d'une table en conflit nomme chaque règle violée,
|
||||
// puis ses groupes : les noms sont des données, chacun dans son nœud,
|
||||
// reliés par « et » dans une paire et séparés d'un groupe à l'autre.
|
||||
['plan.vue.role', 'plan de salle interactif'],
|
||||
['plan.infobulle.entete', ({ numero, nombre }) => `Table ${numero} : ${compte(nombre, 'conflit', 'conflits')}`],
|
||||
['plan.infobulle.separerAppartenances', 'appartenances réunies : '],
|
||||
['plan.infobulle.nouveauxVoisins', 'voisins revus : '],
|
||||
['plan.infobulle.nouvelleTable', 'table revue : '],
|
||||
['plan.infobulle.et', ' et '],
|
||||
['plan.infobulle.separateur', ', '],
|
||||
|
||||
// --- Les panneaux latéraux (§ 2.1, étape 8) --------------------------------
|
||||
['plan.panneaux', 'Panneaux du plan'],
|
||||
['plan.listesTravail.titre', 'Listes de travail'],
|
||||
['plan.reserve.titre', 'Réserve'],
|
||||
|
||||
// --- Le choix en attente (§ 5.8, § 7.1) ------------------------------------
|
||||
// Le nom de la personne choisie est une donnée, dans son propre nœud.
|
||||
['plan.choix.personne', 'Personne choisie : '],
|
||||
['plan.choix.reservation', 'Réservation choisie : '],
|
||||
['plan.choix.oublier', 'Oublier le choix'],
|
||||
[
|
||||
'plan.choix.inviter',
|
||||
"Choisissez d'abord une personne : dans la réserve, dans la liste des pressentis, ou par « Déplacer » dans son menu.",
|
||||
],
|
||||
|
||||
// --- Le panneau d'une place (§ 7.1, § 2.1 étape 6, § 4) --------------------
|
||||
['plan.action.assigner', 'Assigner'],
|
||||
['plan.action.reserver', 'Réserver'],
|
||||
['plan.action.donnerTitre', 'Donner un titre'],
|
||||
['plan.action.designer', 'Désigner'],
|
||||
['plan.action.retirerReservation', 'Retirer la réservation'],
|
||||
['plan.action.retirerTitre', 'Retirer le titre'],
|
||||
['plan.action.deplacerReservationIci', 'Déplacer la réservation ici'],
|
||||
['plan.portee.etiquette', 'Portée'],
|
||||
['plan.portee.tour', ({ tour }) => `Tour ${tour}`],
|
||||
['plan.portee.tous', 'Tous les tours'],
|
||||
['plan.titre.etiquette', 'Libellé du titre'],
|
||||
|
||||
// --- Le panneau d'une table (§ 7.3, § 2.1 étape 6) -------------------------
|
||||
['plan.panneauTable.sieges', 'Sièges'],
|
||||
[
|
||||
'plan.panneauTable.titres',
|
||||
({ titres, pourvus }) => `${compte(titres, 'titre', 'titres')} dont ${compte(pourvus, 'pourvu', 'pourvus')}`,
|
||||
],
|
||||
['plan.panneauTable.siege', 'Siège'],
|
||||
|
||||
// --- Le menu par personne (§ 7.1) ------------------------------------------
|
||||
// Le nom de la personne, titre du menu, est une donnée dans son nœud.
|
||||
['plan.menu.titre', 'Menu de '],
|
||||
['plan.menu.deplacer', 'Déplacer'],
|
||||
['plan.menu.echanger', 'Échanger'],
|
||||
['plan.menu.retirerDuTour', 'Retirer du tour'],
|
||||
['plan.menu.reserverIci', 'Réserver ici'],
|
||||
['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
|
||||
['plan.menu.exclure', 'Exclure'],
|
||||
['plan.menu.reintegrer', 'Réintégrer'],
|
||||
['plan.menu.ouvrirFiche', 'Ouvrir la fiche'],
|
||||
|
||||
// --- Les listes de travail (§ 5.8, § 4.1, § 4.2) ---------------------------
|
||||
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
|
||||
// des données, chacun dans son nœud, entre ces textes.
|
||||
['plan.listesTravail.titres', 'Titres de place non pourvus'],
|
||||
['plan.listesTravail.pressentis', 'Titres pressentis sans réservation'],
|
||||
['plan.listesTravail.conversions', 'Conversions proposées'],
|
||||
['plan.listesTravail.aucun', 'Aucun.'],
|
||||
['plan.listesTravail.titreNonPourvu', ({ numero }) => `table ${numero} : titre `],
|
||||
['plan.listesTravail.nonPourvu', ' non pourvu'],
|
||||
[
|
||||
'plan.listesTravail.pourvuAuxTours',
|
||||
({ tours }) => ` (pourvu ${tours.length === 1 ? 'au tour' : 'aux tours'} ${enumeration(tours.map(String))})`,
|
||||
],
|
||||
['plan.listesTravail.pressenti', ' \u{2014} titre pressenti '],
|
||||
['plan.listesTravail.conversion', ({ numero }) => ` : une réservation à chaque tour, table ${numero}`],
|
||||
['plan.listesTravail.convertir', 'Convertir en « tous les tours »'],
|
||||
['plan.listesTravail.ignorer', 'Ignorer'],
|
||||
|
||||
// --- La réserve (§ 7.1, § 5.8) ---------------------------------------------
|
||||
['plan.reserve.tour', ({ tour }) => `Personnes en attente au tour ${tour}`],
|
||||
['plan.reserve.vide', 'Personne en attente à ce tour.'],
|
||||
]);
|
||||
|
|
|
|||
74
src/interface/plan/Infobulle.svelte
Normal file
74
src/interface/plan/Infobulle.svelte
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// L'infobulle du plan (§ 7.1, § 7.3) : un seul élément [role=tooltip],
|
||||
// toujours présent dans la surface du plan, hors du <svg data-plan>, caché
|
||||
// tant qu'aucune table en conflit n'est désignée. Plan.svelte l'ouvre par
|
||||
// trois déclencheurs — le survol, le focus du clavier, la pression longue —
|
||||
// et lui passe le détail des conflits (detailConflits, modele.js) et sa
|
||||
// position dans la surface, en px. Elle énonce la table et son nombre de
|
||||
// violations, puis chaque règle violée et ses groupes : chaque nom occupe
|
||||
// son propre nœud, relié à l'autre nom d'une paire par « et », séparé du
|
||||
// groupe suivant.
|
||||
//
|
||||
// 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.
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
let { infobulle, id } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="infobulle"
|
||||
role="tooltip"
|
||||
{id}
|
||||
hidden={infobulle === null}
|
||||
style:left={infobulle === null ? null : `${infobulle.x}px`}
|
||||
style:top={infobulle === null ? null : `${infobulle.y}px`}
|
||||
>
|
||||
{#if infobulle !== null}
|
||||
<p class="entete">{t('plan.infobulle.entete', { numero: infobulle.detail.numero, nombre: infobulle.detail.nombre })}</p>
|
||||
{#each infobulle.detail.regles as regle, rang (rang)}
|
||||
<p>
|
||||
<span>{t(regle.cle)}</span>{#each regle.groupes as groupe, g (g)}{#if g > 0}<span
|
||||
>{t('plan.infobulle.separateur')}</span
|
||||
>{/if}{#each groupe as nom, n (n)}{#if n > 0}<span>{t('plan.infobulle.et')}</span>{/if}<span class="donnee"
|
||||
>{nom}</span
|
||||
>{/each}{/each}
|
||||
</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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);
|
||||
color: var(--couleur-page-texte);
|
||||
box-shadow: var(--ombre-1);
|
||||
font-size: var(--taille-texte);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.infobulle[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entete {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,21 +2,170 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les listes de travail du plan sous leur forme minimale : leur titre,
|
||||
// tiré des libellés. Le panneau lit 'seance' et 'ui' au contexte, sans
|
||||
// propriété de SectionPlan.
|
||||
import { getContext } from 'svelte';
|
||||
// Les listes de travail du plan (§ 5.8, § 4.1, § 4.2, § 2.1 étape 6) : le
|
||||
// travail qui reste, montré plutôt que fait, par des listes cliquables qui
|
||||
// mènent à la place ou à la personne.
|
||||
// - Les titres de place non pourvus (titresNonPourvus, A5),
|
||||
// [data-liste-travail=titres] : « table N : titre <libellé> non
|
||||
// pourvu ». Un clic sélectionne la place et porte le focus à la racine
|
||||
// du plan, qui l'active et l'amène dans le cadre.
|
||||
// - Les personnes au titre pressenti sans réservation
|
||||
// (pressentisNonReserves), [data-liste-travail=pressentis] : un clic en
|
||||
// fait la personne choisie ; « Désigner », dans le panneau de la place,
|
||||
// propose son titre pressenti pour libellé (A5).
|
||||
// - Les conversions proposées (conversionsPossibles, § 4.2) : R
|
||||
// réservations « tour » à une table qu'une seule « tous les tours »
|
||||
// remplacerait. « Convertir » l'exécute ; « Ignorer » la retire de la
|
||||
// liste jusqu'au prochain montage, sans rien écrire.
|
||||
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
|
||||
// des données, chacun dans son nœud. Échap, sur le panneau ou l'un de ses
|
||||
// contrôles, oublie le choix en attente. Le panneau lit 'seance' et 'ui'
|
||||
// au contexte, sans propriété.
|
||||
import { getContext, tick } from 'svelte';
|
||||
import { conversionsPossibles, pressentisNonReserves, titresNonPourvus } from '../../application/listes_travail.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { nomDeCharge } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
const titres = $derived(charge === null ? [] : titresNonPourvus(charge));
|
||||
const pressentis = $derived(charge === null ? [] : pressentisNonReserves(charge));
|
||||
// Les conversions que l'opérateur a ignorées, par clé « personne:table ».
|
||||
let ignorees = $state([]);
|
||||
const conversions = $derived(
|
||||
charge === null
|
||||
? []
|
||||
: conversionsPossibles(charge)
|
||||
.map((conversion) => ({ ...conversion, cle: `${conversion.participant}:${conversion.table}` }))
|
||||
.filter(({ cle }) => !ignorees.includes(cle)),
|
||||
);
|
||||
const numeroDe = (id) => charge.tables.find((table) => table.id === id).numero;
|
||||
const choix = $derived($ui.choix);
|
||||
const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id;
|
||||
|
||||
// La place d'un titre sélectionnée, puis le focus à la racine du plan.
|
||||
async function allerAuTitre(evenement, { table, siege }) {
|
||||
const section = evenement.currentTarget.closest('[data-section]');
|
||||
ui.selectionner({ table, siege });
|
||||
await tick();
|
||||
section?.querySelector('[data-plan]')?.focus();
|
||||
}
|
||||
|
||||
function basculer(id) {
|
||||
ui.choisir(choisie(id) ? null : { genre: 'personne', participant: id, depuis: null });
|
||||
}
|
||||
|
||||
function surTouche(evenement) {
|
||||
if (evenement.key !== 'Escape' || choix === null) return;
|
||||
evenement.preventDefault();
|
||||
ui.choisir(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h3>{t('plan.listesTravail.titre')}</h3>
|
||||
<!-- É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}>
|
||||
<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 (`${titre.table}:${titre.siege}:${titre.libelle}`)}
|
||||
<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
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h3 {
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
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;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
124
src/interface/plan/MenuPersonne.svelte
Normal file
124
src/interface/plan/MenuPersonne.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le menu d'une personne (§ 7.1) : les actions d'un glissement, sans
|
||||
// glisser, pour le tactile et le clavier, et ce qu'aucun glissement ne
|
||||
// fait. Les actions sont celles d'actionsPersonne (modele.js), lues sur la
|
||||
// place que la personne occupe au tour montré :
|
||||
// - déplacer, échanger : la personne devient le choix en attente
|
||||
// (ui.choix) ; une place choisie ensuite, « Assigner » donne ce que
|
||||
// donne le dépôt du glissement (PanneauPlace, commandeDeDepot) ;
|
||||
// - retirer du tour : le dépôt sur le sol, par commandeDeDepot ;
|
||||
// - réserver ici : une réservation du tour montré à sa place ;
|
||||
// - déplacer sa réservation : la réservation qui tient sa chaise devient
|
||||
// le choix en attente, que « Déplacer la réservation ici » déplace ;
|
||||
// - exclure, ou réintégrer une exclue ;
|
||||
// - ouvrir la fiche, dans la section des participants (ui.ouvrirFiche).
|
||||
// Un refus se pose sur sa place, ou sur la place sélectionnée quand elle
|
||||
// n'est pas assise (executerSurPlace), sinon à côté de la commande.
|
||||
//
|
||||
// participant : l'identifiant de la personne ; le menu porte data-menu.
|
||||
import { getContext } from 'svelte';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { commandeDeDepot } from './depot.js';
|
||||
import { actionsPersonne, executerSurPlace } from './modele.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
let { participant } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const fiche = $derived(charge === null ? undefined : charge.participants.find(({ id }) => id === participant));
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const etat = $derived(fiche === undefined ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
// La place que la personne occupe au tour montré, et sa chaise, ou null.
|
||||
const assise = $derived.by(() => {
|
||||
if (etat === null) return null;
|
||||
for (const table of etat.tables) {
|
||||
const chaise = table.chaises.find(({ occupant }) => occupant === participant);
|
||||
if (chaise !== undefined) return { place: { table: table.id, siege: chaise.siege }, chaise };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const retrait = $derived(
|
||||
assise === null
|
||||
? null
|
||||
: commandeDeDepot({ etatPlaces: etat, tour, personne: participant, depuis: assise.place, cible: null, surLeSol: true }),
|
||||
);
|
||||
const actions = $derived(fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait }));
|
||||
|
||||
const dependances = { seance, ui };
|
||||
// Où poser un refus : sa place, sinon la place sélectionnée.
|
||||
const lieu = () => assise?.place ?? ui.etat().selection ?? null;
|
||||
|
||||
function choisirPersonne() {
|
||||
ui.choisir({ genre: 'personne', participant, depuis: assise === null ? null : assise.place });
|
||||
}
|
||||
|
||||
async function retirerDuTour() {
|
||||
if (Object.hasOwn(retrait, 'refus')) {
|
||||
ui.poserSurPlace({ place: assise.place, refus: retrait.refus, avertissements: [] });
|
||||
return;
|
||||
}
|
||||
await executerSurPlace(dependances, assise.place, retrait.commande, retrait.arguments);
|
||||
}
|
||||
|
||||
async function reserverIci() {
|
||||
const { table, siege } = assise.place;
|
||||
await executerSurPlace(dependances, assise.place, 'reserver', { participant, table, siege, portee: 'tour', tour });
|
||||
}
|
||||
|
||||
function deplacerReservation() {
|
||||
const { portee, tour: sonTour } = assise.chaise.reservation;
|
||||
ui.choisir({ genre: 'reservation', participant, depuis: assise.place, de: { table: assise.place.table, portee, tour: sonTour } });
|
||||
}
|
||||
|
||||
const EXECUTIONS = {
|
||||
deplacer: choisirPersonne,
|
||||
echanger: choisirPersonne,
|
||||
retirerDuTour,
|
||||
reserverIci,
|
||||
deplacerReservation,
|
||||
exclure: () => executerSurPlace(dependances, lieu(), 'exclureParticipant', { id: participant }),
|
||||
reintegrer: () => executerSurPlace(dependances, lieu(), 'reintegrerParticipant', { id: participant }),
|
||||
ouvrirFiche: () => ui.ouvrirFiche(participant),
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if fiche !== undefined}
|
||||
<section class="menu" data-menu={participant} aria-labelledby="{uid}-titre">
|
||||
<h4 id="{uid}-titre"><span>{t('plan.menu.titre')}</span><span class="donnee">{nomDeCharge(charge)(participant)}</span></h4>
|
||||
<div class="actions">
|
||||
{#each actions as action (action)}
|
||||
<Commande cle="plan.menu.{action}" nom={action} executer={EXECUTIONS[action]} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
padding-top: var(--espace-2);
|
||||
border-top: 1px solid var(--couleur-page-filet);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,6 +2,237 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le panneau d'une place sous sa forme minimale : il ne rend rien.
|
||||
// SectionPlan le monte sans propriété ; ce qu'il montre se lit au contexte.
|
||||
// Le panneau de la place sélectionnée (§ 7.1, § 2.1 étape 6, § 4, § 5.9) :
|
||||
// ouvert quand l'état de navigation sélectionne un siège, {table, siege}.
|
||||
// Il nomme la place et sa ligne, le choix en attente, puis offre les
|
||||
// actions d'actionsPlace (modele.js) : assigner, réserver — portée au
|
||||
// choix —, donner un titre, désigner, retirer la réservation, retirer le
|
||||
// titre, déplacer la réservation ici. Assigner, réserver et désigner
|
||||
// portent sur la personne choisie (ui.choix), et le panneau invite à la
|
||||
// choisir tant qu'il n'y en a pas. Assigner donne ce que donne le dépôt
|
||||
// d'un glissement, par commandeDeDepot (depot.js) : une seule
|
||||
// implémentation du dépôt. Sous des sièges non attribués, un titre et une
|
||||
// désignation se posent sans siège : l'application prend le siège (A5).
|
||||
//
|
||||
// Toute commande passe par executerSurPlace : son refus se pose sur la
|
||||
// place du plan, sauf LIBELLE_VIDE, refusé à côté du champ du libellé.
|
||||
// Le menu de l'occupant suit (MenuPersonne) ; il reste celui de la même
|
||||
// personne tant que la place reste sélectionnée, même quand elle la quitte
|
||||
// — une exclusion —, pour qu'elle se réintègre d'où elle a été exclue.
|
||||
//
|
||||
// Échap referme le panneau et rend le focus à la racine du plan. Le
|
||||
// panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import { etatPlaces, libellePropose } from '../../application/places.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { commandeDeDepot } from './depot.js';
|
||||
import { libellePlace } from './gestes.js';
|
||||
import MenuPersonne from './MenuPersonne.svelte';
|
||||
import { actionsPlace, executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const selection = $derived($ui.selection);
|
||||
const place = $derived(charge !== null && selection !== null && (selection.siege ?? null) !== null ? selection : null);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const etat = $derived(place === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === place.table));
|
||||
const chaise = $derived(etatTable === undefined ? null : (etatTable.chaises[place.siege - 1] ?? null));
|
||||
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
const choix = $derived($ui.choix);
|
||||
const personne = $derived(choix !== null && choix.genre === 'personne' ? choix : null);
|
||||
const actions = $derived(chaise === null ? [] : actionsPlace(chaise, choix));
|
||||
const parties = $derived(chaise === null ? [] : partiesLigne(textesLigne(ligneListe(chaise, nomDe), t)));
|
||||
const nommee = $derived(etatTable === undefined ? null : libellePlace({ numero: etatTable.numero, siege: place.siege }));
|
||||
|
||||
// Le libellé proposé : le titre pressenti de la personne choisie, sinon
|
||||
// celui de la place, sinon le plus fréquent (libellePropose, A5).
|
||||
const propose = $derived.by(() => {
|
||||
if (chaise === null) return '';
|
||||
if (personne !== null) return libellePropose(charge, personne.participant);
|
||||
return chaise.titre === null ? libellePropose(charge, 0) : chaise.titre.libelle;
|
||||
});
|
||||
// La saisie de l'opérateur, null tant qu'il n'a rien tapé ; la portée
|
||||
// d'une réservation.
|
||||
let saisi = $state(null);
|
||||
let portee = $state('tour');
|
||||
const libelleSaisi = $derived(saisi ?? propose);
|
||||
|
||||
// Le menu : la personne de la place au moment de sa sélection, ou la
|
||||
// première qui s'y assied ensuite.
|
||||
const occupant = $derived(chaise === null ? null : chaise.occupant);
|
||||
let menuDe = $state(null);
|
||||
let placeDuMenu = null;
|
||||
$effect(() => {
|
||||
const lue = place;
|
||||
const assise = occupant;
|
||||
untrack(() => {
|
||||
if (lue !== placeDuMenu) {
|
||||
placeDuMenu = lue;
|
||||
menuDe = assise;
|
||||
saisi = null;
|
||||
} else if (menuDe === null) {
|
||||
menuDe = assise;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const dependances = { seance, ui };
|
||||
const sieges = () => (etat.siegesAttribues ? place.siege : null);
|
||||
|
||||
async function assigner() {
|
||||
if (personne === null) return;
|
||||
const rendu = commandeDeDepot({
|
||||
etatPlaces: etat,
|
||||
tour,
|
||||
personne: personne.participant,
|
||||
depuis: personne.depuis,
|
||||
cible: { table: place.table, siege: place.siege },
|
||||
surLeSol: false,
|
||||
});
|
||||
if (rendu === null) return;
|
||||
if (Object.hasOwn(rendu, 'refus')) {
|
||||
ui.poserSurPlace({ place, refus: rendu.refus, avertissements: [] });
|
||||
return;
|
||||
}
|
||||
await executerSurPlace(dependances, place, rendu.commande, rendu.arguments);
|
||||
}
|
||||
|
||||
async function reserver() {
|
||||
if (personne === null) return;
|
||||
await executerSurPlace(dependances, place, 'reserver', {
|
||||
participant: personne.participant,
|
||||
table: place.table,
|
||||
siege: place.siege,
|
||||
portee,
|
||||
tour: portee === 'tour' ? tour : null,
|
||||
});
|
||||
}
|
||||
|
||||
async function donnerTitre() {
|
||||
await executerSurPlace(dependances, place, 'poserTitre', { table: place.table, siege: sieges(), libelle: libelleSaisi }, { locaux: ['LIBELLE_VIDE'] });
|
||||
}
|
||||
|
||||
async function designer() {
|
||||
if (personne === null) return;
|
||||
await executerSurPlace(
|
||||
dependances,
|
||||
place,
|
||||
'designer',
|
||||
{ participant: personne.participant, table: place.table, siege: sieges(), libelle: libelleSaisi },
|
||||
{ locaux: ['LIBELLE_VIDE'] },
|
||||
);
|
||||
}
|
||||
|
||||
async function retirerReservation() {
|
||||
const { participant, portee: sienne, tour: sonTour } = chaise.reservation;
|
||||
await executerSurPlace(dependances, place, 'retirerReservation', { participant, table: place.table, portee: sienne, tour: sonTour });
|
||||
}
|
||||
|
||||
async function retirerTitre() {
|
||||
await executerSurPlace(dependances, place, 'retirerTitre', { table: place.table, siege: place.siege });
|
||||
}
|
||||
|
||||
async function deplacerReservationIci() {
|
||||
await executerSurPlace(dependances, place, 'deplacerReservation', {
|
||||
participant: choix.participant,
|
||||
de: choix.de,
|
||||
vers: { table: place.table, siege: place.siege },
|
||||
});
|
||||
}
|
||||
|
||||
const EXECUTIONS = { assigner, reserver, donnerTitre, designer, retirerReservation, retirerTitre, deplacerReservationIci };
|
||||
|
||||
// Échap referme le panneau et rend le focus à la racine du plan.
|
||||
function surTouche(evenement) {
|
||||
if (evenement.key !== 'Escape') return;
|
||||
evenement.preventDefault();
|
||||
const racine = evenement.currentTarget.closest('[data-section]')?.querySelector('[data-plan]') ?? null;
|
||||
ui.selectionner(null);
|
||||
racine?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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}>
|
||||
<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
|
||||
>
|
||||
</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)} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if menuDe !== null}
|
||||
<MenuPersonne participant={menuDe} />
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panneau {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.titre {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,216 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le panneau d'une table sous sa forme minimale : il ne rend rien.
|
||||
// SectionPlan le monte sans propriété ; ce qu'il montre se lit au contexte.
|
||||
// Le panneau de la table sélectionnée (§ 7.3, § 2.1 étape 6, § 5.9) :
|
||||
// ouvert quand l'état de navigation sélectionne une table sans siège,
|
||||
// {table} — un clic sur une table, ou l'entrée du clavier sur une table
|
||||
// en régime table, où les chaises ne sont plus des cibles. Il nomme la
|
||||
// table, compte ses titres et ceux qu'une réservation « tous les tours »
|
||||
// pourvoit (comptesTitres, A5), dit son gel, et liste ses sièges, chacun
|
||||
// un bouton [data-choix-siege] qui sélectionne la place : le siège se
|
||||
// choisit dans le panneau. Le premier reçoit le focus quand le clavier
|
||||
// ouvre le panneau ([data-focus-initial]). Le siège choisi remplace ce
|
||||
// panneau par celui de la place, qui reçoit le focus : il ne tombe pas
|
||||
// sur le corps du document avec le bouton détruit.
|
||||
//
|
||||
// Il offre aussi « Donner un titre » et « Désigner », sur la personne
|
||||
// choisie, au niveau de la table : sous des sièges non attribués, aucun
|
||||
// choix de siège n'est offert — l'application prend le siège (A5) — ;
|
||||
// sous des sièges attribués, le numéro se choisit ([data-champ=siege]),
|
||||
// le plus petit siège sans titre d'abord. Un refus se pose sur la table
|
||||
// (executerSurPlace), LIBELLE_VIDE à côté du champ.
|
||||
//
|
||||
// Échap referme le panneau et rend le focus à la racine du plan. Le
|
||||
// panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
import { getContext, tick, untrack } from 'svelte';
|
||||
import { comptesTitres } from '../../application/listes_travail.js';
|
||||
import { etatPlaces, libellePropose } from '../../application/places.js';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { libellePlace } from './gestes.js';
|
||||
import { executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const selection = $derived($ui.selection);
|
||||
const table = $derived(charge !== null && selection !== null && (selection.siege ?? null) === null ? selection.table : null);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const etat = $derived(table === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === table));
|
||||
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
const comptes = $derived(etatTable === undefined ? null : comptesTitres(charge, table));
|
||||
const nommee = $derived(etatTable === undefined ? null : libellePlace({ numero: etatTable.numero, siege: null }));
|
||||
const lignes = $derived(
|
||||
etatTable === undefined ? [] : etatTable.chaises.map((chaise) => partiesLigne(textesLigne(ligneListe(chaise, nomDe), t))),
|
||||
);
|
||||
const choix = $derived($ui.choix);
|
||||
const personne = $derived(choix !== null && choix.genre === 'personne' ? choix : null);
|
||||
const propose = $derived(charge === null ? '' : libellePropose(charge, personne === null ? 0 : personne.participant));
|
||||
|
||||
// La saisie du libellé, null tant que l'opérateur n'a rien tapé ; le
|
||||
// siège choisi sous des sièges attribués, null pour le premier sans titre.
|
||||
let saisi = $state(null);
|
||||
let siege = $state(null);
|
||||
const libelleSaisi = $derived(saisi ?? propose);
|
||||
const premierSansTitre = $derived(etatTable === undefined ? 1 : (etatTable.chaises.find(({ titre }) => titre === null)?.siege ?? 1));
|
||||
const siegeChoisi = $derived(siege ?? premierSansTitre);
|
||||
|
||||
let tableSuivie = null;
|
||||
$effect(() => {
|
||||
const lue = table;
|
||||
untrack(() => {
|
||||
if (lue === tableSuivie) return;
|
||||
tableSuivie = lue;
|
||||
saisi = null;
|
||||
siege = null;
|
||||
});
|
||||
});
|
||||
|
||||
const dependances = { seance, ui };
|
||||
const lieu = () => ({ table });
|
||||
const siegeDuTitre = () => (etat.siegesAttribues ? siegeChoisi : null);
|
||||
|
||||
async function donnerTitre() {
|
||||
await executerSurPlace(dependances, lieu(), 'poserTitre', { table, siege: siegeDuTitre(), libelle: libelleSaisi }, { locaux: ['LIBELLE_VIDE'] });
|
||||
}
|
||||
|
||||
async function designer() {
|
||||
if (personne === null) return;
|
||||
await executerSurPlace(
|
||||
dependances,
|
||||
lieu(),
|
||||
'designer',
|
||||
{ participant: personne.participant, table, siege: siegeDuTitre(), libelle: libelleSaisi },
|
||||
{ locaux: ['LIBELLE_VIDE'] },
|
||||
);
|
||||
}
|
||||
|
||||
// Sélectionne le siège, puis porte le focus au panneau de la place : à
|
||||
// son élément [data-focus-initial], sinon au panneau.
|
||||
async function choisirSiege(evenement, numero) {
|
||||
const section = evenement.currentTarget.closest('[data-section]');
|
||||
ui.selectionner({ table, siege: numero });
|
||||
await tick();
|
||||
const suivant = section?.querySelector('[data-panneau="place"]') ?? null;
|
||||
(suivant?.querySelector('[data-focus-initial]') ?? suivant)?.focus();
|
||||
}
|
||||
|
||||
function surTouche(evenement) {
|
||||
if (evenement.key !== 'Escape') return;
|
||||
evenement.preventDefault();
|
||||
const racine = evenement.currentTarget.closest('[data-section]')?.querySelector('[data-plan]') ?? null;
|
||||
ui.selectionner(null);
|
||||
racine?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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}>
|
||||
<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)}
|
||||
>
|
||||
{#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}
|
||||
<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.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>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.panneau {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.gel {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sieges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sieges button {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.titre {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
1387
src/interface/plan/Panneaux.navigateur.test.js
Normal file
1387
src/interface/plan/Panneaux.navigateur.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -51,17 +51,53 @@
|
|||
// par RefusPlace, hors du <svg>, et la place le désigne par
|
||||
// aria-describedby ; l'appui suivant le retire. Tant que la commande d'un
|
||||
// geste n'est pas réglée, un appui ne saisit que le sol.
|
||||
import { getContext, tick } from 'svelte';
|
||||
//
|
||||
// La pose — refus ou avertissements sur une place — vit dans l'état de
|
||||
// navigation (ui.pose) : un panneau latéral y pose le refus d'une commande
|
||||
// de place, que le plan montre comme celui d'un geste. Son remède
|
||||
// choisirPlace (PLACE_RESERVEE) fait de la réservation qui tient la place
|
||||
// le choix en attente (ui.choix). Une commande d'un geste exécutée oublie
|
||||
// le choix en attente.
|
||||
//
|
||||
// Le clavier (§ 7.3, § 13.5) : la racine est le seul arrêt de tabulation
|
||||
// du plan — tabindex 0, un role, un aria-label — et désigne l'élément
|
||||
// actif, une table ou un siège, par aria-activedescendant, sur les id que
|
||||
// TablePlan pose. Les touches passent par toucheDuPlan (clavier.js) :
|
||||
// flèches, entrée, échappement. L'entrée sur un siège le sélectionne et
|
||||
// porte le focus au panneau de la place ; en régime table, elle ouvre le
|
||||
// panneau de la table et porte le focus sur la liste de ses sièges. Le
|
||||
// panneau rend le focus à la racine par Échap. Échap sur la racine oublie
|
||||
// le choix en attente, quel que soit l'élément actif. L'élément actif suit
|
||||
// la place sélectionnée. Une flèche, un focus venu du clavier et une
|
||||
// sélection venue d'ailleurs que du pointeur — un panneau, une liste de
|
||||
// travail — amènent l'élément actif dans le cadre (vueQuiMontre) ; la
|
||||
// sélection d'un appui sur le plan ne déplace pas la vue : la chaise
|
||||
// cliquée reste sous le pointeur.
|
||||
// La marque focus ne se montre que sous un focus venu du clavier
|
||||
// (:focus-visible, puis toute touche) : un clic n'en pose pas.
|
||||
//
|
||||
// L'infobulle (§ 7.1, § 7.3) : un seul [role=tooltip], ouvert sur une table
|
||||
// en conflit par le survol — un pointermove de la racine sans bouton
|
||||
// enfoncé, hors d'un geste —, par le focus du clavier sur la table ou l'un
|
||||
// de ses sièges, et par la pression longue d'un doigt, minuterie de
|
||||
// l'horloge de DUREE_PRESSION_LONGUE_MS, annulée au relâcher, à
|
||||
// l'annulation, ou quand la machine du geste juge l'appui devenu
|
||||
// glissement, au-delà du seuil. Elle reste ouverte jusqu'à l'appui
|
||||
// suivant.
|
||||
import { getContext, tick, untrack } from 'svelte';
|
||||
import { controlerCapacite } from '../../application/capacite.js';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { constante } from '../../geometrie/constantes.js';
|
||||
import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js';
|
||||
import BandeauPlan from './BandeauPlan.svelte';
|
||||
import BarreVue from './BarreVue.svelte';
|
||||
import { toucheDuPlan, vueQuiMontre } from './clavier.js';
|
||||
import { deposer } from './depot.js';
|
||||
import EchellePlan from './EchellePlan.svelte';
|
||||
import Fantome from './Fantome.svelte';
|
||||
import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
|
||||
import { modeleDessin } from './modele.js';
|
||||
import Infobulle from './Infobulle.svelte';
|
||||
import { detailConflits, modeleDessin } from './modele.js';
|
||||
import PlanDessin from './PlanDessin.svelte';
|
||||
import RefusPlace from './RefusPlace.svelte';
|
||||
import {
|
||||
|
|
@ -80,6 +116,9 @@
|
|||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const horloge = getContext('horloge');
|
||||
|
||||
const DUREE_PRESSION = constante('DUREE_PRESSION_LONGUE_MS');
|
||||
|
||||
let racine = $state(null);
|
||||
let vue = $state.raw(null);
|
||||
|
|
@ -90,13 +129,22 @@
|
|||
let poignee = $state.raw(null);
|
||||
// Le fantôme montré, {nom, cle}, ou null.
|
||||
let fantome = $state.raw(null);
|
||||
// Le refus ou les avertissements posés sur une place : {place, refus,
|
||||
// avertissements, ancre — le point de dessin de la place, cm —, origine —
|
||||
// le coin du <svg> dans la surface, px —}, ou null.
|
||||
let pose = $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.
|
||||
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.
|
||||
let actif = $state.raw(null);
|
||||
let aLeFocus = $state(false);
|
||||
let auClavier = $state(false);
|
||||
// Les tables que désignent le survol et la pression longue, ou null.
|
||||
let survol = $state(null);
|
||||
let pression = $state(null);
|
||||
|
||||
const prefixe = $props.id();
|
||||
const idPose = `${prefixe}-pose`;
|
||||
const idInfobulle = `${prefixe}-infobulle`;
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const tours = $derived(charge.evenement.tours);
|
||||
|
|
@ -104,19 +152,51 @@
|
|||
const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const englobantPlan = $derived(englobantDuTour(etat));
|
||||
const nomDe = $derived(nomDeCharge(charge));
|
||||
const selection = $derived($ui.selection);
|
||||
const pose = $derived($ui.pose);
|
||||
// L'élément actif que le dessin marque : sous un focus venu du clavier.
|
||||
const actifMontre = $derived(aLeFocus && auClavier ? actif : null);
|
||||
const modele = $derived(
|
||||
vue === null
|
||||
? null
|
||||
: modeleDessin(etat, { k: vue.k, listes: $ui.listes, miseEnEvidence: $ui.miseEnEvidence, nomDe, diagnostic, poignee }),
|
||||
: modeleDessin(etat, {
|
||||
k: vue.k,
|
||||
listes: $ui.listes,
|
||||
miseEnEvidence: $ui.miseEnEvidence,
|
||||
nomDe,
|
||||
diagnostic,
|
||||
poignee,
|
||||
selection,
|
||||
actif: actifMontre,
|
||||
}),
|
||||
);
|
||||
// La pose à sa position dans la surface, sous la vue courante.
|
||||
// La pose à sa position dans la surface, sous la vue courante ; rien pour
|
||||
// une place dont la table n'est plus dessinée.
|
||||
const poseAffichee = $derived.by(() => {
|
||||
if (pose === null || vue === null) return null;
|
||||
const { place, refus, avertissements, ancre, origine } = pose;
|
||||
const { x, y } = ecranDepuisDessin(vue, origine, ancre.x, ancre.y);
|
||||
if (pose === null || vue === null || modele === null) return null;
|
||||
const { place, refus, avertissements } = pose;
|
||||
const ancre = ancreDe(place);
|
||||
if (ancre === null) return null;
|
||||
const { x, y } = ecranDepuisDessin(vue, origineSvg, ancre.x, ancre.y);
|
||||
const numero = etat.tables.find(({ id }) => id === place.table)?.numero ?? null;
|
||||
return { id: idPose, numero, siege: place.siege ?? null, refus, avertissements, x, y };
|
||||
});
|
||||
// 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.
|
||||
const infobulleTable = $derived(pression ?? survol ?? (actifMontre === null ? null : actifMontre.table));
|
||||
const infobulle = $derived.by(() => {
|
||||
if (infobulleTable === null || vue === null || modele === null) return null;
|
||||
const etatTable = etat.tables.find(({ id }) => id === infobulleTable);
|
||||
const dessin = modele.tables.find(({ id }) => id === infobulleTable);
|
||||
if (etatTable === undefined || dessin === undefined) return null;
|
||||
const detail = detailConflits(etatTable, nomDe);
|
||||
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 infobulleDuFocus = $derived(infobulle !== null && pression === null && survol === null);
|
||||
|
||||
// Le cadre de la racine, relu maintenant.
|
||||
const cadreCourant = () => cadreDepuisRect(racine.getBoundingClientRect());
|
||||
|
|
@ -176,31 +256,110 @@
|
|||
return chaise ?? racine.querySelector(`[data-table="${table}"]`);
|
||||
}
|
||||
|
||||
// Le point de dessin d'une place : le centre de sa chaise, ou de sa table.
|
||||
// Le point de dessin d'une place : le centre de sa chaise, ou de sa
|
||||
// table ; null pour une table que le dessin n'a pas.
|
||||
function ancreDe({ table, siege = null }) {
|
||||
const { geometrie } = modele.tables.find(({ id }) => id === table);
|
||||
const dessin = modele.tables.find(({ id }) => id === table);
|
||||
if (dessin === undefined) return null;
|
||||
const { geometrie } = dessin;
|
||||
const chaise = siege === null ? undefined : geometrie.chaises[siege - 1];
|
||||
return chaise === undefined ? { ...geometrie.centre } : { x: chaise.x, y: chaise.y };
|
||||
}
|
||||
|
||||
// Le coin du <svg> dans la surface, relu à l'appui : un cadre dont le
|
||||
// bord gauche et le haut sont relatifs à la surface.
|
||||
let origineSurface = { gauche: 0, haut: 0, largeur: 0, hauteur: 0 };
|
||||
// Relit le coin du <svg> dans la surface, du cadre relu à l'instant.
|
||||
function relireOrigine(cadre) {
|
||||
const rect = surface.getBoundingClientRect();
|
||||
origineSvg = { gauche: cadre.gauche - rect.left, haut: cadre.haut - rect.top, largeur: cadre.largeur, hauteur: cadre.hauteur };
|
||||
}
|
||||
|
||||
// Pose un refus et des avertissements sur une place ; rien des deux
|
||||
// retire la pose.
|
||||
function poserSurPlace(place, refus, avertissements) {
|
||||
if (refus === null && avertissements.length === 0) {
|
||||
pose = null;
|
||||
return;
|
||||
}
|
||||
pose = { place, refus, avertissements, ancre: ancreDe(place), origine: origineSurface };
|
||||
ui.poserSurPlace(refus === null && avertissements.length === 0 ? null : { place, refus, avertissements });
|
||||
}
|
||||
|
||||
const fermerPose = () => {
|
||||
pose = null;
|
||||
ui.poserSurPlace(null);
|
||||
};
|
||||
|
||||
// Les gestes qu'un remède posé sur une place emprunte au plan : choisir
|
||||
// une place pour la réservation qui tient la place refusée (PLACE_RESERVEE,
|
||||
// { geste: 'choisirPlace', puis }) — elle devient le choix en attente, et
|
||||
// « Déplacer la réservation ici » la déplace (PanneauPlace).
|
||||
const gestesPose = {
|
||||
choisirPlace: (remede) => {
|
||||
const place = ui.etat().pose?.place ?? null;
|
||||
const { participant, de } = remede.puis.arguments;
|
||||
ui.choisir({
|
||||
genre: 'reservation',
|
||||
participant,
|
||||
depuis: place === null ? null : { table: place.table, siege: place.siege ?? null },
|
||||
de,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// Amène une place dans le cadre, k inchangé ; la vue ne change que si la
|
||||
// place en sort.
|
||||
function montrer(place) {
|
||||
if (place === null || vue === null || modele === null) return;
|
||||
const ancre = ancreDe(place);
|
||||
if (ancre === null) return;
|
||||
const suivante = vueQuiMontre(vue, cadreCourant(), ancre);
|
||||
if (suivante !== vue) poserVue(suivante);
|
||||
}
|
||||
|
||||
// Porte le focus à un panneau latéral de la section : à son élément
|
||||
// [data-focus-initial], sinon au panneau.
|
||||
async function focaliserPanneau(genre) {
|
||||
await tick();
|
||||
const section = racine.closest('[data-section]') ?? document;
|
||||
const panneau = section.querySelector(`[data-panneau="${genre}"]`);
|
||||
if (panneau === null) return;
|
||||
(panneau.querySelector('[data-focus-initial]') ?? panneau).focus();
|
||||
}
|
||||
|
||||
// Le contexte de toucheDuPlan : les géométries dessinées, leur régime et
|
||||
// leur nombre de chaises.
|
||||
function contexteClavier() {
|
||||
const dessin = (id) => modele.tables.find((table) => table.id === id);
|
||||
return {
|
||||
geometries: modele.tables.map(({ geometrie }) => geometrie),
|
||||
regimeDe: (id) => dessin(id).regime,
|
||||
capaciteDe: (id) => dessin(id).geometrie.chaises.length,
|
||||
};
|
||||
}
|
||||
|
||||
// L'élément actif qu'annonce aria-activedescendant : le siège quand sa
|
||||
// chaise est dessinée, sa table sinon.
|
||||
const activeDescendant = $derived.by(() => {
|
||||
if (actif === null || modele === null) return undefined;
|
||||
const dessin = modele.tables.find(({ id }) => id === actif.table);
|
||||
if (dessin === undefined) return undefined;
|
||||
if (actif.siege !== null && dessin.chaises.some(({ siege }) => siege === actif.siege)) return `siege-${actif.table}-${actif.siege}`;
|
||||
return `table-${actif.table}`;
|
||||
});
|
||||
|
||||
// L'élément actif suit la place sélectionnée ; une sélection nouvelle
|
||||
// vient dans le cadre, sauf celle d'un appui du pointeur, que l'effet
|
||||
// selectionner d'un geste désigne ici par l'objet même qu'il confie à
|
||||
// ui.selectionner. La sélection du montage ne déplace pas la vue.
|
||||
let selectionSuivie = untrack(() => selection);
|
||||
let selectionDuPointeur = null;
|
||||
$effect(() => {
|
||||
const place = selection;
|
||||
if (place === selectionSuivie) return;
|
||||
selectionSuivie = place;
|
||||
const duPointeur = place !== null && place === selectionDuPointeur;
|
||||
selectionDuPointeur = null;
|
||||
if (place === null) return;
|
||||
untrack(() => {
|
||||
actif = { table: place.table, siege: place.siege ?? null };
|
||||
if (!duPointeur) montrer(actif);
|
||||
});
|
||||
});
|
||||
|
||||
// Restaure ce qu'un geste a montré.
|
||||
function restaurer(cible) {
|
||||
if (cible === 'vue') groupeVue.setAttribute('transform', transformDe(vue));
|
||||
|
|
@ -226,6 +385,7 @@
|
|||
commandeEnCours = true;
|
||||
try {
|
||||
await seance.executer(commande, args);
|
||||
ui.choisir(null);
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string') throw erreur;
|
||||
poserSurPlace(place, { code: erreur.code, details: erreur.details ?? {} }, []);
|
||||
|
|
@ -253,6 +413,7 @@
|
|||
const recadrer = () => {
|
||||
image = null;
|
||||
const cadre = cadreCourant();
|
||||
relireOrigine(cadre);
|
||||
vue = vue === null ? vueInitiale(seance.cadrage(), englobantPlan, cadre) : vueApresCadre(vue, englobantPlan, cadre);
|
||||
};
|
||||
const observateur = new ResizeObserver(() => {
|
||||
|
|
@ -275,6 +436,12 @@
|
|||
const element = racine;
|
||||
let etatGeste = REPOS;
|
||||
let contexte = null;
|
||||
// La minuterie de la pression longue, armée par l'appui d'un doigt.
|
||||
let minuteriePression = null;
|
||||
const oublierPression = () => {
|
||||
if (minuteriePression !== null) horloge.annuler(minuteriePression);
|
||||
minuteriePression = null;
|
||||
};
|
||||
// Le dernier point d'un mouvement en attente de son image.
|
||||
let image = null;
|
||||
let enAttente = null;
|
||||
|
|
@ -289,6 +456,9 @@
|
|||
function appliquer({ etat: suivant, effets }) {
|
||||
const pointeur = etatGeste.pointeur;
|
||||
etatGeste = suivant;
|
||||
// Un appui devenu glissement — au-delà du seuil, que la machine juge —
|
||||
// n'est plus une pression longue.
|
||||
if (suivant.phase === 'glisse') oublierPression();
|
||||
for (const effet of effets) {
|
||||
switch (effet.type) {
|
||||
case 'capturer':
|
||||
|
|
@ -314,10 +484,10 @@
|
|||
break;
|
||||
case 'montrerPlaces':
|
||||
poignee = { table: effet.table, places: effet.places };
|
||||
poserSurPlace({ table: effet.table }, pose?.refus ?? null, effet.avertissements);
|
||||
poserSurPlace({ table: effet.table }, ui.etat().pose?.refus ?? null, effet.avertissements);
|
||||
break;
|
||||
case 'montrerRefus':
|
||||
poserSurPlace(effet.place, effet.refus, pose?.avertissements ?? []);
|
||||
poserSurPlace(effet.place, effet.refus, ui.etat().pose?.avertissements ?? []);
|
||||
break;
|
||||
case 'restaurer':
|
||||
restaurer(effet.cible);
|
||||
|
|
@ -326,6 +496,7 @@
|
|||
poserVue(effet.vue);
|
||||
break;
|
||||
case 'selectionner':
|
||||
selectionDuPointeur = effet.place;
|
||||
ui.selectionner(effet.place);
|
||||
break;
|
||||
case 'executer':
|
||||
|
|
@ -352,13 +523,24 @@
|
|||
// qu'une commande d'un geste n'est pas réglée, seul le sol se saisit :
|
||||
// le déplacement de vue ne lit ni la charge ni l'état des places.
|
||||
const surAppui = (evenement) => {
|
||||
auClavier = false;
|
||||
if (etatGeste.phase !== 'repos' || evenement.button !== 0 || vue === null || modele === null) return;
|
||||
const source = sourceDe(evenement.target);
|
||||
if (commandeEnCours && source.genre !== 'sol') return;
|
||||
const cadre = cadreCourant();
|
||||
const rect = surface.getBoundingClientRect();
|
||||
origineSurface = { gauche: cadre.gauche - rect.left, haut: cadre.haut - rect.top, largeur: cadre.largeur, hauteur: cadre.hauteur };
|
||||
pose = null;
|
||||
relireOrigine(cadre);
|
||||
ui.poserSurPlace(null);
|
||||
survol = null;
|
||||
pression = null;
|
||||
oublierPression();
|
||||
// Un doigt posé sur une table : sa pression longue ouvre l'infobulle.
|
||||
if (evenement.pointerType === 'touch' && source.table !== null) {
|
||||
const table = source.table;
|
||||
minuteriePression = horloge.planifier(() => {
|
||||
minuteriePression = null;
|
||||
pression = table;
|
||||
}, DUREE_PRESSION);
|
||||
}
|
||||
// La charge, l'état, les géométries et k du moment de l'appui : le
|
||||
// geste se juge sur eux jusqu'à son terme.
|
||||
const chargeDuGeste = charge;
|
||||
|
|
@ -392,29 +574,103 @@
|
|||
};
|
||||
|
||||
const surMouvement = (evenement) => {
|
||||
if (etatGeste.phase === 'repos' || evenement.pointerId !== etatGeste.pointeur) return;
|
||||
if (etatGeste.phase === 'repos') {
|
||||
// Le survol : une souris ou un stylet sans bouton enfoncé.
|
||||
if (evenement.pointerType !== 'touch' && evenement.buttons === 0) {
|
||||
const groupe = evenement.target.closest('[data-table]');
|
||||
survol = groupe === null ? null : Number(groupe.dataset.table);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (evenement.pointerId !== etatGeste.pointeur) return;
|
||||
enAttente = { x: evenement.clientX, y: evenement.clientY };
|
||||
if (image === null) image = requestAnimationFrame(livrer);
|
||||
};
|
||||
|
||||
const surRelache = (evenement) => {
|
||||
if (etatGeste.phase === 'repos' || evenement.pointerId !== etatGeste.pointeur) return;
|
||||
oublierPression();
|
||||
oublierImage();
|
||||
const point = { x: evenement.clientX, y: evenement.clientY };
|
||||
appliquer(geste(etatGeste, { type: 'relacher', pointeur: evenement.pointerId, point }, contexte));
|
||||
};
|
||||
|
||||
const surAnnulationPointeur = (evenement) => {
|
||||
oublierPression();
|
||||
if (etatGeste.phase !== 'repos' && evenement.pointerId === etatGeste.pointeur) annulerGeste('pointercancel');
|
||||
};
|
||||
|
||||
// La sortie du pointeur et la perte du focus arrivent aussi pendant
|
||||
// qu'un bloc démonte le plan — Chromium retire le focus d'un élément
|
||||
// qu'on détache — : l'état change hors du suivi des dépendances.
|
||||
const surSortie = () => {
|
||||
untrack(() => {
|
||||
survol = null;
|
||||
});
|
||||
};
|
||||
|
||||
// Le focus de la racine. Venu du clavier, il active la sélection, sinon
|
||||
// la première table, quand rien n'est actif, et amène l'élément actif
|
||||
// dans le cadre. Un appui, qui donne aussi le focus, n'écrit rien sous
|
||||
// la racine et ne déplace pas la vue : la première touche activera.
|
||||
const surFocus = () => {
|
||||
aLeFocus = true;
|
||||
auClavier = element.matches(':focus-visible');
|
||||
if (!auClavier || modele === null || modele.tables.length === 0) return;
|
||||
const existe = actif !== null && modele.tables.some(({ id }) => id === actif.table);
|
||||
if (!existe) {
|
||||
const place = selection ?? { table: modele.tables[0].id, siege: null };
|
||||
actif = { table: place.table, siege: place.siege ?? null };
|
||||
}
|
||||
if (etatGeste.phase === 'repos') montrer(actif);
|
||||
};
|
||||
const surPerteFocus = () => {
|
||||
untrack(() => {
|
||||
aLeFocus = false;
|
||||
});
|
||||
};
|
||||
|
||||
// Une touche sur la racine, hors d'un geste : Échap d'un geste en cours
|
||||
// passe d'abord par surTouche, en phase de capture.
|
||||
const surToucheClavier = (evenement) => {
|
||||
if (etatGeste.phase !== 'repos' || modele === null || evenement.altKey || evenement.ctrlKey || evenement.metaKey) return;
|
||||
auClavier = true;
|
||||
if (evenement.key === 'Escape') ui.choisir(null);
|
||||
const rendu = toucheDuPlan(actif, evenement.key, contexteClavier());
|
||||
if (rendu === null) return;
|
||||
evenement.preventDefault();
|
||||
actif = rendu.actif;
|
||||
const { effet } = rendu;
|
||||
if (effet === null) {
|
||||
montrer(actif);
|
||||
} else if (effet.type === 'selectionner') {
|
||||
ui.selectionner(effet.place);
|
||||
focaliserPanneau('place');
|
||||
} else if (effet.type === 'ouvrirTable') {
|
||||
ui.selectionner({ table: effet.table });
|
||||
focaliserPanneau('table');
|
||||
} else {
|
||||
ui.poserSurPlace(null);
|
||||
ui.selectionner(null);
|
||||
}
|
||||
};
|
||||
|
||||
const desabonner = seance.surAnnulation(() => annulerGeste('lecture'));
|
||||
element.addEventListener('pointerdown', surAppui);
|
||||
element.addEventListener('pointermove', surMouvement);
|
||||
element.addEventListener('pointerup', surRelache);
|
||||
element.addEventListener('pointercancel', surAnnulationPointeur);
|
||||
element.addEventListener('pointerleave', surSortie);
|
||||
element.addEventListener('focus', surFocus);
|
||||
element.addEventListener('blur', surPerteFocus);
|
||||
element.addEventListener('keydown', surToucheClavier);
|
||||
return () => {
|
||||
desabonner();
|
||||
oublierPression();
|
||||
element.removeEventListener('pointerleave', surSortie);
|
||||
element.removeEventListener('focus', surFocus);
|
||||
element.removeEventListener('blur', surPerteFocus);
|
||||
element.removeEventListener('keydown', surToucheClavier);
|
||||
element.removeEventListener('pointerdown', surAppui);
|
||||
element.removeEventListener('pointermove', surMouvement);
|
||||
element.removeEventListener('pointerup', surRelache);
|
||||
|
|
@ -440,7 +696,21 @@
|
|||
<BarreVue {tour} {tours} {zoomer} {ajuster} />
|
||||
<div class="surface" data-surface bind:this={surface}>
|
||||
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
|
||||
<svg class="dessin" data-plan aria-label={t('plan.vue.dessin')} bind:this={racine}>
|
||||
<!-- Le plan est un composant interactif à deux dimensions, que le
|
||||
clavier parcourt par aria-activedescendant : role="application",
|
||||
que le compilateur range parmi les rôles non interactifs. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<svg
|
||||
class="dessin"
|
||||
data-plan
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-roledescription={t('plan.vue.role')}
|
||||
aria-label={t('plan.vue.dessin')}
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-describedby={infobulleDuFocus ? idInfobulle : undefined}
|
||||
bind:this={racine}
|
||||
>
|
||||
<g data-vue transform={vue === null ? undefined : transformDe(vue)} bind:this={groupeVue}>
|
||||
{#if modele !== null}
|
||||
<PlanDessin {modele} />
|
||||
|
|
@ -449,8 +719,9 @@
|
|||
<Fantome {fantome} bind:element={elementFantome} />
|
||||
</svg>
|
||||
{#if poseAffichee !== null}
|
||||
<RefusPlace pose={poseAffichee} fermer={fermerPose} />
|
||||
<RefusPlace pose={poseAffichee} fermer={fermerPose} gestes={gestesPose} />
|
||||
{/if}
|
||||
<Infobulle {infobulle} 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}>
|
||||
|
|
@ -497,4 +768,16 @@
|
|||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Un focus venu du clavier se voit sur la racine, contour tracé en
|
||||
dedans : un contour ne change pas la boîte du cadre. L'élément actif
|
||||
porte en plus la marque focus. */
|
||||
.dessin:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dessin:focus-visible {
|
||||
outline: 3px solid var(--couleur-focus);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@
|
|||
// place le désigne par aria-describedby, vers id.
|
||||
//
|
||||
// pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y
|
||||
// en px de la surface ; fermer : retire la pose.
|
||||
// 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.
|
||||
import { getContext } from 'svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { libellePlace } from './gestes.js';
|
||||
|
||||
let { pose, fermer } = $props();
|
||||
let { pose, fermer, gestes = {} } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const libelle = $derived(libellePlace(pose));
|
||||
|
|
@ -28,7 +30,7 @@
|
|||
{t(libelle.cle, libelle.details)}
|
||||
</p>
|
||||
{#if pose.refus !== null}
|
||||
<Refus refus={pose.refus} lever={fermer} />
|
||||
<Refus refus={pose.refus} lever={fermer} {gestes} />
|
||||
{/if}
|
||||
{#each pose.avertissements as avertissement, rang (rang)}
|
||||
<p class="avertissement" role="status" data-avertissement={avertissement.code}>{t(avertissement.code, avertissement.details)}</p>
|
||||
|
|
|
|||
|
|
@ -2,21 +2,92 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La réserve du tour affiché sous sa forme minimale : son titre, tiré des
|
||||
// libellés. Le panneau lit 'seance' et 'ui' au contexte, sans propriété de
|
||||
// SectionPlan.
|
||||
// La réserve visible (§ 7.1, § 5.8) : les personnes en attente au tour
|
||||
// affiché, celles de l'état des places (EtatPlaces.reserve, A5) — avant
|
||||
// toute proposition, chaque personne qu'aucune réservation n'assied à ce
|
||||
// tour —, par identifiant croissant. Chacune est un bouton
|
||||
// [data-participant] qui en fait la personne choisie (ui.choix), puis
|
||||
// l'oublie ; aria-pressed dit la personne choisie. Aucun glisser ne part
|
||||
// d'une liste vers le plan : la place se choisit ensuite sur le plan. Un
|
||||
// changement de tour change la liste et oublie le choix (etat_ui.js) ;
|
||||
// Échap, sur la réserve ou l'un de ses boutons, l'oublie aussi.
|
||||
// Le panneau lit 'seance' et 'ui' au contexte, sans propriété.
|
||||
import { getContext } from 'svelte';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { nomDeCharge, tourAffiche } from './vue_plan.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
const uid = $props.id();
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
|
||||
const reserve = $derived(charge === null ? [] : etatPlaces(charge, { tour, proposition: $ui.proposition }).reserve);
|
||||
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
|
||||
const choix = $derived($ui.choix);
|
||||
const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id;
|
||||
|
||||
function basculer(id) {
|
||||
ui.choisir(choisie(id) ? null : { genre: 'personne', participant: id, depuis: null });
|
||||
}
|
||||
|
||||
function surTouche(evenement) {
|
||||
if (evenement.key !== 'Escape' || choix === null) return;
|
||||
evenement.preventDefault();
|
||||
ui.choisir(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h3>{t('plan.reserve.titre')}</h3>
|
||||
<!-- É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}>
|
||||
<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}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h3 {
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,17 +20,29 @@
|
|||
// Une table en conflit, quand le plan signale, porte un contour épaissi
|
||||
// (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa
|
||||
// pastille porte le nombre de violations, signalée ou non (§ 7.5).
|
||||
//
|
||||
// Le groupe de la table porte id="table-<id>", celui d'une chaise
|
||||
// id="siege-<table>-<siège>" : la racine du plan, seul arrêt de tabulation,
|
||||
// désigne l'élément actif par aria-activedescendant (§ 7.3, § 13.5). Une
|
||||
// chaise a pour nom accessible sa ligne entière (texteLigne). La place
|
||||
// sélectionnée porte la marque selection, un anneau en tirets ; l'élément
|
||||
// actif du clavier, la marque focus, un carré : deux formes, que la
|
||||
// couleur ne porte pas seule. Sur la chaise que le modèle désigne, ou sur
|
||||
// l'emprise de la table quand la place n'a pas de chaise dessinée.
|
||||
import { getContext } from 'svelte';
|
||||
import { RAYON_POIGNEE } from '../../geometrie/tables.js';
|
||||
import {
|
||||
CADENAS_FERME,
|
||||
CADENAS_OUVERT,
|
||||
ECART_ANNEAU,
|
||||
ECART_DESIGNATION,
|
||||
ECART_FOCUS,
|
||||
ECART_MISE_EN_EVIDENCE,
|
||||
RAYON_PASTILLE,
|
||||
TAILLE_TEXTE_PASTILLE_CM,
|
||||
TRACE_GEL,
|
||||
formesTable,
|
||||
texteLigne,
|
||||
} from './modele.js';
|
||||
|
||||
let { table, signaler, prefixe } = $props();
|
||||
|
|
@ -46,9 +58,17 @@
|
|||
const signal = $derived(signaler && table.conflit);
|
||||
const hachure = $derived(`url(#${prefixe}-hachure)`);
|
||||
const trame = $derived(`url(#${prefixe}-trame)`);
|
||||
// Les marques de désignation de la table, autour de l'emprise.
|
||||
const autour = $derived(formesTable({ ...table.geometrie, rayonEmprise: table.geometrie.rayonEmprise + ECART_DESIGNATION }));
|
||||
const cadreFocus = $derived(carreAutour(table.geometrie.centre, table.geometrie.rayonEmprise + ECART_FOCUS));
|
||||
|
||||
// Le carré de demi-côté demi centré sur un point, en attributs SVG.
|
||||
function carreAutour({ x, y }, demi) {
|
||||
return { x: x - demi, y: y - demi, width: 2 * demi, height: 2 * demi };
|
||||
}
|
||||
</script>
|
||||
|
||||
<g data-table={table.id} role="group" aria-label={t(table.nomAccessible.cle, table.nomAccessible.details)}>
|
||||
<g id="table-{table.id}" data-table={table.id} role="group" aria-label={t(table.nomAccessible.cle, table.nomAccessible.details)}>
|
||||
{#if table.regime === 'table'}
|
||||
{#if formes.ronde}
|
||||
<circle class="emprise" data-marque="contourChaise" {...formes.emprise} />
|
||||
|
|
@ -71,8 +91,23 @@
|
|||
{#if table.gelee}
|
||||
<path class="gel" data-marque="gel" d={TRACE_GEL} transform="translate({formes.gel.x},{formes.gel.y})" />
|
||||
{/if}
|
||||
{#if table.selection}
|
||||
{#if autour.ronde}
|
||||
<circle class="selection" data-marque="selection" {...autour.emprise} />
|
||||
{:else}
|
||||
<rect class="selection" data-marque="selection" {...autour.emprise} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if table.focus}
|
||||
<rect class="focus" data-marque="focus" {...cadreFocus} />
|
||||
{/if}
|
||||
{#each table.chaises as chaise (chaise.siege)}
|
||||
<g data-siege="{table.id}:{chaise.siege}">
|
||||
<g
|
||||
id="siege-{table.id}-{chaise.siege}"
|
||||
data-siege="{table.id}:{chaise.siege}"
|
||||
role="img"
|
||||
aria-label={texteLigne(table.lignes[chaise.siege - 1], t)}
|
||||
>
|
||||
<circle
|
||||
class="chaise"
|
||||
class:pleine={chaise.remplissage === 'plein'}
|
||||
|
|
@ -92,6 +127,12 @@
|
|||
{#if chaise.miseEnEvidence}
|
||||
<circle class="evidence" data-marque="miseEnEvidence" cx={chaise.x} cy={chaise.y} r={chaise.rayon + ECART_MISE_EN_EVIDENCE} />
|
||||
{/if}
|
||||
{#if chaise.selection}
|
||||
<circle class="selection" data-marque="selection" cx={chaise.x} cy={chaise.y} r={chaise.rayon + ECART_DESIGNATION} />
|
||||
{/if}
|
||||
{#if chaise.focus}
|
||||
<rect class="focus" data-marque="focus" {...carreAutour(chaise, chaise.rayon + ECART_FOCUS)} />
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
|
|
@ -172,6 +213,25 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* La sélection : un anneau en tirets ; le focus du clavier : un carré
|
||||
plein trait. Les deux gardent leur épaisseur à l'écran. */
|
||||
.selection,
|
||||
.focus {
|
||||
fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-width: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selection {
|
||||
stroke: var(--couleur-selection);
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
|
||||
.focus {
|
||||
stroke: var(--couleur-focus);
|
||||
}
|
||||
|
||||
.poignee {
|
||||
fill: var(--couleur-poignee);
|
||||
stroke: var(--couleur-poignee);
|
||||
|
|
|
|||
152
src/interface/plan/clavier.js
Normal file
152
src/interface/plan/clavier.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La navigation au clavier du plan (§ 7.3, § 13.5), pure : le plan est un
|
||||
// seul arrêt de tabulation, et l'élément actif — une table, ou un siège
|
||||
// d'une table, {table, siege|null} — se déplace par les flèches, selon la
|
||||
// position des tables sur le plan ; l'entrée descend dans les sièges,
|
||||
// l'échappement remonte. Aucune fonction ne lit le DOM : Plan.svelte
|
||||
// applique l'élément actif rendu et l'effet qui l'accompagne.
|
||||
import { ecranDepuisDessin } from '../../geometrie/vue.js';
|
||||
|
||||
/** Les directions des flèches, sur l'écran (y vers le bas). */
|
||||
const VECTEURS = new Map([
|
||||
['droite', { x: 1, y: 0 }],
|
||||
['gauche', { x: -1, y: 0 }],
|
||||
['haut', { x: 0, y: -1 }],
|
||||
['bas', { x: 0, y: 1 }],
|
||||
]);
|
||||
|
||||
/** La direction de chaque flèche, et le sens où elle fait tourner les sièges. */
|
||||
const FLECHES = new Map([
|
||||
['ArrowRight', { direction: 'droite', sens: 1 }],
|
||||
['ArrowDown', { direction: 'bas', sens: 1 }],
|
||||
['ArrowLeft', { direction: 'gauche', sens: -1 }],
|
||||
['ArrowUp', { direction: 'haut', sens: -1 }],
|
||||
]);
|
||||
|
||||
const ENTREES = new Set(['Enter', ' ']);
|
||||
|
||||
/**
|
||||
* La table voisine dans la direction d'une flèche : la plus proche, par la
|
||||
* distance des centres, de celles dont le centre est dans le cône de ±45°
|
||||
* autour de la direction — bords compris ; à égalité de distance, le plus
|
||||
* petit numéro ; aucune → la même.
|
||||
*
|
||||
* @param {Array<{id: number, numero: number, centre: {x: number, y: number}}>} geometries
|
||||
* @param {number} depuis identifiant de la table de départ
|
||||
* @param {'droite'|'gauche'|'haut'|'bas'} direction
|
||||
* @returns {number} identifiant
|
||||
*/
|
||||
export function tableVoisine(geometries, depuis, direction) {
|
||||
const vecteur = VECTEURS.get(direction);
|
||||
if (vecteur === undefined) throw new RangeError(`direction inconnue : ${JSON.stringify(direction)}`);
|
||||
const origine = geometries.find(({ id }) => id === depuis);
|
||||
if (origine === undefined) return depuis;
|
||||
let meilleure = null;
|
||||
for (const candidate of geometries) {
|
||||
if (candidate.id === depuis) continue;
|
||||
const dx = candidate.centre.x - origine.centre.x;
|
||||
const dy = candidate.centre.y - origine.centre.y;
|
||||
// Composante le long de la direction et composante transverse : le cône
|
||||
// de ±45° est l'ensemble où la première est positive et au moins égale
|
||||
// à la valeur absolue de la seconde.
|
||||
const long = dx * vecteur.x + dy * vecteur.y;
|
||||
const travers = Math.abs(dx * vecteur.y - dy * vecteur.x);
|
||||
if (long <= 0 || long < travers) continue;
|
||||
const distance = dx * dx + dy * dy;
|
||||
if (
|
||||
meilleure === null ||
|
||||
distance < meilleure.distance ||
|
||||
(distance === meilleure.distance && candidate.numero < meilleure.numero)
|
||||
) {
|
||||
meilleure = { id: candidate.id, numero: candidate.numero, distance };
|
||||
}
|
||||
}
|
||||
return meilleure === null ? depuis : meilleure.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le siège suivant (sens +1) ou précédent (sens −1), dans l'ordre des
|
||||
* numéros de 1 à capacite, circulairement.
|
||||
*
|
||||
* @param {number} capacite @param {number} siege @param {1|-1} sens
|
||||
* @returns {number}
|
||||
*/
|
||||
export function siegeVoisin(capacite, siege, sens) {
|
||||
return ((((siege - 1 + sens) % capacite) + capacite) % capacite) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que fait une touche sur l'élément actif du plan (§ 7.3).
|
||||
*
|
||||
* Sans élément actif, ou quand sa table n'existe plus, une flèche ou
|
||||
* l'entrée active la première table des géométries. Sur une table, une
|
||||
* flèche mène à tableVoisine ; l'entrée — Enter ou l'espace — descend au
|
||||
* siège 1 en régime sièges, et en régime table, où aucune chaise n'est
|
||||
* dessinée, rend l'effet ouvrirTable : le siège se choisit dans le panneau
|
||||
* de la table. Sur un siège, les flèches droite et bas mènent au suivant,
|
||||
* gauche et haut au précédent ; l'entrée rend l'effet selectionner de la
|
||||
* place. L'échappement remonte du siège à sa table, puis, sur une table,
|
||||
* rend l'effet remonter : le choix en attente et la sélection s'oublient.
|
||||
* Un siège d'une table passée en régime table se lit comme sa table.
|
||||
*
|
||||
* @param {{table: number, siege: number|null}|null} actif
|
||||
* @param {string} cle KeyboardEvent.key
|
||||
* @param {{geometries: Array<{id: number, numero: number, centre: Object}>,
|
||||
* regimeDe: (id: number) => 'sieges'|'table',
|
||||
* capaciteDe: (id: number) => number}} contexte
|
||||
* @returns {null|{actif: {table: number, siege: number|null},
|
||||
* effet: null|{type: 'ouvrirTable', table: number}|{type: 'selectionner', place: Object}|{type: 'remonter'}}}
|
||||
* null pour une touche que le plan ne prend pas, ou sans table
|
||||
*/
|
||||
export function toucheDuPlan(actif, cle, { geometries, regimeDe, capaciteDe }) {
|
||||
const fleche = FLECHES.get(cle);
|
||||
const entree = ENTREES.has(cle);
|
||||
const echap = cle === 'Escape';
|
||||
if (fleche === undefined && !entree && !echap) return null;
|
||||
if (geometries.length === 0) return null;
|
||||
|
||||
const existe = actif !== null && geometries.some(({ id }) => id === actif.table);
|
||||
if (!existe) {
|
||||
if (echap) return null;
|
||||
return { actif: { table: geometries[0].id, siege: null }, effet: null };
|
||||
}
|
||||
const { table } = actif;
|
||||
const siege = actif.siege !== null && regimeDe(table) === 'sieges' ? actif.siege : null;
|
||||
|
||||
if (siege === null) {
|
||||
if (fleche !== undefined) return { actif: { table: tableVoisine(geometries, table, fleche.direction), siege: null }, effet: null };
|
||||
if (echap) return { actif: { table, siege: null }, effet: { type: 'remonter' } };
|
||||
if (regimeDe(table) === 'sieges') return { actif: { table, siege: 1 }, effet: null };
|
||||
return { actif: { table, siege: null }, effet: { type: 'ouvrirTable', table } };
|
||||
}
|
||||
if (fleche !== undefined) return { actif: { table, siege: siegeVoisin(capaciteDe(table), siege, fleche.sens) }, effet: null };
|
||||
if (echap) return { actif: { table, siege: null }, effet: null };
|
||||
return { actif: { table, siege }, effet: { type: 'selectionner', place: { table, siege } } };
|
||||
}
|
||||
|
||||
/** Marge qui garde une place à l'intérieur du cadre, en px d'écran. */
|
||||
export const MARGE_MONTRER_PX = 24;
|
||||
|
||||
/**
|
||||
* La vue qui amène un point du dessin dans le cadre, k inchangé : la même
|
||||
* vue quand le point y est déjà, à MARGE_MONTRER_PX du bord au moins ; sinon
|
||||
* la vue translatée qui pose le point au centre du cadre. Sans vue, ou dans
|
||||
* un cadre trop petit pour sa marge, la vue reçue.
|
||||
*
|
||||
* @param {{k: number, tx: number, ty: number}|null} vue
|
||||
* @param {{gauche: number, haut: number, largeur: number, hauteur: number}} cadre
|
||||
* @param {{x: number, y: number}} point cm
|
||||
* @returns {{k: number, tx: number, ty: number}|null}
|
||||
*/
|
||||
export function vueQuiMontre(vue, cadre, point) {
|
||||
if (vue === null || cadre.largeur <= 2 * MARGE_MONTRER_PX || cadre.hauteur <= 2 * MARGE_MONTRER_PX) return vue;
|
||||
const ecran = ecranDepuisDessin(vue, cadre, point.x, point.y);
|
||||
const x = ecran.x - cadre.gauche;
|
||||
const y = ecran.y - cadre.haut;
|
||||
const dedans =
|
||||
x >= MARGE_MONTRER_PX && x <= cadre.largeur - MARGE_MONTRER_PX && y >= MARGE_MONTRER_PX && y <= cadre.hauteur - MARGE_MONTRER_PX;
|
||||
if (dedans) return vue;
|
||||
return { k: vue.k, tx: cadre.largeur / 2 - point.x * vue.k, ty: cadre.hauteur / 2 - point.y * vue.k };
|
||||
}
|
||||
158
src/interface/plan/clavier.test.js
Normal file
158
src/interface/plan/clavier.test.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves de la navigation au clavier du plan (§ 7.3, § 13.5), sans DOM :
|
||||
// la table voisine dans la direction d'une flèche, le siège voisin, ce que
|
||||
// fait une touche sur l'élément actif, et la vue qui amène une place dans
|
||||
// le cadre.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { ecranDepuisDessin } from '../../geometrie/vue.js';
|
||||
import { siegeVoisin, tableVoisine, toucheDuPlan, vueQuiMontre } from './clavier.js';
|
||||
|
||||
// Une grille de 3 × 3 tables, au pas de 300 cm, numérotées ligne par ligne :
|
||||
// 1 2 3 / 4 5 6 / 7 8 9 ; l'identifiant vaut dix fois le numéro.
|
||||
const GRILLE = Array.from({ length: 9 }, (_, rang) => ({
|
||||
id: 10 * (rang + 1),
|
||||
numero: rang + 1,
|
||||
centre: { x: 300 * (rang % 3), y: 300 * Math.floor(rang / 3) },
|
||||
}));
|
||||
|
||||
const idDuNumero = (numero) => 10 * numero;
|
||||
|
||||
describe('tableVoisine : la plus proche dans le cône de la flèche (§ 7.3)', () => {
|
||||
test('depuis le centre de la grille : chaque flèche mène à la voisine de son côté', () => {
|
||||
assert.equal(GRILLE.length, 9);
|
||||
const centre = idDuNumero(5);
|
||||
assert.equal(tableVoisine(GRILLE, centre, 'droite'), idDuNumero(6));
|
||||
assert.equal(tableVoisine(GRILLE, centre, 'gauche'), idDuNumero(4));
|
||||
assert.equal(tableVoisine(GRILLE, centre, 'haut'), idDuNumero(2));
|
||||
assert.equal(tableVoisine(GRILLE, centre, 'bas'), idDuNumero(8));
|
||||
});
|
||||
|
||||
test('au bord, aucune table dans le cône : la même', () => {
|
||||
assert.equal(tableVoisine(GRILLE, idDuNumero(6), 'droite'), idDuNumero(6));
|
||||
assert.equal(tableVoisine(GRILLE, idDuNumero(1), 'haut'), idDuNumero(1));
|
||||
assert.equal(tableVoisine(GRILLE, idDuNumero(7), 'gauche'), idDuNumero(7));
|
||||
assert.equal(tableVoisine(GRILLE, idDuNumero(9), 'bas'), idDuNumero(9));
|
||||
});
|
||||
|
||||
test('égalité de distance dans le cône : le plus petit numéro l’emporte, quel que soit l’ordre des géométries', () => {
|
||||
// Depuis la table 1, vers la droite et en diagonale à 45° : (300, 300)
|
||||
// et (300, −300) sont à égale distance et sur les bords du cône.
|
||||
const tables = [
|
||||
{ id: 1, numero: 1, centre: { x: 0, y: 0 } },
|
||||
{ id: 2, numero: 7, centre: { x: 300, y: 300 } },
|
||||
{ id: 3, numero: 4, centre: { x: 300, y: -300 } },
|
||||
];
|
||||
assert.equal(tableVoisine(tables, 1, 'droite'), 3);
|
||||
assert.equal(tableVoisine([...tables].reverse(), 1, 'droite'), 3);
|
||||
});
|
||||
|
||||
test('hors du cône, même plus proche, une table n’est pas voisine', () => {
|
||||
const tables = [
|
||||
{ id: 1, numero: 1, centre: { x: 0, y: 0 } },
|
||||
{ id: 2, numero: 2, centre: { x: 100, y: 150 } },
|
||||
{ id: 3, numero: 3, centre: { x: 900, y: 0 } },
|
||||
];
|
||||
assert.equal(tableVoisine(tables, 1, 'droite'), 3);
|
||||
});
|
||||
|
||||
test('une direction inconnue lève', () => {
|
||||
assert.throws(() => tableVoisine(GRILLE, 50, 'nord'), /direction/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('siegeVoisin : l’ordre des numéros, circulairement', () => {
|
||||
test('suivant et précédent ; 8 → 1 et 1 → 8 sur une table de 8', () => {
|
||||
assert.equal(siegeVoisin(8, 8, +1), 1);
|
||||
assert.equal(siegeVoisin(8, 1, -1), 8);
|
||||
assert.equal(siegeVoisin(8, 3, +1), 4);
|
||||
assert.equal(siegeVoisin(8, 3, -1), 2);
|
||||
assert.equal(siegeVoisin(2, 2, +1), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toucheDuPlan : ce que fait une touche sur l’élément actif (§ 7.3)', () => {
|
||||
const regimes = new Map([[10, 'sieges'], [20, 'table']]);
|
||||
const contexte = {
|
||||
geometries: [
|
||||
{ id: 10, numero: 1, centre: { x: 0, y: 0 } },
|
||||
{ id: 20, numero: 2, centre: { x: 300, y: 0 } },
|
||||
],
|
||||
regimeDe: (id) => regimes.get(id),
|
||||
capaciteDe: () => 6,
|
||||
};
|
||||
|
||||
test('sans élément actif, une flèche ou l’entrée active la première table ; une autre touche ne fait rien', () => {
|
||||
assert.deepEqual(toucheDuPlan(null, 'ArrowRight', contexte), { actif: { table: 10, siege: null }, effet: null });
|
||||
assert.deepEqual(toucheDuPlan(null, 'Enter', contexte), { actif: { table: 10, siege: null }, effet: null });
|
||||
assert.equal(toucheDuPlan(null, 'a', contexte), null);
|
||||
assert.equal(toucheDuPlan(null, 'ArrowRight', { ...contexte, geometries: [] }), null);
|
||||
});
|
||||
|
||||
test('une flèche sur une table mène à sa voisine ; sur un siège, au siège voisin', () => {
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, 'ArrowRight', contexte), { actif: { table: 20, siege: null }, effet: null });
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: 6 }, 'ArrowRight', contexte), { actif: { table: 10, siege: 1 }, effet: null });
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: 1 }, 'ArrowUp', contexte), { actif: { table: 10, siege: 6 }, effet: null });
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: 2 }, 'ArrowDown', contexte), { actif: { table: 10, siege: 3 }, effet: null });
|
||||
});
|
||||
|
||||
test('l’entrée descend dans les sièges en régime sièges ; en régime table, ouvre le panneau de la table', () => {
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, 'Enter', contexte), { actif: { table: 10, siege: 1 }, effet: null });
|
||||
assert.deepEqual(toucheDuPlan({ table: 20, siege: null }, 'Enter', contexte), {
|
||||
actif: { table: 20, siege: null },
|
||||
effet: { type: 'ouvrirTable', table: 20 },
|
||||
});
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, ' ', contexte), { actif: { table: 10, siege: 1 }, effet: null });
|
||||
});
|
||||
|
||||
test('l’entrée sur un siège le sélectionne ; l’échappement remonte à la table, puis oublie', () => {
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: 4 }, 'Enter', contexte), {
|
||||
actif: { table: 10, siege: 4 },
|
||||
effet: { type: 'selectionner', place: { table: 10, siege: 4 } },
|
||||
});
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: 4 }, 'Escape', contexte), { actif: { table: 10, siege: null }, effet: null });
|
||||
assert.deepEqual(toucheDuPlan({ table: 10, siege: null }, 'Escape', contexte), {
|
||||
actif: { table: 10, siege: null },
|
||||
effet: { type: 'remonter' },
|
||||
});
|
||||
});
|
||||
|
||||
test('un siège d’une table passée en régime table se lit comme sa table', () => {
|
||||
assert.deepEqual(toucheDuPlan({ table: 20, siege: 3 }, 'ArrowLeft', contexte), { actif: { table: 10, siege: null }, effet: null });
|
||||
assert.deepEqual(toucheDuPlan({ table: 20, siege: 3 }, 'Enter', contexte), {
|
||||
actif: { table: 20, siege: null },
|
||||
effet: { type: 'ouvrirTable', table: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
test('un élément actif dont la table n’existe plus repart de la première', () => {
|
||||
assert.deepEqual(toucheDuPlan({ table: 99, siege: 2 }, 'ArrowRight', contexte), { actif: { table: 10, siege: null }, effet: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('vueQuiMontre : la place dans le cadre, sans changer k (§ 5.8)', () => {
|
||||
const cadre = { gauche: 40, haut: 30, largeur: 800, hauteur: 600 };
|
||||
const vue = { k: 0.5, tx: 100, ty: 50 };
|
||||
|
||||
test('un point déjà dans le cadre, marge comprise : la même vue', () => {
|
||||
assert.equal(vueQuiMontre(vue, cadre, { x: 200, y: 200 }), vue);
|
||||
});
|
||||
|
||||
test('un point hors du cadre : k inchangé, le point au centre du cadre', () => {
|
||||
for (const point of [{ x: 3000, y: 100 }, { x: -900, y: 100 }, { x: 100, y: 5000 }, { x: 100, y: -400 }]) {
|
||||
const montree = vueQuiMontre(vue, cadre, point);
|
||||
assert.equal(montree.k, vue.k);
|
||||
const ecran = ecranDepuisDessin(montree, cadre, point.x, point.y);
|
||||
assert.equal(ecran.x, cadre.gauche + cadre.largeur / 2);
|
||||
assert.equal(ecran.y, cadre.haut + cadre.hauteur / 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('sans vue, ou dans un cadre nul, rien ne change', () => {
|
||||
assert.equal(vueQuiMontre(null, cadre, { x: 0, y: 0 }), null);
|
||||
const nul = { gauche: 0, haut: 0, largeur: 0, hauteur: 0 };
|
||||
assert.equal(vueQuiMontre(vue, nul, { x: 9000, y: 0 }), vue);
|
||||
});
|
||||
});
|
||||
|
|
@ -25,6 +25,10 @@ export const TAILLE_TEXTE_PASTILLE_CM = 18;
|
|||
/** Écart entre le bord d'une chaise et son anneau, ou sa mise en évidence, en cm. */
|
||||
export const ECART_ANNEAU = 6;
|
||||
export const ECART_MISE_EN_EVIDENCE = 12;
|
||||
/** Écart entre le bord d'une chaise, ou d'une emprise, et la marque de
|
||||
* sélection (anneau en tirets), puis le carré du focus, en cm. */
|
||||
export const ECART_DESIGNATION = 18;
|
||||
export const ECART_FOCUS = 26;
|
||||
|
||||
// Les cadenas d'une chaise (§ 7.6), en cm, centrés sur l'origine : un corps
|
||||
// plein et une anse, fermée ou ouverte, remplis d'un seul tenant. Le dessin
|
||||
|
|
@ -141,6 +145,22 @@ export function textesLigne({ numero, etat, titulaire = null, nom, titre }, t) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* La ligne entière en un seul texte : les parties de textesLigne bout à
|
||||
* bout, dans l'ordre de la lecture. C'est le nom accessible d'un siège, que
|
||||
* le plan annonce quand il devient l'élément actif.
|
||||
*
|
||||
* @param {ReturnType<typeof ligneListe>} ligne
|
||||
* @param {(cle: string, details?: Object) => string} t
|
||||
* @returns {string}
|
||||
*/
|
||||
export function texteLigne(ligne, t) {
|
||||
const textes = textesLigne(ligne, t);
|
||||
return ['tete', 'titulaire', 'suite', 'nom', 'avantTitre', 'titre', 'apresTitre']
|
||||
.map((partie) => textes[partie] ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* La ligne qui tient dans largeur (§ 7.4) : le nom s'abrège, suivi de la
|
||||
* marque, à la largeur que lui laissent les autres parties ; quand la
|
||||
|
|
@ -274,14 +294,26 @@ function chaisesA(etatTable, places) {
|
|||
return Array.from({ length: places }, (_, i) => etatTable.chaises[i] ?? chaiseLibre(i + 1));
|
||||
}
|
||||
|
||||
// Le porteur d'une place {table, siege|null} dans une table dessinée : le
|
||||
// siège quand la place en nomme un que le régime sièges dessine, 'table'
|
||||
// quand elle n'en nomme pas ou que le régime table n'en dessine aucun, null
|
||||
// pour une place d'une autre table.
|
||||
function porteurDe(place, id, regime) {
|
||||
if (place === null || place === undefined || place.table !== id) return null;
|
||||
const siege = place.siege ?? null;
|
||||
return siege === null || regime === 'table' ? 'table' : siege;
|
||||
}
|
||||
|
||||
// Le dessin d'une table de l'état des places au zoom k ; places, le nombre
|
||||
// de sièges que la poignée montre, sinon sa capacité.
|
||||
function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable.capacite) {
|
||||
function tableDessin(etatTable, { k, miseEnEvidence, nomDe, selection = null, actif = null }, places = etatTable.capacite) {
|
||||
const { id, numero, gelee, surchargee, conflits } = etatTable;
|
||||
const geometrie = geometrieTable(etatTable, places);
|
||||
const regime = regimeCibles(k, geometrie);
|
||||
const pastille = conflits === null ? 0 : conflits.nombre;
|
||||
const etatChaises = chaisesA(etatTable, places);
|
||||
const selectionnee = porteurDe(selection, id, regime);
|
||||
const active = porteurDe(actif, id, regime);
|
||||
const chaises =
|
||||
regime === 'table'
|
||||
? []
|
||||
|
|
@ -295,6 +327,8 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable
|
|||
remplissage: chaise.remplissage,
|
||||
marques: [...chaise.marques],
|
||||
miseEnEvidence: miseEnEvidence !== null && chaise.occupant === miseEnEvidence,
|
||||
selection: selectionnee === chaise.siege,
|
||||
focus: active === chaise.siege,
|
||||
};
|
||||
});
|
||||
return {
|
||||
|
|
@ -303,6 +337,8 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable
|
|||
regime,
|
||||
conflit: pastille > 0,
|
||||
pastille,
|
||||
selection: selectionnee === 'table',
|
||||
focus: active === 'table',
|
||||
gelee,
|
||||
surchargee,
|
||||
nomAccessible: {
|
||||
|
|
@ -330,6 +366,12 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable
|
|||
* bloc de noms n'est pas dans le modèle : c'est LARGEUR_BLOC_NOMS_CM du
|
||||
* registre, que le dessin lit.
|
||||
*
|
||||
* La place sélectionnée (option selection) et l'élément actif du clavier
|
||||
* (option actif), {table, siege|null} ou null, marquent leur chaise —
|
||||
* drapeaux selection et focus d'une chaise — quand la place nomme un siège
|
||||
* que le régime sièges dessine, leur table sinon — drapeaux selection et
|
||||
* focus d'une table — ; tout autre porteur garde ses drapeaux faux.
|
||||
*
|
||||
* Pendant le tirage d'une poignée (option poignee), la table qu'elle désigne
|
||||
* se dessine à places sièges — géométrie, régime, chaises, lignes et nom
|
||||
* accessible —, ses chaises 1 à places, celles au-delà de sa capacité
|
||||
|
|
@ -338,13 +380,22 @@ function tableDessin(etatTable, { k, miseEnEvidence, nomDe }, places = etatTable
|
|||
* @param {Object} etatPlaces EtatPlaces (A5)
|
||||
* @param {{k: number, listes: boolean, miseEnEvidence: number|null,
|
||||
* nomDe: (id: number) => string, diagnostic?: Object|null,
|
||||
* poignee?: {table: number, places: number}|null}} options
|
||||
* poignee?: {table: number, places: number}|null,
|
||||
* selection?: {table: number, siege?: number|null}|null,
|
||||
* actif?: {table: number, siege: number|null}|null}} options
|
||||
* diagnostic : celui que rend diagnostiquer, compléments de M1 compris
|
||||
* @returns {Object} ModeleDessin
|
||||
*/
|
||||
export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, diagnostic = null, poignee = null }) {
|
||||
export function modeleDessin(
|
||||
etatPlaces,
|
||||
{ k, listes, miseEnEvidence, nomDe, diagnostic = null, poignee = null, selection = null, actif = null },
|
||||
) {
|
||||
const tables = etatPlaces.tables.map((etatTable) =>
|
||||
tableDessin(etatTable, { k, miseEnEvidence, nomDe }, poignee !== null && poignee.table === etatTable.id ? poignee.places : etatTable.capacite),
|
||||
tableDessin(
|
||||
etatTable,
|
||||
{ k, miseEnEvidence, nomDe, selection, actif },
|
||||
poignee !== null && poignee.table === etatTable.id ? poignee.places : etatTable.capacite,
|
||||
),
|
||||
);
|
||||
const toutesEnConflit = tables.length > 0 && tables.every(({ conflit }) => conflit);
|
||||
return {
|
||||
|
|
@ -354,3 +405,121 @@ export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, dia
|
|||
listesRendues: listes && k >= constante('K_LISTES'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Le détail des conflits d'une table au tour montré (§ 7.1, § 7.3), que
|
||||
* l'infobulle énonce : son numéro, le nombre de violations, et chaque règle
|
||||
* violée dans l'ordre du moteur (conflitsParTable, M1), sa clé de libellé
|
||||
* plan.infobulle.<règle> et ses groupes nommés — une paire de noms pour
|
||||
* séparer les appartenances et nouveaux voisins, un nom seul pour nouvelle
|
||||
* table. Les noms sont des données : l'infobulle pose chacun dans son nœud.
|
||||
* null quand les conflits ne s'évaluent pas ou qu'il n'y en a aucun.
|
||||
*
|
||||
* @param {Object} etatTable EtatTable (A5)
|
||||
* @param {(id: number) => string} nomDe
|
||||
* @returns {null|{numero: number, nombre: number, regles: Array<{cle: string, groupes: string[][]}>}}
|
||||
*/
|
||||
export function detailConflits({ numero, conflits }, nomDe) {
|
||||
if (conflits === null || conflits.nombre === 0) return null;
|
||||
return {
|
||||
numero,
|
||||
nombre: conflits.nombre,
|
||||
regles: conflits.regles.map((regle) => ({
|
||||
cle: `plan.infobulle.${regle.regle}`,
|
||||
groupes: (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Les actions qu'offre le panneau d'une place (§ 7.1, § 2.1 étape 6), dans
|
||||
* l'ordre de l'affichage : sur une place qu'aucune réservation ne tient,
|
||||
* assigner, réserver et désigner, qui portent sur la personne choisie ;
|
||||
* assigner aussi sur une place tenue quand une personne est choisie — le
|
||||
* dépôt du menu, que la pose refuse sur la place comme un glissement ;
|
||||
* donner un titre sur une place sans titre ; retirer la réservation et
|
||||
* retirer le titre sur celles qui en portent ; déplacer la réservation ici
|
||||
* quand le choix en attente est une réservation.
|
||||
*
|
||||
* @param {{reservation: Object|null, titre: Object|null}} chaise EtatChaise
|
||||
* @param {Object|null} choix EtatUi.choix
|
||||
* @returns {string[]} parmi assigner, reserver, donnerTitre, designer,
|
||||
* retirerReservation, retirerTitre, deplacerReservationIci
|
||||
*/
|
||||
export function actionsPlace({ reservation, titre }, choix) {
|
||||
const libre = reservation === null;
|
||||
const personne = choix !== null && choix.genre === 'personne';
|
||||
return [
|
||||
['assigner', libre || personne],
|
||||
['reserver', libre],
|
||||
['donnerTitre', titre === null],
|
||||
['designer', libre],
|
||||
['retirerReservation', !libre],
|
||||
['retirerTitre', titre !== null],
|
||||
['deplacerReservationIci', choix !== null && choix.genre === 'reservation'],
|
||||
]
|
||||
.filter(([, offerte]) => offerte)
|
||||
.map(([action]) => action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le menu d'une personne (§ 7.1), dans l'ordre de l'affichage : déplacer —
|
||||
* le choix en attente —, toujours ; échanger quand elle est assise au tour
|
||||
* montré ; retirer du tour quand le dépôt sur le sol rend une commande ou un
|
||||
* refus (retrait non null) ; réserver ici quand elle est assise sur une
|
||||
* place qu'aucune réservation ne tient ; déplacer sa réservation quand la
|
||||
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; ouvrir la
|
||||
* fiche.
|
||||
*
|
||||
* @param {{exclu: boolean, chaise: Object|null, retrait: Object|null}} personne
|
||||
* chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin
|
||||
* @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci,
|
||||
* deplacerReservation, exclure, reintegrer, ouvrirFiche
|
||||
*/
|
||||
export function actionsPersonne({ exclu, chaise, retrait }) {
|
||||
const assise = chaise !== null;
|
||||
const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant;
|
||||
return [
|
||||
['deplacer', true],
|
||||
['echanger', assise],
|
||||
['retirerDuTour', retrait !== null],
|
||||
['reserverIci', assise && !exclu && chaise.reservation === null],
|
||||
['deplacerReservation', tenue],
|
||||
['exclure', !exclu],
|
||||
['reintegrer', exclu],
|
||||
['ouvrirFiche', true],
|
||||
]
|
||||
.filter(([, offerte]) => offerte)
|
||||
.map(([action]) => action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute la commande d'un panneau du plan — panneau d'une place, d'une
|
||||
* table, menu d'une personne — et pose son refus sur la place, là où le
|
||||
* geste a lieu (§ 5.9) : la même place qu'un glissement refusé, que
|
||||
* Plan.svelte montre par ui.pose. Réussie, la commande oublie le choix en
|
||||
* attente et retire la pose ; elle rend ce que rend la séance. Refusée — une
|
||||
* erreur qui porte un code —, le refus se pose, et null est rendu. Un code de
|
||||
* locaux, que l'appelant montre à côté de son champ, une place null et une
|
||||
* erreur sans code remontent.
|
||||
*
|
||||
* @param {{seance: {executer: Function}, ui: {choisir: Function, poserSurPlace: Function}}} dependances
|
||||
* @param {{table: number, siege?: number|null}|null} place
|
||||
* @param {string} commande
|
||||
* @param {Object} args
|
||||
* @param {{locaux?: string[]}} [options]
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function executerSurPlace({ seance, ui }, place, commande, args, { locaux = [] } = {}) {
|
||||
let rendu;
|
||||
try {
|
||||
rendu = await seance.executer(commande, args);
|
||||
} catch (erreur) {
|
||||
if (typeof erreur?.code !== 'string' || place === null || locaux.includes(erreur.code)) throw erreur;
|
||||
ui.poserSurPlace({ place, refus: { code: erreur.code, details: erreur.details ?? {} }, avertissements: [] });
|
||||
return null;
|
||||
}
|
||||
ui.choisir(null);
|
||||
ui.poserSurPlace(null);
|
||||
return rendu;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,18 @@ import { planDepuisFichier, versFichier } from '../../stockage/placements.js';
|
|||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
LARGEUR_ECHELLE_PX,
|
||||
actionsPersonne,
|
||||
actionsPlace,
|
||||
ajusterLigne,
|
||||
detailConflits,
|
||||
executerSurPlace,
|
||||
formesTable,
|
||||
libelleLongueur,
|
||||
ligneListe,
|
||||
modeleDessin,
|
||||
ordonneesLignes,
|
||||
partiesLigne,
|
||||
texteLigne,
|
||||
textesLigne,
|
||||
} from './modele.js';
|
||||
|
||||
|
|
@ -775,3 +780,238 @@ describe('modeleDessin : l\u{2019}option poignee dessine une table au nombre tir
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- La sélection et l'élément actif (I11) ---------------------------------------
|
||||
|
||||
describe('modeleDessin : les options selection et actif marquent leur chaise, ou leur table, et elle seule (§ 7.1, § 7.3)', () => {
|
||||
// Les porteurs d'une marque : 'table:<id>' ou 'siege:<id>:<siège>'.
|
||||
function porteurs(modele, marque) {
|
||||
const trouves = [];
|
||||
for (const table of modele.tables) {
|
||||
if (table[marque]) trouves.push(`table:${table.id}`);
|
||||
for (const chaise of table.chaises) if (chaise[marque]) trouves.push(`siege:${table.id}:${chaise.siege}`);
|
||||
}
|
||||
return trouves;
|
||||
}
|
||||
|
||||
test('sans option, ni sélection ni focus ; chaque table et chaque chaise porte les deux drapeaux, faux', () => {
|
||||
const charge = petite();
|
||||
const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge));
|
||||
assert.equal(modele.tables.length, 4);
|
||||
let chaises = 0;
|
||||
for (const table of modele.tables) {
|
||||
assert.equal(table.selection, false);
|
||||
assert.equal(table.focus, false);
|
||||
for (const chaise of table.chaises) {
|
||||
chaises += 1;
|
||||
assert.equal(chaise.selection, false);
|
||||
assert.equal(chaise.focus, false);
|
||||
}
|
||||
}
|
||||
assert.ok(chaises > 0);
|
||||
});
|
||||
|
||||
test('régime sièges : un siège marque sa chaise, une place sans siège marque sa table', () => {
|
||||
const charge = petite();
|
||||
const etat = etatPlaces(charge, { tour: 1 });
|
||||
const [, deuxieme, troisieme] = etat.tables;
|
||||
const modele = modeleDessin(etat, options(charge, { selection: { table: deuxieme.id, siege: 2 }, actif: { table: troisieme.id, siege: null } }));
|
||||
assert.equal(modele.tables[1].regime, 'sieges');
|
||||
assert.deepEqual(porteurs(modele, 'selection'), [`siege:${deuxieme.id}:2`]);
|
||||
assert.deepEqual(porteurs(modele, 'focus'), [`table:${troisieme.id}`]);
|
||||
const memePlace = modeleDessin(etat, options(charge, { selection: { table: deuxieme.id, siege: 1 }, actif: { table: deuxieme.id, siege: 1 } }));
|
||||
assert.deepEqual(porteurs(memePlace, 'selection'), [`siege:${deuxieme.id}:1`]);
|
||||
assert.deepEqual(porteurs(memePlace, 'focus'), [`siege:${deuxieme.id}:1`]);
|
||||
});
|
||||
|
||||
test('régime table : un siège, que nulle chaise ne dessine, marque sa table', () => {
|
||||
const charge = petite();
|
||||
const etat = etatPlaces(charge, { tour: 1 });
|
||||
const cible = etat.tables[0];
|
||||
const modele = modeleDessin(etat, options(charge, { k: 0.2, selection: { table: cible.id, siege: 2 }, actif: { table: cible.id, siege: 3 } }));
|
||||
assert.equal(modele.tables[0].regime, 'table');
|
||||
assert.deepEqual(porteurs(modele, 'selection'), [`table:${cible.id}`]);
|
||||
assert.deepEqual(porteurs(modele, 'focus'), [`table:${cible.id}`]);
|
||||
});
|
||||
|
||||
test('une place d’une table absente ne marque rien', () => {
|
||||
const charge = petite();
|
||||
const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge, { selection: { table: 99, siege: 1 }, actif: { table: 99, siege: null } }));
|
||||
assert.deepEqual(porteurs(modele, 'selection'), []);
|
||||
assert.deepEqual(porteurs(modele, 'focus'), []);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Le détail d'une table en conflit (I11) --------------------------------------
|
||||
|
||||
describe('detailConflits : le détail que rend le moteur, nommé (§ 7.1, § 7.3)', () => {
|
||||
test('petite démonstration en conflit : chaque règle violée, ses paires ou ses personnes, dans l’ordre du moteur', () => {
|
||||
const charge = avecPropositions(petite('petite-conflit'));
|
||||
const nomDe = nomsDe(charge);
|
||||
let vus = 0;
|
||||
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
|
||||
for (const table of etatPlaces(charge, { tour, proposition: 1 }).tables) {
|
||||
const detail = detailConflits(table, nomDe);
|
||||
if (table.conflits.nombre === 0) {
|
||||
assert.equal(detail, null);
|
||||
continue;
|
||||
}
|
||||
vus += 1;
|
||||
assert.deepEqual(detail, {
|
||||
numero: table.numero,
|
||||
nombre: table.conflits.nombre,
|
||||
regles: table.conflits.regles.map((regle) => ({
|
||||
cle: `plan.infobulle.${regle.regle}`,
|
||||
groupes: (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)),
|
||||
})),
|
||||
});
|
||||
for (const { cle } of detail.regles) assert.ok(typeof t(cle) === 'string' && t(cle).length > 0, cle);
|
||||
}
|
||||
}
|
||||
assert.ok(vus > 0, 'au moins une table en conflit');
|
||||
});
|
||||
|
||||
test('conflits non évalués, ou nuls : null', () => {
|
||||
const charge = petite();
|
||||
const [table] = etatPlaces(charge, { tour: 1 }).tables;
|
||||
assert.equal(detailConflits({ ...table, conflits: null }, nomsDe(charge)), null);
|
||||
assert.equal(detailConflits({ ...table, conflits: { nombre: 0, regles: [] } }, nomsDe(charge)), null);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Le texte entier d'une ligne, et les actions des panneaux (I11) --------------
|
||||
|
||||
describe('texteLigne : la ligne d’une chaise en un texte, nom accessible du siège', () => {
|
||||
const nomDe = (id) => ({ 1: 'Iris Ombrelle', 2: 'Théo Pervenche' })[id];
|
||||
const chaise = (autres) => ({ siege: 3, occupant: null, reservation: null, titre: null, ...autres });
|
||||
|
||||
test('les parties de textesLigne, bout à bout', () => {
|
||||
const cas = [
|
||||
[chaise({}), '3. (libre)'],
|
||||
[chaise({ occupant: 1 }), '3. Iris Ombrelle'],
|
||||
[
|
||||
chaise({ occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'animateur', pourvu: true } }),
|
||||
'3. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur',
|
||||
],
|
||||
[chaise({ titre: { libelle: 'hôte', pourvu: false } }), '3. (libre) \u{2014} titre hôte non pourvu'],
|
||||
[chaise({ occupant: 2, reservation: { participant: 1, portee: 'tour', tour: 2 } }), '3. (réservée à Iris Ombrelle, tour 2) Théo Pervenche'],
|
||||
];
|
||||
for (const [entree, attendu] of cas) assert.equal(texteLigne(ligneListe(entree, nomDe), t), attendu);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionsPlace : ce qu’offre le panneau d’une place (§ 7.1, § 2.1 étape 6)', () => {
|
||||
const libre = { siege: 1, occupant: null, reservation: null, titre: null };
|
||||
const tenue = { siege: 2, occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'hôte', pourvu: true } };
|
||||
const reservation = { genre: 'reservation', participant: 1, depuis: { table: 1, siege: 2 }, de: { table: 1, portee: 'tous', tour: null } };
|
||||
|
||||
test('une place libre : assigner, réserver, donner un titre, désigner — exactement', () => {
|
||||
assert.deepEqual(actionsPlace(libre, null), ['assigner', 'reserver', 'donnerTitre', 'designer']);
|
||||
assert.deepEqual(actionsPlace(libre, { genre: 'personne', participant: 2, depuis: null }), ['assigner', 'reserver', 'donnerTitre', 'designer']);
|
||||
});
|
||||
|
||||
test('une place réservée et titrée : retirer la réservation, retirer le titre ; une personne choisie y ajoute assigner, que la pose refusera', () => {
|
||||
assert.deepEqual(actionsPlace(tenue, null), ['retirerReservation', 'retirerTitre']);
|
||||
assert.deepEqual(actionsPlace(tenue, reservation), ['retirerReservation', 'retirerTitre', 'deplacerReservationIci']);
|
||||
assert.deepEqual(actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null }), ['assigner', 'retirerReservation', 'retirerTitre']);
|
||||
});
|
||||
|
||||
test('une place titrée, non tenue : assigner, réserver, désigner, retirer le titre', () => {
|
||||
assert.deepEqual(actionsPlace({ ...libre, titre: { libelle: 'hôte', pourvu: false } }, null), ['assigner', 'reserver', 'designer', 'retirerTitre']);
|
||||
});
|
||||
|
||||
test('une réservation choisie ajoute « Déplacer la réservation ici »', () => {
|
||||
assert.deepEqual(actionsPlace(libre, reservation), ['assigner', 'reserver', 'donnerTitre', 'designer', 'deplacerReservationIci']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionsPersonne : le menu d’une personne (§ 7.1)', () => {
|
||||
const assise = { siege: 2, occupant: 4, reservation: null, titre: null };
|
||||
const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } };
|
||||
|
||||
test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, ouvrir la fiche', () => {
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait }), [
|
||||
'deplacer',
|
||||
'echanger',
|
||||
'retirerDuTour',
|
||||
'reserverIci',
|
||||
'exclure',
|
||||
'ouvrirFiche',
|
||||
]);
|
||||
});
|
||||
|
||||
test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => {
|
||||
const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } };
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null }), ['deplacer', 'echanger', 'deplacerReservation', 'exclure', 'ouvrirFiche']);
|
||||
});
|
||||
|
||||
test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, et la fiche', () => {
|
||||
assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null }), ['deplacer', 'exclure', 'ouvrirFiche']);
|
||||
assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null }), ['deplacer', 'reintegrer', 'ouvrirFiche']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executerSurPlace : la commande d’un panneau, son refus posé sur la place (§ 5.9)', () => {
|
||||
// Une séance et un état de navigation d'épreuve, qui relèvent leurs appels.
|
||||
function dependances(executer) {
|
||||
const appels = [];
|
||||
return {
|
||||
appels,
|
||||
seance: { executer: async (nom, args) => (appels.push(['executer', nom, args]), executer(nom, args)) },
|
||||
ui: {
|
||||
choisir: (choix) => appels.push(['choisir', choix]),
|
||||
poserSurPlace: (pose) => appels.push(['poserSurPlace', pose]),
|
||||
},
|
||||
};
|
||||
}
|
||||
const refus = (code, details = {}) => Object.assign(new Error(code), { code, details });
|
||||
const place = { table: 2, siege: 3 };
|
||||
|
||||
test('réussie : rend le rendu de la séance, oublie le choix en attente et retire la pose', async () => {
|
||||
const d = dependances(() => ({ libelle: 'ok', avertissements: [] }));
|
||||
assert.deepEqual(await executerSurPlace(d, place, 'reserver', { participant: 1 }), { libelle: 'ok', avertissements: [] });
|
||||
assert.deepEqual(d.appels, [['executer', 'reserver', { participant: 1 }], ['choisir', null], ['poserSurPlace', null]]);
|
||||
});
|
||||
|
||||
test('refusée : le refus, code et détails, posé sur la place ; rien d’autre ; null', async () => {
|
||||
const d = dependances(() => {
|
||||
throw refus('PLACE_RESERVEE', { table: 2, siege: 3 });
|
||||
});
|
||||
assert.equal(await executerSurPlace(d, place, 'reserver', {}), null);
|
||||
assert.deepEqual(d.appels.slice(1), [
|
||||
['poserSurPlace', { place, refus: { code: 'PLACE_RESERVEE', details: { table: 2, siege: 3 } }, avertissements: [] }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('un code local, une place absente, ou une erreur sans code remontent à l’appelant', async () => {
|
||||
for (const [lieu, erreur, options] of [
|
||||
[place, refus('LIBELLE_VIDE'), { locaux: ['LIBELLE_VIDE'] }],
|
||||
[null, refus('SANS_EFFET'), {}],
|
||||
[place, new TypeError('défaut'), {}],
|
||||
]) {
|
||||
const d = dependances(() => {
|
||||
throw erreur;
|
||||
});
|
||||
await assert.rejects(executerSurPlace(d, lieu, 'poserTitre', {}, options), (recue) => recue === erreur);
|
||||
assert.deepEqual(d.appels.map(([nom]) => nom), ['executer']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('les clés que les panneaux composent existent (§ 14.6)', () => {
|
||||
test('plan.action.<action>, plan.menu.<action>, plan.infobulle.<règle> : chaque valeur possible a sa clé', () => {
|
||||
const tenue = { siege: 1, occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'hôte', pourvu: true } };
|
||||
const libre = { siege: 1, occupant: null, reservation: null, titre: null };
|
||||
const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } };
|
||||
const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]);
|
||||
const menu = new Set([
|
||||
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }),
|
||||
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }),
|
||||
]);
|
||||
assert.equal(actions.size, 7);
|
||||
assert.equal(menu.size, 8);
|
||||
for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action);
|
||||
for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action);
|
||||
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
36
test/fixtures/plan/conflit_table_un.gtt.json
vendored
Normal file
36
test/fixtures/plan/conflit_table_un.gtt.json
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":8,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":1}},
|
||||
"charge": {
|
||||
"evenement": {"id":"evt-conflit-table-un","nom":"Club des Merles","date":null,"siegesParDefaut":2,"tours":2,"unite":"cm","etat":"retenu","filiation":null},
|
||||
"reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}},
|
||||
"prochainsIds": {"participant":9,"table":5,"proposition":2},
|
||||
"participants": [
|
||||
{"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
|
||||
{"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
|
||||
{"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
|
||||
{"id":4,"nom":"Grisaille","prenom":null,"appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
|
||||
{"id":5,"nom":"Tourmaline","prenom":"Basile","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
|
||||
{"id":6,"nom":"Arrosoir","prenom":"Capucine","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
|
||||
{"id":7,"nom":"Cerfeuil","prenom":"Aurèle","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false},
|
||||
{"id":8,"nom":"Bruyère","prenom":"Mélisande","appartenance":null,"courriel":null,"titrePressenti":null,"notes":null,"exclu":false}
|
||||
],
|
||||
"tables": [
|
||||
{"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}},
|
||||
{"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}},
|
||||
{"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}},
|
||||
{"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}}
|
||||
],
|
||||
"reservations": [],
|
||||
"titres": [],
|
||||
"propositions": [
|
||||
{"id":1,"graine":1,"arret":40,"historique":1000,"produitVersion":"epreuve","siegesAttribues":false,"tables":[1,2,3,4],"capacites":[2,2,2,2],"tours":2,"participants":[1,2,3,4,5,6,7,8],"placement":[
|
||||
{"sieges":[[1,2],[3,4],[5,6],[7,8]],"reserve":[]},
|
||||
{"sieges":[[1,2],[5,7],[3,8],[4,6]],"reserve":[]}
|
||||
]}
|
||||
],
|
||||
"retenu": {"proposition":1,"siegesAttribues":false,"tables":[1,2,3,4],"capacites":[2,2,2,2],"tours":2,"participants":[1,2,3,4,5,6,7,8],"placement":[
|
||||
{"sieges":[[1,2],[3,4],[5,6],[7,8]],"reserve":[]},
|
||||
{"sieges":[[1,2],[5,7],[3,8],[4,6]],"reserve":[]}
|
||||
]}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue