diff --git a/spec.md b/spec.md
index 9201bd5..9f16739 100644
--- a/spec.md
+++ b/spec.md
@@ -635,6 +635,14 @@ réglages ajoute des candidats plutôt que d'effacer les précédents, et un
classement unique les ordonne tous. Une commande efface la liste en épargnant
celui qui est retenu.
+Au-delà de cent propositions, le tableau se lit par pages de cent : la page
+découpe ce classement unique sans le retrier, chaque ligne y garde son rang, et
+la ligne du retenu reste en tête de chaque page, hors du compte. La page est une
+posture de la navigation, jamais écrite (§ 8.4) : elle repart de la première à
+chaque ouverture d'un événement et après une génération, se borne à la dernière
+quand des propositions partent, et amène la proposition visée à chaque demande
+au plan, même répétée.
+
**La commande prend une graine entière et un compte d'arrêt**, et une longueur
d'historique d'acceptation dont l'algorithme fixe la valeur par défaut. Chaque
proposition conserve sa graine dérivée, ce compte et cette longueur (§ 8.9).
diff --git a/src/interface/etat_ui.js b/src/interface/etat_ui.js
index d5d150b..c96d4cb 100644
--- a/src/interface/etat_ui.js
+++ b/src/interface/etat_ui.js
@@ -3,10 +3,11 @@
// 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, le choix en attente
-// du plan, le refus posé sur une place, l'appartenance collante du
-// formulaire d'ajout et l'aperçu avant impression. Ce qu'il tient survit au
+// demandée au plan et le numéro de sa demande, le texte d'un import en
+// attente, les listes de noms, le plein écran, la place sélectionnée, la
+// fiche ouverte, le choix en attente du plan, le refus posé sur une place,
+// l'appartenance collante du formulaire d'ajout, l'aperçu avant impression
+// et la page du tableau des propositions. Ce qu'il tient survit au
// démontage des sections.
// 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
@@ -49,6 +50,12 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* tour du plan, 'tous' pour une vue de la soirée, jamais pour le plan ; les
* listes de noms du plan ; sa découpe en pages ; la feuille par table, une
* par tour ou la soirée en colonnes ; la section où l'aperçu se referme
+ * @property {number} visee le numéro de la dernière demande d'une proposition
+ * au plan : chaque montrerProposition d'une proposition l'augmente de 1, même
+ * pour celle déjà demandée ; 0 au départ
+ * @property {Object|null} pagePropositions le suivi de la page du tableau des propositions
+ * (SuiviDePage, propositions/modele.js), figé, que la section des propositions
+ * tient ; null au départ, et chaque événement qui s'ouvre repart de la première page
*/
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
@@ -67,6 +74,8 @@ const DEPART = Object.freeze({
pose: null,
appartenance: '',
apercu: null,
+ visee: 0,
+ pagePropositions: null,
});
/** Les vues que l'aperçu imprime (§ 11.7). */
@@ -144,7 +153,8 @@ function choixValide(choix) {
* basculerListes: Function, basculerPleinEcran: Function,
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
* poserSurPlace: Function, garderAppartenance: Function,
- * ouvrirApercu: Function, reglerApercu: Function, fermerApercu: Function}}
+ * ouvrirApercu: Function, reglerApercu: Function, fermerApercu: Function,
+ * retenirPagePropositions: Function}}
*/
export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false });
@@ -197,9 +207,21 @@ export function creerEtatUi() {
changer({ miseEnEvidence: id });
},
/** Demande la proposition id au plan, ou aucune (null) ; la pose, lue
- * sur l'état des places d'avant, se retire. */
+ * sur l'état des places d'avant, se retire. Chaque demande d'une
+ * proposition reçoit le numéro de visée suivant, et publie donc un état
+ * neuf même pour la proposition déjà demandée : la section des
+ * propositions y reconnaît une visée nouvelle. */
montrerProposition(id) {
- changer({ proposition: id, pose: null });
+ changer(id === null ? { proposition: null, pose: null } : { proposition: id, pose: null, visee: etat.visee + 1 });
+ },
+ /** Retient le suivi de la page du tableau des propositions, un objet
+ * figé, ou null ; RangeError sinon, sans rien changer. Le même suivi ne
+ * publie rien. */
+ retenirPagePropositions(suivi) {
+ if (suivi !== null && (typeof suivi !== 'object' || !Object.isFrozen(suivi))) {
+ throw new RangeError(`page des propositions : suivi figé ou null attendu, reçu ${JSON.stringify(suivi)}`);
+ }
+ changer({ pagePropositions: suivi });
},
/** Garde un texte à importer et ouvre la section de l'import. */
ouvrirImport({ texte, origine }) {
diff --git a/src/interface/etat_ui.test.js b/src/interface/etat_ui.test.js
index ef9f46c..114457d 100644
--- a/src/interface/etat_ui.test.js
+++ b/src/interface/etat_ui.test.js
@@ -25,6 +25,8 @@ const DEPART = {
pose: null,
appartenance: '',
apercu: null,
+ visee: 0,
+ pagePropositions: null,
};
// Un état et le relevé de ce que reçoit son abonné.
@@ -95,6 +97,7 @@ describe('état de navigation : chaque transition', () => {
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.retenirPagePropositions(Object.freeze({ page: 2 }));
ui.ouvrirApropos();
ui.reinitialiser();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
@@ -124,11 +127,45 @@ describe('état de navigation : chaque transition', () => {
ui.mettreEnEvidence(12);
ui.montrerProposition(3);
ui.selectionner({ table: 2, siege: 1 });
- assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, selection: { table: 2, siege: 1 } });
+ assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, visee: 1, selection: { table: 2, siege: 1 } });
ui.mettreEnEvidence(null);
ui.montrerProposition(null);
ui.selectionner(null);
- assert.deepEqual(ui.etat(), DEPART);
+ assert.deepEqual(ui.etat(), { ...DEPART, visee: 1 });
+ });
+
+ test('montrerProposition numérote chaque visée : la même proposition demandée de nouveau publie un état neuf ; null retire la visée sans la numéroter', () => {
+ const { ui, recus } = suivi();
+ ui.montrerProposition(5);
+ assert.equal(ui.etat().visee, 1);
+ ui.montrerProposition(5);
+ assert.deepEqual([ui.etat().proposition, ui.etat().visee, recus.length], [5, 2, 3]);
+ ui.montrerProposition(null);
+ ui.montrerProposition(null);
+ assert.deepEqual([ui.etat().proposition, ui.etat().visee, recus.length], [null, 2, 4]);
+ ui.montrerProposition(5);
+ assert.equal(ui.etat().visee, 3);
+ ui.reinitialiser();
+ assert.equal(ui.etat().visee, 0);
+ });
+
+ test('retenirPagePropositions retient le suivi de la page des propositions, figé, ou null ; un autre événement repart de null ; RangeError sinon, sans rien changer', () => {
+ const { ui, recus } = suivi();
+ const page = Object.freeze({ cles: Object.freeze(['1/1/40/1000']), visee: 0, page: 2, pages: 3 });
+ ui.retenirPagePropositions(page);
+ assert.equal(ui.etat().pagePropositions, page);
+ ui.retenirPagePropositions(page);
+ assert.equal(recus.length, 2);
+ ui.allerA('plan');
+ ui.allerA('propositions');
+ assert.equal(ui.etat().pagePropositions, page);
+ for (const faute of [undefined, 2, 'page', { page: 2 }]) assert.throws(() => ui.retenirPagePropositions(faute), RangeError, String(faute));
+ assert.equal(ui.etat().pagePropositions, page);
+ ui.reinitialiser();
+ assert.equal(ui.etat().pagePropositions, null);
+ ui.retenirPagePropositions(page);
+ ui.retenirPagePropositions(null);
+ assert.equal(ui.etat().pagePropositions, null);
});
test('ouvrirImport garde le texte en attente et mène à la section import ; prendreImport le rend une fois', () => {
diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js
index 7f9a4ca..cf3f6ba 100644
--- a/src/interface/libelles/propositions.js
+++ b/src/interface/libelles/propositions.js
@@ -41,6 +41,22 @@ export const TABLE = new Map([
`Appliqué à sa place : ${critere(applique, texte)}.`,
],
+ // --- Les pages du classement (§ 5.7) -------------------------------------------
+ // Au-delà de cent propositions, le classement se lit page à page ; chaque
+ // nav des pages se nomme, celle du dessus et celle du dessous. L'étendue
+ // reçoit ses nombres déjà mis en forme (texteEtendue,
+ // propositions/modele.js), et nombre, le compte de la page, qui accorde
+ // son nom.
+ ['propositions.pages.haut', 'Pages du classement'],
+ ['propositions.pages.bas', 'Pages du classement, sous le tableau'],
+ ['propositions.pages.precedentes', 'Précédentes'],
+ ['propositions.pages.suivantes', 'Suivantes'],
+ [
+ 'propositions.pages.etendue',
+ ({ premier, dernier, total, nombre }) =>
+ nombre <= 1 ? `Proposition ${premier} sur ${total}` : `Propositions ${premier} à ${dernier} sur ${total}`,
+ ],
+
// --- Les colonnes, chacune avec son unité (§ 5.4, § 5.5) ----------------------
['propositions.colonne.rang', 'Rang'],
['propositions.colonne.proposition', 'Proposition'],
diff --git a/src/interface/propositions/Propositions.navigateur.test.js b/src/interface/propositions/Propositions.navigateur.test.js
index 731f4e7..504babc 100644
--- a/src/interface/propositions/Propositions.navigateur.test.js
+++ b/src/interface/propositions/Propositions.navigateur.test.js
@@ -1170,3 +1170,265 @@ describe('l’habillage du tableau des propositions', () => {
}
});
});
+
+// --- Les pages du classement (§ 5.7, § 2.1 étapes 9 et 10, § 8.4) ---------------
+
+// La grande démonstration et 237 propositions (graine 7, arrêt 50) : brute,
+// sans retenu ; retenue, la 150e du classement retenue ; encore, cent
+// propositions de plus sous la graine 8, que l'épreuve enregistre. ordre :
+// les identifiants dans l'ordre que rend le moteur, sollicité une seconde
+// fois (§ 19.1). Calculées une fois, pour toutes les épreuves des pages.
+let calculees = null;
+function pagesDEpreuve() {
+ if (calculees !== null) return calculees;
+ const brute = generee(chargeDemo('grande'), { graine: 7, nombre: 237, arret: 50, historique: 1000 });
+ const configuration = configurationDepuisCharge(brute);
+ const instance = normaliser(configuration);
+ const aPriori = plafondsAPriori(instance);
+ const { ordre } = classer(
+ brute.propositions.map((p) => ({ id: p.id, mesures: mesurer(instance, indexerPlan(instance, planDepuisFichier(p))), plafondsAPriori: aPriori })),
+ );
+ const retenue = executer(brute, 'retenirProposition', { id: ordre[149] });
+ const encore = rechercher(configuration, { graine: 8, nombre: 100, arret: 50, historique: 1000 });
+ calculees = { brute, retenue, ordre, idRetenue: ordre[149], encore, configuration };
+ return calculees;
+}
+
+// La charge brute réduite à ses n premières propositions par identifiant.
+function premieres(n) {
+ const reduite = structuredClone(pagesDEpreuve().brute);
+ reduite.propositions = reduite.propositions.filter(({ id }) => id <= n);
+ return reduite;
+}
+
+const navDes = (m, ou) => section(m).querySelector(`nav[data-pages="${ou}"]`);
+const etendue = (m, ou = 'haut') => navDes(m, ou)?.querySelector('[data-etendue]') ?? null;
+const rangsLus = (m) => lignes(m).map((tr) => texteDe(tr.querySelector('td[data-colonne="rang"]')));
+const rangsDe = (premier, dernier) => Array.from({ length: dernier - premier + 1 }, (_, place) => format.nombre(premier + place));
+const texteEtendue = (premier, dernier, total) =>
+ t('propositions.pages.etendue', { premier: format.nombre(premier), dernier: format.nombre(dernier), total: format.nombre(total), nombre: dernier - premier + 1 });
+
+// La page montrée : les identifiants de ses lignes, et l'étendue que disent
+// les deux nav.
+async function exigerPage(m, ordre, premier, dernier, total) {
+ await attendre(() => expect(ids(m)).toEqual(ordre.slice(premier - 1, dernier)));
+ expect(rangsLus(m)).toEqual(rangsDe(premier, dernier));
+ for (const ou of ['haut', 'bas']) expect(texteDe(etendue(m, ou)), ou).toBe(texteEtendue(premier, dernier, total));
+}
+
+// Une commande de page d'une nav, null quand elle n'y est pas.
+const commandePage = (m, ou, nom) => commande(navDes(m, ou), nom);
+
+describe('les pages du classement (§ 5.7)', () => {
+ beforeEach(async () => {
+ await pointer('bouger', 0, 0);
+ });
+
+ test('237 propositions, la page 1 : les rangs 1 à 100 dans l’ordre du moteur, le retenu en tête ; une nav nommée au-dessus du tableau et une sous lui, « Suivantes » sans « Précédentes »', async () => {
+ const { retenue, ordre, idRetenue } = pagesDEpreuve();
+ const m = await ouvrir(retenue);
+ await exigerPage(m, ordre, 1, 100, 237);
+ const table = section(m).querySelector('table');
+ const haut = navDes(m, 'haut');
+ const bas = navDes(m, 'bas');
+ expect(haut.getAttribute('aria-label')).toBe(t('propositions.pages.haut'));
+ expect(bas.getAttribute('aria-label')).toBe(t('propositions.pages.bas'));
+ expect(haut.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(bas.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
+ for (const ou of ['haut', 'bas']) {
+ expect(commandePage(m, ou, 'precedentes'), ou).toBeNull();
+ expect(texteDe(commandePage(m, ou, 'suivantes')), ou).toBe(t('propositions.pages.suivantes'));
+ }
+ expect(etendue(m, 'haut').getAttribute('role')).toBe('status');
+ expect(etendue(m, 'bas').getAttribute('role')).toBeNull();
+ expect(ligneRetenu(m)).not.toBeNull();
+ expect(ligneRetenu(m).compareDocumentPosition(lignes(m)[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(ligne(m, idRetenue)).toBeNull();
+ });
+
+ test('« Suivantes » : la page 2 aux rangs globaux 101 à 200, le focus sur l’étendue annoncée ; puis la 3, 37 lignes, sans « Suivantes » ; « Précédentes » rend la 2 ; rien ne s’écrit', async () => {
+ const { retenue, ordre, idRetenue } = pagesDEpreuve();
+ const m = await ouvrir(retenue);
+ const ecritures = m.trace.length;
+ const revisionAvant = revision(m);
+ commandePage(m, 'haut', 'suivantes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ await attendre(() => expect(document.activeElement).toBe(etendue(m, 'haut')));
+ expect(ligneRetenu(m)).not.toBeNull();
+ expect(texteDe(ligne(m, idRetenue))).toContain(t('propositions.marque.retenue'));
+ for (const ou of ['haut', 'bas']) {
+ expect(commandePage(m, ou, 'precedentes'), ou).not.toBeNull();
+ expect(commandePage(m, ou, 'suivantes'), ou).not.toBeNull();
+ }
+ commandePage(m, 'bas', 'suivantes').click();
+ await exigerPage(m, ordre, 201, 237, 237);
+ await attendre(() => expect(document.activeElement).toBe(etendue(m, 'haut')));
+ for (const ou of ['haut', 'bas']) expect(commandePage(m, ou, 'suivantes'), ou).toBeNull();
+ commandePage(m, 'bas', 'precedentes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ await attendre(() => expect(document.activeElement).toBe(etendue(m, 'haut')));
+ expect(m.trace.length).toBe(ecritures);
+ expect(revision(m)).toBe(revisionAvant);
+ });
+
+ test('en mode lecture, « Suivantes » montre la page 2 et rien ne s’écrit (§ 8.4)', async () => {
+ const { retenue, ordre } = pagesDEpreuve();
+ const m = await ouvrir(retenue, { mode: 'lecture' });
+ await exigerPage(m, ordre, 1, 100, 237);
+ const ecritures = m.trace.length;
+ const revisionAvant = revision(m);
+ commandePage(m, 'haut', 'suivantes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ expect(m.trace.length).toBe(ecritures);
+ expect(revision(m)).toBe(revisionAvant);
+ });
+
+ test('la page survit au plan : « Voir sur le plan » depuis la page 2, retour sur la 2 ; changer de page garde la proposition demandée au plan, et la page choisie reste', async () => {
+ const { retenue, ordre } = pagesDEpreuve();
+ const m = await ouvrir(retenue);
+ commandePage(m, 'haut', 'suivantes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ const montree = ordre[120];
+ commande(ligne(m, montree), 'voir').click();
+ await attendre(() => expect(m.racine.querySelector('[data-section="plan"]')).not.toBeNull());
+ expect(m.ui.etat().proposition).toBe(montree);
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 101, 200, 237);
+ commandePage(m, 'haut', 'precedentes').click();
+ await exigerPage(m, ordre, 1, 100, 237);
+ expect(m.ui.etat().proposition).toBe(montree);
+ m.ui.allerA('plan');
+ flushSync();
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 1, 100, 237);
+ });
+
+ test('une proposition visée depuis ailleurs amène la page qui la porte : au retour sur la section, et section ouverte', async () => {
+ const { retenue, ordre } = pagesDEpreuve();
+ const m = await ouvrir(retenue);
+ await exigerPage(m, ordre, 1, 100, 237);
+ m.ui.allerA('plan');
+ flushSync();
+ m.ui.montrerProposition(ordre[220]);
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 201, 237, 237);
+ expect(ligne(m, ordre[220])).not.toBeNull();
+ m.ui.montrerProposition(ordre[150]);
+ await exigerPage(m, ordre, 101, 200, 237);
+ });
+
+ test('la même proposition visée de nouveau depuis le plan, après un retour à aucune ou sans lui, amène encore sa page', async () => {
+ const { retenue, ordre } = pagesDEpreuve();
+ const m = await ouvrir(retenue);
+ m.ui.allerA('plan');
+ flushSync();
+ m.ui.montrerProposition(ordre[220]);
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 201, 237, 237);
+ commandePage(m, 'haut', 'precedentes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ m.ui.allerA('plan');
+ flushSync();
+ m.ui.montrerProposition(null);
+ flushSync();
+ m.ui.montrerProposition(ordre[220]);
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 201, 237, 237);
+ commandePage(m, 'haut', 'precedentes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ m.ui.allerA('plan');
+ flushSync();
+ m.ui.montrerProposition(ordre[220]);
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 201, 237, 237);
+ });
+
+ test('une génération ramène à la page 1 ; « Défaire », qui la retire, borne la page à la dernière qui reste', async () => {
+ const { retenue, ordre, encore, configuration } = pagesDEpreuve();
+ const m = await ouvrir(retenue);
+ commandePage(m, 'haut', 'suivantes').click();
+ commandePage(m, 'haut', 'suivantes').click();
+ await exigerPage(m, ordre, 201, 237, 237);
+ await m.seance.executer('enregistrerGeneration', { propositions: encore, configuration, produitVersion: V1 });
+ await attendre(() => expect(texteDe(etendue(m))).toBe(texteEtendue(1, 100, 337)));
+ expect(rangsLus(m)).toEqual(rangsDe(1, 100));
+ for (let page = 2; page <= 4; page += 1) {
+ commandePage(m, 'haut', 'suivantes').click();
+ await attendre(() => expect(texteDe(etendue(m))).toBe(texteEtendue((page - 1) * 100 + 1, Math.min(page * 100, 337), 337)));
+ }
+ await m.seance.defaire();
+ await exigerPage(m, ordre, 201, 237, 237);
+ });
+
+ test('un autre événement s’ouvre sur la page 1', async () => {
+ const { retenue, ordre } = pagesDEpreuve();
+ const texte = serialiser(retenue, { revision: 1, produitVersion: V1 });
+ const m = await monterApp({
+ fichiers: [BASE, 'autre-soiree'].map((base) => ({ racine: 'documents', chemin: `${base}${SUFFIXES.etat}`, texte })),
+ });
+ montes.push(m);
+ await m.seance.ouvrir(BASE);
+ m.ui.allerA('propositions');
+ flushSync();
+ commandePage(m, 'haut', 'suivantes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ await m.seance.ouvrir('autre-soiree');
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 1, 100, 237);
+ });
+
+ test('chaque ouverture repart de la page 1 : le premier événement rouvert après un autre que la section n’a pas vu, puis rouvert après sa fermeture', async () => {
+ const { retenue, ordre } = pagesDEpreuve();
+ const texte = serialiser(retenue, { revision: 1, produitVersion: V1 });
+ const m = await monterApp({
+ fichiers: [BASE, 'autre-soiree'].map((base) => ({ racine: 'documents', chemin: `${base}${SUFFIXES.etat}`, texte })),
+ });
+ montes.push(m);
+ await m.seance.ouvrir(BASE);
+ m.ui.allerA('propositions');
+ flushSync();
+ commandePage(m, 'haut', 'suivantes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ await m.seance.ouvrir('autre-soiree');
+ flushSync();
+ await m.seance.ouvrir(BASE);
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 1, 100, 237);
+ commandePage(m, 'haut', 'suivantes').click();
+ await exigerPage(m, ordre, 101, 200, 237);
+ await m.seance.fermer();
+ flushSync();
+ await m.seance.ouvrir(BASE);
+ m.ui.allerA('propositions');
+ await exigerPage(m, ordre, 1, 100, 237);
+ });
+
+ test('à 100 propositions, ni nav ni commande de page ; à 101, la page 2 ne porte que la 101e : « Proposition 101 sur 101 »', async () => {
+ const cent = await ouvrir(premieres(100));
+ await attendre(() => expect(lignes(cent).length).toBe(100));
+ expect(section(cent).querySelector('nav')).toBeNull();
+ expect(section(cent).querySelector('[data-etendue]')).toBeNull();
+ for (const nom of ['precedentes', 'suivantes']) expect(commande(section(cent), nom), nom).toBeNull();
+ const centUn = await ouvrir(premieres(101));
+ await attendre(() => expect(texteDe(etendue(centUn))).toBe(texteEtendue(1, 100, 101)));
+ expect(lignes(centUn).length).toBe(100);
+ commandePage(centUn, 'bas', 'suivantes').click();
+ await attendre(() => expect(lignes(centUn).length).toBe(1));
+ expect(texteDe(etendue(centUn))).toBe('Proposition 101 sur 101');
+ expect(rangsLus(centUn)).toEqual([format.nombre(101)]);
+ });
+
+ test('la colonne que désigne un nom survolé reste surlignée sur la page suivante', async () => {
+ const { retenue } = pagesDEpreuve();
+ const m = await ouvrir(retenue);
+ const nom = section(m).querySelector('[data-ordre] [data-critere="excedentCollisions"]');
+ const { x, y } = await centre(nom);
+ await pointer('bouger', x, y);
+ await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'excedentCollisions')));
+ commandePage(m, 'haut', 'suivantes').click();
+ await attendre(() => expect(texteDe(etendue(m))).toBe(texteEtendue(101, 200, 237)));
+ const attendues = colonne(m, 'excedentCollisions');
+ expect(attendues.length).toBe(102);
+ await attendre(() => expect(surlignees(m)).toEqual(attendues));
+ });
+});
diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte
index 0b64c0c..4eb6704 100644
--- a/src/interface/propositions/SectionPropositions.svelte
+++ b/src/interface/propositions/SectionPropositions.svelte
@@ -39,6 +39,25 @@
// explication (explicationDeColonne, modele.js). Le terme expliqué porte
// aria-describedby vers elle, et lui seul.
//
+ // Au-delà de TAILLE_PAGE propositions (modele.js), le tableau se lit page
+ // à page : pagine découpe le classement unique, sans le retrier, et la
+ // ligne du retenu reste en tête de chaque page, hors du compte. Une nav
+ // nommée au-dessus du tableau, répétée sous lui, porte « Précédentes »,
+ // l'étendue de la page et « Suivantes » ; une commande sans objet n'y est
+ // pas, comme « Refaire » sans retour à refaire. Changer de page n'écrit
+ // rien (§ 8.4) : le focus va à l'étendue de la nav du dessus, une région
+ // role="status" qui annonce la page ; chaque étendue se focalise par
+ // programme (tabindex -1), jamais par Tab. La page est un état
+ // d'interface que l'état de navigation retient (pagePropositions,
+ // retenirPagePropositions) : elle survit au démontage de la section, et
+ // repart de la première à chaque ouverture d'un événement, quand
+ // l'état de navigation revient à son départ. suivrePage la recale à chaque
+ // charge ou visée nouvelles — la première après une génération, bornée à
+ // la dernière quand des propositions partent, celle de la proposition
+ // visée à chaque nouvelle demande au plan (EtatUi.visee). Rien de ce
+ // qu'une ligne tient — la colonne surlignée, la proposition demandée au
+ // plan, le retenu — ne dépend de la page.
+ //
// Habillage : le tableau et la phrase de l'ordre vivent dans une carte
// titrée, « Effacer les propositions » à droite de son en-tête ; le message
// d'une liste vide et le champ de la cible, chacun dans sa carte.
@@ -47,7 +66,7 @@
// l'événement (cibleRedondance), et exécute changerCibleRedondance à la
// sortie du champ — une entrée ; la saisie se lit par lireEntier, et un
// refus se pose contre le champ (Champ).
- import { getContext } from 'svelte';
+ import { getContext, tick, untrack } from 'svelte';
import { lireEntier } from '../../application/capacite.js';
import { cibleRedondance } from '../../application/evaluation.js';
import { REGLAGES_GENERATION, reglagesDeSection } from '../generation/reglages.js';
@@ -58,9 +77,22 @@
import Infobulle from '../primitives/Infobulle.svelte';
import Refus from '../primitives/Refus.svelte';
import TableauPropositions from './TableauPropositions.svelte';
- import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, surlignee as colonneSurlignee, tableau } from './modele.js';
+ import {
+ changerDePage,
+ demandeConfirmation,
+ evaluation,
+ explicationDeColonne,
+ nomDe,
+ pagine,
+ retenuAjuste,
+ suivrePage,
+ surlignee as colonneSurlignee,
+ tableau,
+ texteEtendue,
+ } from './modele.js';
const t = getContext('t');
+ const format = getContext('format');
// Le champ de la cible lit l'entrée que lit aussi le tableau des réglages
// de la documentation (§ 19.8).
const [entreeCible] = reglagesDeSection(REGLAGES_GENERATION, 'propositions');
@@ -72,6 +104,29 @@
const ajuste = $derived(charge !== null && retenuAjuste(charge));
const vue = $derived(evaluee.table === null ? null : tableau(evaluee.table, { ajuste }));
+ // Le suivi de la page (SuiviDePage, modele.js), que l'état de navigation
+ // retient, recalé avant le rendu à chaque vue ou visée nouvelles ; sans
+ // tableau, il reste tel. Le suivi inchangé revient identique de
+ // suivrePage, et l'état de navigation ne publie rien : le recalage
+ // s'arrête là. La page montrée en découle.
+ const suivi = $derived($ui.pagePropositions);
+ $effect.pre(() => {
+ const observee = { vue, propositions: charge?.propositions ?? [], vise: $ui.proposition, visee: $ui.visee };
+ if (vue === null) return;
+ untrack(() => ui.retenirPagePropositions(suivrePage(ui.etat().pagePropositions, observee)));
+ });
+ const page = $derived(vue === null ? null : pagine(vue, suivi?.page ?? 1));
+ const vuePage = $derived(page === null ? null : { ...vue, lignes: page.lignes, horsClassement: page.horsClassement });
+
+ // L'élément de la section, où se trouve l'étendue de la nav du dessus.
+ let racine = $state(null);
+
+ // Montre la page demandée, puis rend le focus à l'étendue qui l'annonce.
+ function tourner(numero) {
+ ui.retenirPagePropositions(changerDePage(ui.etat().pagePropositions, numero));
+ tick().then(() => racine?.querySelector('[data-pages="haut"] [data-etendue]')?.focus());
+ }
+
// La retenue en attente de confirmation, telle que demandeConfirmation la
// rend : la proposition demandée, le titre et les éléments de la fenêtre ;
// null sans fenêtre.
@@ -142,11 +197,33 @@
const nommer = (id) => nomDe(charge, id);
+{#snippet pages(ou)}
+
+{/snippet}
+
{#snippet effacerListe()}
{t('coquille.section.propositions')}
{#if charge !== null && charge.propositions.length === 0}
@@ -188,7 +265,9 @@