From 1b47828c11604c9d38fc52c54a27eb89c3b9c360 Mon Sep 17 00:00:00 2001 From: mathieu Date: Fri, 21 Aug 2026 13:38:46 +0200 Subject: [PATCH] =?UTF-8?q?Remonte=20le=20type=20de=20document,=20colle=20?= =?UTF-8?q?le=20menu,=20et=20fais=20du=20retour=20une=20remont=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq retours d'usage du 2026-08-21 qui ne touchent que l'interface (lots Q, R, S, T et V d'IDEES.md). Aucune migration, aucune entité modifiée. Q — L'ordre des champs du formulaire livre est une décision, pas une mise en page : type de document, thèmes, auteurs, rôles, puis le reste. Le type commande la suite de la saisie — les rôles ne se posent que là — et il fallait descendre tout le formulaire pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend. Rien n'est présélectionné : « non précisé » ne prétend toujours rien, et les rôles gardent leur règle actée (deux auteurs au moins, BD ou non). Des tests verrouillent la préservation des rôles à chaque frappe, que ce réordonnancement ne doit pas entamer. R — Bandeau et menu passent dans un même conteneur collant : sur PC la rangée des six destinations défilait avec la page et devenait inatteignable au bas d'une longue liste. Un seul conteneur, et non deux éléments collants superposés, qui auraient obligé à écrire en dur la hauteur d'un bandeau qui varie avec la pastille hors-ligne et le nom d'utilisateur. Sur téléphone le menu déployé occupe l'écran entier : sous le bandeau, il partageait l'écran avec la liste qu'on quittait. Trois portes de sortie s'ajoutent à la fermeture déjà en place sur LocationChanged — croix, Échap (le calque prend le focus à l'ouverture, comme celui d'agrandissement des couvertures) et clic hors des liens. Le plein écran est explicitement annulé au-delà de 40 rem, sans quoi un menu ouvert au doigt puis une fenêtre agrandie laisseraient un calque sans bascule pour le refermer. Toutes les règles du menu restent en feuille globale. S — ⚠️ Le retour du bandeau devient une remontée hiérarchique d'un cran de route, ce qui RENVERSE la décision actée le 2026-08-20 (« le retour passe par l'historique du navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les allers-retours (filtre, ordre, édition) et l'on cliquait cinq fois sans quitter le même écran. La parenté est une table explicite et testable, pas un découpage naïf d'URL : toutes les routes n'ont pas la forme d'une arborescence, /auteurs/{id}/bibliographie remontant à la liste des auteurs, dont il n'existe pas de fiche. Le garde-fou « ne jamais sortir de l'application » y vit désormais aussi : la fonction rend toujours un chemin interne, là où history.length ne disait pas ce qu'on croyait dans une PWA standalone. js/navigation.js n'a plus d'utilisateur et disparaît. T — Sur la fiche d'une série, « Modifier » et « Changer l'ordre » rejoignent la ligne du titre et de l'avancement, groupés à droite comme la bibliographie et la liste des auteurs le font déjà. Reléguées au bas de l'écran, les actions d'une saga de vingt tomes ne se découvraient qu'après avoir déroulé la liste. « Changer l'ordre » n'apparaît toujours qu'à partir de deux tomes ou deux sous-séries. V — Dans la liste des revues, toute la ligne ouvre la fiche, comme la carte entière le fait au catalogue. Le titre reste un vrai lien — adresse, clavier, clic-milieu — et son clic ne remonte pas jusqu'à la ligne, qui naviguerait une seconde fois ; toute action posée un jour sur cette ligne devra faire de même. ⚠️ Aucune vérification en navigateur : le rendu de ces écrans repose sur la compilation et la relecture. 552 tests au vert (506 avant ce lot). Co-Authored-By: Claude Opus 5 --- .../Composants/FormulaireLivre.razor | 93 +++++----- MaBibli.Client/Layout/MainLayout.razor | 92 ++++++++-- MaBibli.Client/Layout/MainLayout.razor.css | 5 +- MaBibli.Client/Pages/Revues.razor | 19 ++- MaBibli.Client/Pages/Serie.razor | 74 ++++---- MaBibli.Client/Services/RemonteeRoutes.cs | 161 ++++++++++++++++++ MaBibli.Client/wwwroot/css/app.css | 132 ++++++++++++++ MaBibli.Client/wwwroot/index.html | 3 - MaBibli.Client/wwwroot/js/navigation.js | 18 -- MaBibli.Tests/EnregistrementLivreTests.cs | 82 +++++++++ MaBibli.Tests/RemonteeRoutesTests.cs | 101 +++++++++++ 11 files changed, 665 insertions(+), 115 deletions(-) create mode 100644 MaBibli.Client/Services/RemonteeRoutes.cs delete mode 100644 MaBibli.Client/wwwroot/js/navigation.js create mode 100644 MaBibli.Tests/EnregistrementLivreTests.cs create mode 100644 MaBibli.Tests/RemonteeRoutesTests.cs diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index a2904cc..49b2b33 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -18,6 +18,45 @@ placeholder="Titre du livre" autocomplete="off" /> + @* + ⚠️ L'ORDRE DES CHAMPS EST UNE DÉCISION (lot Q, 2026-08-21), pas une mise en page. + + Type de document, puis thèmes, puis auteurs, puis rôles : c'est l'ordre dans lequel on + regarde un livre qu'on tient en main, et surtout le type COMMANDE LA SUITE de la saisie — + les rôles ne se posent que là. Relégué en bas, il obligeait à descendre tout le formulaire + pour dire « c'est une BD », c'est-à-dire après avoir saisi ce qui en dépend. + + ⚠️ Remonter le champ le rend visible ; le PRÉREMPLIR écrirait quelque chose de faux. Le + défaut reste « non précisé », qui ne prétend rien (décision actée) — c'est aussi ce qui + rend l'étiquette honnête dans les listes : elle ne s'affiche que si un type a été choisi. + *@ +
+ + Type (facultatif) + +
+ @foreach (var type in Types) + { + + } +
+
+ + + @* Un seul champ, plusieurs auteurs : le point-virgule les sépare. La virgule est déjà prise par la forme bibliographique « Zola, Émile », qu'on doit pouvoir taper telle quelle. @@ -63,29 +102,6 @@ } - - - - - - @* Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc physique par défaut. Cette valeur DOIT rester présélectionnée ici, sinon on créerait @@ -109,26 +125,17 @@ - @* - ⚠️ Le défaut est « non précisé », PAS « roman » : on ne remplit pas une case à la place - de l'utilisateur. C'est aussi ce qui rend l'étiquette honnête dans les listes — elle ne - s'affiche que lorsqu'un type a été choisi. - *@ -
- - Type (facultatif) - -
- @foreach (var type in Types) - { - - } -
-
+ + +
diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor index a702646..a3ce45b 100644 --- a/MaBibli.Client/Layout/MainLayout.razor +++ b/MaBibli.Client/Layout/MainLayout.razor @@ -3,7 +3,6 @@ @inject ServiceLivresApi Api @inject EtatReseau Reseau @inject NavigationManager Navigation -@inject IJSRuntime JS @* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche. @@ -18,12 +17,18 @@ exotique : sans la moindre feuille de style, il reste une suite de liens lisibles les uns sous les autres, au lieu d'une rangée écrasée. *@ +@* + ⚠️ Bandeau et menu sont dans un MÊME conteneur collant, et ce n'est pas cosmétique : sur PC + la rangée du menu doit rester atteignable au défilement (lot R), et deux éléments collants + superposés obligeraient à écrire en dur la hauteur du bandeau dans le `top` du second. +*@ +
@* Le déplacement entre écrans était jugé « foireux » : d'un écran profond (bibliographie, fiche de tome, ajout d'envie) il fallait deviner quel onglet ramenait en arrière. Un retour explicite, au même endroit sur tous les écrans, répond à la question sans obliger à connaître l'arborescence. *@ - @@ -55,7 +60,7 @@ } - +
+ } + Catalogue Auteurs Séries @@ -77,6 +100,7 @@ Prêts Envies + @if (!Reseau.EnLigne) { @@ -106,6 +130,11 @@ /// Menu déployé (téléphone). Sur PC la rangée est visible en permanence. private bool _menuOuvert; + /// Le calque du menu doit prendre le focus au prochain rendu — sinon pas d'Échap. + private bool _menuAFocaliser; + + private ElementReference _menu; + protected override async Task OnInitializedAsync() { Reseau.Change += SurChangementReseau; @@ -194,19 +223,52 @@ } /// - /// Revient à l'écran précédent, en repliant le menu au passage. + /// Remonte d'un cran dans l'arborescence des écrans, en repliant le menu au passage. /// /// - /// Le retour passe par l'historique du navigateur, pas par une destination calculée : - /// c'est le seul moyen de ramener là d'où l'on vient réellement, un même écran étant - /// atteignable par plusieurs chemins (une fiche livre s'ouvre depuis le catalogue, une - /// bibliographie, une série ou une recherche). Le repli sur le catalogue quand la pile est - /// vide est traité en JavaScript, history.length n'ayant pas d'équivalent côté C#. + /// ⚠️ Renverse la décision du 2026-08-20 (« le retour passe par l'historique du + /// navigateur, jamais par une destination calculée »). L'historique remonte aussi les + /// allers-retours — filtre, ordre, édition — et l'on cliquait cinq fois sans quitter le même + /// écran. La parenté est désormais explicite et testable : voir + /// , qui porte aussi le garde-fou « ne jamais sortir de + /// l'application » que history.length tenait mal dans une PWA standalone. /// - private async Task RetourAsync() + private void Retour() { - _menuOuvert = false; - await JS.InvokeVoidAsync("mabibliRetour"); + FermerMenu(); + Navigation.NavigateTo(RemonteeRoutes.Parent(Navigation.ToBaseRelativePath(Navigation.Uri))); + } + + private void BasculerMenu() + { + _menuOuvert = !_menuOuvert; + + // Le focus n'est demandé qu'à l'ouverture : c'est lui qui rend Échap opérant sur un + // calque, et il n'y a rien à focaliser une fois le menu refermé. + _menuAFocaliser = _menuOuvert; + } + + /// + /// Referme le menu. Posé sur le calque lui-même : un clic hors des liens ferme, et un clic + /// SUR un lien ferme aussi — la navigation qui suit s'en chargerait de toute façon. + /// + private void FermerMenu() => _menuOuvert = false; + + private void SurToucheMenu(KeyboardEventArgs e) + { + if (e.Key is "Escape" or "Esc") + { + FermerMenu(); + } + } + + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (_menuAFocaliser) + { + _menuAFocaliser = false; + await _menu.FocusAsync(); + } } public void Dispose() diff --git a/MaBibli.Client/Layout/MainLayout.razor.css b/MaBibli.Client/Layout/MainLayout.razor.css index 98cf50a..829e9ed 100644 --- a/MaBibli.Client/Layout/MainLayout.razor.css +++ b/MaBibli.Client/Layout/MainLayout.razor.css @@ -1,7 +1,6 @@ +/* ⚠️ Ce n'est plus le bandeau qui colle en haut, mais le conteneur `.entete` qui le réunit au + menu (feuille globale, lot R) : les deux doivent rester solidaires au défilement. */ .bandeau { - position: sticky; - top: 0; - z-index: 10; display: flex; align-items: center; gap: 0.5rem; diff --git a/MaBibli.Client/Pages/Revues.razor b/MaBibli.Client/Pages/Revues.razor index 8691b2a..8180339 100644 --- a/MaBibli.Client/Pages/Revues.razor +++ b/MaBibli.Client/Pages/Revues.razor @@ -1,6 +1,7 @@ @page "/revues" @inject ServiceLivresApi Api @inject EtatReseau Reseau +@inject NavigationManager Navigation @implements IDisposable MaBibli — revues @@ -29,8 +30,20 @@ else
    @foreach (var revue in _revues) { -
  • - @revue.Titre + @* + ⚠️ Toute la LIGNE ouvre la fiche (lot V), et pas seulement le titre : c'est ce que + le projet tient déjà au catalogue, où la carte entière y mène. Viser un titre de + trois mots au pouce était le seul point d'entrée. + + Le lien reste un vrai lien : il porte l'adresse, le clavier et le clic-milieu. + Son clic ne remonte donc PAS jusqu'à la ligne, qui navigerait une seconde fois. + Toute action posée un jour sur cette ligne doit faire de même + (@@onclick:stopPropagation), sans quoi elle ouvrirait la fiche en s'exécutant. + *@ +
  • + @revue.Titre

    @@ -83,6 +96,8 @@ else @code { private IReadOnlyList? _revues; + private void Ouvrir(int id) => Navigation.NavigateTo($"revues/{id}"); + private static string Quand(NumeroRevueDto numero) => numero.DateParution is { } date ? $"({date.ToLocalTime():MM/yyyy})" : string.Empty; diff --git a/MaBibli.Client/Pages/Serie.razor b/MaBibli.Client/Pages/Serie.razor index ac7a7c2..1f3fb54 100644 --- a/MaBibli.Client/Pages/Serie.razor +++ b/MaBibli.Client/Pages/Serie.razor @@ -26,19 +26,52 @@ else } } -

    @_serie.Titre

    + @* + Titre, avancement et actions sur la MÊME ligne (lot T) : c'est la disposition déjà tenue + par la bibliographie et la liste des auteurs, et elle rend les deux actions visibles sans + descendre au bas d'une saga de vingt tomes. -

    - @if (_serie.Elements.Count == 0) + ⚠️ « Changer l'ordre » n'apparaît qu'à partir de DEUX tomes ou deux sous-séries : un + bouton toujours présent mais inerte serait un recul. Et les deux écrans restent distincts + — « Modifier » touche à la fiche (nom, cycle, suppression), « Changer l'ordre » à la + place des tomes, dont les flèches ne vivent que là. + *@ +

    +
    +

    @_serie.Titre

    + +

    + @if (_serie.Elements.Count == 0) + { + Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent. + } + else + { + var total = Total(_serie); + @total.Possedes sur @total.Total tomes dans la bibliothèque. + } +

    +
    + + @if (ModeConsultation) { - Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent. +
    + @if (Reseau.EnLigne) + { + @if (_serie.Elements.Count > 1 || _filles.Count > 1) + { + Changer l'ordre + } + Modifier + } + else + { + + } +
    } - else - { - var total = Total(_serie); - @total.Possedes sur @total.Total tomes dans la bibliothèque. - } -

    +
    @if (ModeOrdre) { @@ -439,27 +472,6 @@ else Terminé } - else if (ModeConsultation) - { - - } } @code { diff --git a/MaBibli.Client/Services/RemonteeRoutes.cs b/MaBibli.Client/Services/RemonteeRoutes.cs new file mode 100644 index 0000000..926bf09 --- /dev/null +++ b/MaBibli.Client/Services/RemonteeRoutes.cs @@ -0,0 +1,161 @@ +namespace MaBibli.Client.Services; + +/// +/// Parenté des routes de l'application : à quel écran remonte la flèche de retour du bandeau. +/// +/// +/// +/// ⚠️ Ceci renverse la décision actée le 2026-08-20 (« le retour passe par l'historique du +/// navigateur, jamais par une destination calculée »). Le motif d'alors reste vrai — un même +/// écran s'atteint par plusieurs chemins — mais l'historique remonte aussi les +/// allers-retours (filtre, ordre, édition) : on cliquait cinq fois sans quitter le même +/// écran. Une remontée d'un cran de route répond à « où suis-je ? », qui est la question posée. +/// +/// +/// La table est explicite, et non un découpage naïf de l'URL : toutes les routes n'ont pas +/// la forme d'une arborescence (/souhaits/ordre remonte à /souhaits, mais +/// /auteurs/{id}/bibliographie remonte à /auteurs — la fiche d'un auteur n'existe +/// pas). Une route qui apparaît dans l'application doit apparaître ici. +/// +/// +/// ⚠️ Le retour ne sort jamais de l'application : la fonction rend toujours un chemin +/// interne, et la racine d'une branche rend la destination de menu correspondante — le catalogue +/// en dernier ressort. C'est ce qui remplace le test de history.length qui vivait en +/// JavaScript, et qui ne disait pas ce qu'on croyait dans une PWA standalone : la pile +/// d'une session y contient aussi ce qui précède l'application. +/// +/// +public static class RemonteeRoutes +{ + /// Le catalogue est la racine : son parent est lui-même, on ne remonte pas plus haut. + public const string Racine = "/"; + + /// + /// Table de parenté, dans l'ordre de lecture. {id} représente un segment numérique. + /// + /// + /// La première ligne dont le modèle correspond gagne : les modèles les plus longs sont donc + /// écrits avant ceux dont ils sont un prolongement. + /// + private static readonly (string Modele, string Parent)[] Parents = + [ + // Fiche livre : l'édition retombe sur la consultation, la consultation sur le catalogue. + ("/livres/{id}/edition", "/livres/{id}"), + ("/livres/{id}", Racine), + + // Séries : les deux écrans de modification retombent sur la consultation de la série. + ("/series/{id}/edition", "/series/{id}"), + ("/series/{id}/ordre", "/series/{id}"), + ("/series/{id}", "/series"), + ("/series", Racine), + + // Envies : l'ordre et l'ajout sont deux écrans de la même liste. + ("/souhaits/ordre", "/souhaits"), + ("/souhaits/ajout", "/souhaits"), + ("/souhaits", Racine), + + // Ajout d'un ouvrage : on y entre depuis le catalogue comme depuis les revues, mais le + // catalogue est la destination de menu de ce qu'on y saisit. + ("/ajout/manuel", Racine), + ("/ajout/isbn", Racine), + ("/ajout", Racine), + + // Revues. ⚠️ « /revues/ajout » est écrit AVANT « /revues/{id} » : « ajout » n'est pas un + // identifiant, mais l'ordre de lecture est ce qui le garantit sans ambiguïté. + ("/revues/ajout", "/revues"), + ("/revues/{id}", "/revues"), + ("/revues", Racine), + + // La bibliographie remonte à la LISTE des auteurs : il n'existe pas de fiche auteur. + ("/auteurs/{id}/bibliographie", "/auteurs"), + ("/auteurs", Racine), + + ("/prets", Racine), + ("/not-found", Racine), + ]; + + /// + /// Écran d'où l'on vient hiérarchiquement, à partir d'un chemin d'application. + /// + /// + /// Chemin absolu ou relatif à la base, avec ou sans requête (?auteur=3) ni fragment. + /// + /// Un chemin interne, toujours ; jamais null, jamais une URL absolue. + public static string Parent(string? chemin) + { + var segments = Segments(chemin); + + if (segments.Length == 0) + { + return Racine; + } + + foreach (var (modele, parent) in Parents) + { + if (Correspond(Segments(modele), segments)) + { + return Rendre(parent, segments); + } + } + + // Route inconnue : on retombe sur la destination de menu de sa branche, plutôt que sur + // un chemin deviné. Une route ajoutée sans sa ligne de table reste ainsi utilisable — + // mais elle doit être ajoutée : c'est un repli, pas le mécanisme. + return Parents.FirstOrDefault(p => p.Modele == $"/{segments[0]}").Parent is not null + ? $"/{segments[0]}" + : Racine; + } + + /// Découpe en segments non vides, requête et fragment retirés. + private static string[] Segments(string? chemin) + { + if (string.IsNullOrWhiteSpace(chemin)) + { + return []; + } + + var utile = chemin.Split('?', '#')[0]; + + return utile.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static bool Correspond(string[] modele, string[] segments) + { + if (modele.Length != segments.Length) + { + return false; + } + + for (var i = 0; i < modele.Length; i++) + { + var attendu = modele[i]; + + var ok = attendu == "{id}" + ? int.TryParse(segments[i], out _) + : string.Equals(attendu, segments[i], StringComparison.OrdinalIgnoreCase); + + if (!ok) + { + return false; + } + } + + return true; + } + + /// Remplace {id} du parent par l'identifiant lu dans le chemin d'origine. + private static string Rendre(string parent, string[] segments) + { + if (!parent.Contains("{id}", StringComparison.Ordinal)) + { + return parent; + } + + var identifiant = segments.FirstOrDefault(s => int.TryParse(s, out _)); + + // Sans identifiant lisible, on ne fabrique pas une route bancale : la racine est sûre. + return identifiant is null + ? Racine + : parent.Replace("{id}", identifiant, StringComparison.Ordinal); + } +} diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index eb04867..2460276 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -139,6 +139,38 @@ body { margin: 0 0 0.75rem; } +/* + Ligne d'en-tête d'une fiche : le texte à gauche, les actions groupées à DROITE (lot T). + + C'est la disposition déjà tenue par la bibliographie et la liste des auteurs. Reléguées en + bas de l'écran, les actions d'une saga de vingt tomes ne se découvraient qu'après avoir + déroulé toute la liste. + + ⚠️ `min-width: 0` sur la colonne de texte : sans lui, un titre long refuse de rétrécir et + pousse les actions hors de l'écran — le piège déjà rencontré au lot M1. +*/ +.entete-fiche { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; +} + +.entete-fiche-texte { + flex: 1 1 12rem; + min-width: 0; +} + +.entete-fiche-actions { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 0.4rem; +} + .message-discret { color: var(--mb-texte-doux); font-size: 0.9rem; @@ -1190,6 +1222,18 @@ body { border-bottom: 1px solid var(--mb-bord); } +/* Ligne entièrement cliquable (lot V) : le curseur doit le dire, sans quoi seul le titre a + l'air de mener quelque part. Le survol marque la ligne visée, utile à la souris comme au + clavier lorsque le lien de titre prend le focus. */ +.carte-cliquable { + cursor: pointer; +} + +.carte-cliquable:hover, +.carte-cliquable:focus-within { + background: var(--mb-fond-doux); +} + .liste-sous-series { margin: 0.35rem 0 0 1rem; font-size: 0.9rem; @@ -1321,6 +1365,21 @@ body { autres : c'est tout l'intérêt du gabarit retenu le 2026-08-20. ------------------------------------------------------------------ */ +/* + Bandeau et menu collent ENSEMBLE en haut de l'écran (lot R) : sur PC, la rangée des six + destinations défilait avec la page et devenait inatteignable dès qu'on descendait dans une + longue liste. + + ⚠️ Un seul conteneur collant, et non deux éléments collants superposés : ceux-ci auraient + obligé à écrire en dur la hauteur du bandeau dans le `top` du menu, hauteur qui varie avec + la pastille hors-ligne et le nom d'utilisateur. +*/ +.entete { + position: sticky; + top: 0; + z-index: 10; +} + .menu { display: none; flex-direction: column; @@ -1328,8 +1387,66 @@ body { border-bottom: 1px solid rgba(255, 255, 255, 0.15); } +/* + Téléphone : le menu déployé occupe l'écran ENTIER. + + Déployé sous le bandeau, il partageait l'écran avec la liste qu'on quittait, et l'on + choisissait sa destination au milieu d'autre chose. En plein écran, les six destinations se + lisent d'un coup d'œil. + + ⚠️ Le calque garde une porte de sortie explicite — la croix ci-dessous, Échap, et le clic + hors des liens (posés dans MainLayout.razor), en plus de la fermeture sur LocationChanged. + Sans elles, un plein écran est un piège. +*/ .menu-ouvert { display: flex; + position: fixed; + inset: 0; + z-index: 50; + overflow-y: auto; + padding-bottom: 2rem; + /* Le calque prend le focus à l'ouverture : sans cela Échap ne lui parviendrait pas. Le + contour du navigateur n'apprend rien ici, la croix étant visible. */ + outline: none; +} + +.menu-entete { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); +} + +.menu-titre { + color: #e8edf3; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* Cible tactile de 2.25rem, comme les boutons du bandeau : on la touche au pouce. */ +.menu-fermer { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + font-size: 1.15rem; + line-height: 1; + color: #fff; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 6px; + cursor: pointer; +} + +.menu-fermer:hover, +.menu-fermer:focus-visible { + background: rgba(255, 255, 255, 0.15); } .menu-lien { @@ -1373,6 +1490,21 @@ body { padding: 0 0.5rem; } + /* + ⚠️ Le plein écran est ANNULÉ ici : sans cela, un menu ouvert au doigt puis un + agrandissement de la fenêtre laisserait un calque plein écran sur un PC, sans bascule + pour le refermer (elle est masquée à partir de cette largeur). + */ + .menu-ouvert { + position: static; + overflow-y: visible; + padding-bottom: 0; + } + + .menu-entete { + display: none; + } + .menu-lien { padding: 0.6rem 0.9rem; border-left: 0; diff --git a/MaBibli.Client/wwwroot/index.html b/MaBibli.Client/wwwroot/index.html index 6a37ac1..3a2252a 100644 --- a/MaBibli.Client/wwwroot/index.html +++ b/MaBibli.Client/wwwroot/index.html @@ -39,9 +39,6 @@ navigator.serviceWorker (absent hors contexte sécurisé), journalise un échec au lieu de l'avaler, et propose la mise à jour quand une nouvelle version est prête. --> - - diff --git a/MaBibli.Client/wwwroot/js/navigation.js b/MaBibli.Client/wwwroot/js/navigation.js deleted file mode 100644 index 66938bc..0000000 --- a/MaBibli.Client/wwwroot/js/navigation.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - Retour à l'écran précédent. - - ⚠️ `history.back()` seul ne suffit pas : ouvert depuis l'écran d'accueil du téléphone, - l'application démarre sur une pile d'un seul cran, et « retour » sortirait de la PWA au - lieu de remonter d'un écran. On retombe alors sur le catalogue, qui est la racine. - - Volontairement posé sur `window` plutôt qu'exporté en module : l'appel vient de Blazor par - IJSRuntime, et un module imposerait un import asynchrone pour une seule fonction. -*/ -window.mabibliRetour = function () { - if (window.history.length > 1) { - window.history.back(); - return; - } - - window.location.href = '/'; -}; diff --git a/MaBibli.Tests/EnregistrementLivreTests.cs b/MaBibli.Tests/EnregistrementLivreTests.cs new file mode 100644 index 0000000..efb0af1 --- /dev/null +++ b/MaBibli.Tests/EnregistrementLivreTests.cs @@ -0,0 +1,82 @@ +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; + +namespace MaBibli.Tests; + +/// +/// La vue « une seule ligne » du champ des auteurs se relie à chaque frappe : elle +/// reconstruit la liste entière à chaque caractère tapé. +/// +/// +/// ⚠️ Sans reprise des rôles déjà choisis, corriger une faute de frappe dans le champ des noms +/// remettrait tous les rôles à « non précisé » sous les yeux de l'utilisateur. Le lot Q remonte +/// les rôles juste après les auteurs dans le formulaire, ce qui rend la perte d'autant plus +/// visible : ces tests verrouillent l'invariant que le réordonnancement ne doit pas entamer. +/// +public class EnregistrementLivreTests +{ + [Fact] + public void Une_frappe_dans_le_champ_des_noms_preserve_les_roles() + { + var saisie = new EnregistrementLivre { Auteur = "Dobbs ; Christophe Regnault" }; + saisie.Auteurs[0] = saisie.Auteurs[0] with { Role = RoleAuteur.Scenario }; + saisie.Auteurs[1] = saisie.Auteurs[1] with { Role = RoleAuteur.Dessin }; + + // L'utilisateur ajoute un troisième auteur : le champ entier est relu. + saisie.Auteur = "Dobbs ; Christophe Regnault ; Marie Coloriste"; + + Assert.Equal(RoleAuteur.Scenario, saisie.Auteurs[0].Role); + Assert.Equal(RoleAuteur.Dessin, saisie.Auteurs[1].Role); + Assert.Equal(RoleAuteur.NonPrecise, saisie.Auteurs[2].Role); + } + + /// Le rapprochement se fait sur la forme normalisée : un accent ajouté après coup + /// ne doit pas coûter son rôle à l'auteur. + [Fact] + public void Un_accent_ajoute_apres_coup_ne_perd_pas_le_role() + { + var saisie = new EnregistrementLivre { Auteur = "Dobbs ; Christophe Regnault" }; + saisie.Auteurs[1] = saisie.Auteurs[1] with { Role = RoleAuteur.Dessin }; + + saisie.Auteur = "Dobbs ; Christophé Regnault"; + + Assert.Equal(RoleAuteur.Dessin, saisie.Auteurs[1].Role); + } + + /// Un nom réellement différent repart à zéro : on ne devine pas que c'est la même + /// personne. + [Fact] + public void Un_nom_different_repart_sans_role() + { + var saisie = new EnregistrementLivre { Auteur = "Dobbs ; Christophe Regnault" }; + saisie.Auteurs[1] = saisie.Auteurs[1] with { Role = RoleAuteur.Dessin }; + + saisie.Auteur = "Dobbs ; Jean Dupont"; + + Assert.Equal(RoleAuteur.NonPrecise, saisie.Auteurs[1].Role); + } + + /// + /// Les rôles ne s'affichent qu'à partir de deux auteurs (règle actée, inchangée par le lot Q) : + /// c'est le nombre d'auteurs qui commande, pas le type de document. + /// + [Theory] + [InlineData("Émile Zola", 1)] + [InlineData("Dobbs ; Christophe Regnault", 2)] + [InlineData(" ; Dobbs ; ; ", 1)] + [InlineData("", 0)] + public void Le_nombre_dauteurs_est_celui_des_entrees_non_vides(string ligne, int attendu) => + Assert.Equal(attendu, new EnregistrementLivre { Auteur = ligne }.Auteurs.Count); + + /// Le type de document reste « non précisé » tant que personne ne l'a choisi — le + /// lot Q remonte le champ, il ne le préremplit pas. + [Fact] + public void Le_type_de_document_ne_pretend_rien_par_defaut() => + Assert.Equal(TypeDocument.NonPrecise, new EnregistrementLivre().TypeDocument); + + /// Le format, lui, DOIT rester physique par défaut : dans les listes, l'absence + /// d'étiquette signifie « physique ». + [Fact] + public void Le_format_reste_physique_par_defaut() => + Assert.Equal(Format.Physique, new EnregistrementLivre().Format); +} diff --git a/MaBibli.Tests/RemonteeRoutesTests.cs b/MaBibli.Tests/RemonteeRoutesTests.cs new file mode 100644 index 0000000..d114f13 --- /dev/null +++ b/MaBibli.Tests/RemonteeRoutesTests.cs @@ -0,0 +1,101 @@ +using MaBibli.Client.Services; + +namespace MaBibli.Tests; + +/// +/// La flèche de retour du bandeau remonte d'un cran de route, et ne sort jamais de +/// l'application. Ces cas sont ceux qui ont été écrits noir sur blanc avant de coder. +/// +public class RemonteeRoutesTests +{ + [Theory] + // Fiche livre + [InlineData("/livres/3/edition", "/livres/3")] + [InlineData("/livres/3", "/")] + // Séries + [InlineData("/series/7/edition", "/series/7")] + [InlineData("/series/7/ordre", "/series/7")] + [InlineData("/series/7", "/series")] + [InlineData("/series", "/")] + // Envies + [InlineData("/souhaits/ordre", "/souhaits")] + [InlineData("/souhaits/ajout", "/souhaits")] + [InlineData("/souhaits", "/")] + // Ajout d'un ouvrage + [InlineData("/ajout", "/")] + [InlineData("/ajout/isbn", "/")] + [InlineData("/ajout/manuel", "/")] + // Revues + [InlineData("/revues/ajout", "/revues")] + [InlineData("/revues/12", "/revues")] + [InlineData("/revues", "/")] + // Auteurs + [InlineData("/auteurs/5/bibliographie", "/auteurs")] + [InlineData("/auteurs", "/")] + // Le reste du menu + [InlineData("/prets", "/")] + [InlineData("/", "/")] + public void Remonte_dun_cran(string chemin, string attendu) => + Assert.Equal(attendu, RemonteeRoutes.Parent(chemin)); + + /// + /// « ajout » n'est pas un identifiant : sans l'ordre de lecture de la table, il serait pris + /// pour la fiche d'une revue. + /// + [Fact] + public void Ajout_de_revue_nest_pas_une_fiche() => + Assert.Equal("/revues", RemonteeRoutes.Parent("/revues/ajout")); + + /// La requête d'un filtre ou d'un tri ne change pas la parenté. + [Theory] + [InlineData("/?auteur=3", "/")] + [InlineData("/livres/3/edition?onglet=pret", "/livres/3")] + [InlineData("/auteurs/5/bibliographie#bas", "/auteurs")] + public void La_requete_et_le_fragment_sont_ignores(string chemin, string attendu) => + Assert.Equal(attendu, RemonteeRoutes.Parent(chemin)); + + /// Le chemin peut arriver relatif à la base, sans barre oblique de tête. + [Theory] + [InlineData("livres/3/edition", "/livres/3")] + [InlineData("series/7/ordre", "/series/7")] + [InlineData("", "/")] + public void Un_chemin_relatif_est_accepte(string chemin, string attendu) => + Assert.Equal(attendu, RemonteeRoutes.Parent(chemin)); + + /// Une barre oblique finale ne doit pas faire manquer la ligne de table. + [Fact] + public void La_barre_finale_est_ignoree() => + Assert.Equal("/livres/3", RemonteeRoutes.Parent("/livres/3/edition/")); + + /// + /// Repli d'une route inconnue : la destination de menu de sa branche, jamais un chemin + /// deviné, et jamais rien d'externe. + /// + [Theory] + [InlineData("/revues/12/edition", "/revues")] + [InlineData("/series/7/inconnu", "/series")] + [InlineData("/inconnu/total", "/")] + [InlineData("/livres", "/")] + public void Une_route_inconnue_retombe_sur_sa_branche(string chemin, string attendu) => + Assert.Equal(attendu, RemonteeRoutes.Parent(chemin)); + + /// + /// ⚠️ Le garde-fou du lot S : quoi qu'on lui donne, le retour reste dans l'application. + /// Une URL absolue ou un chemin protocolaire ne doit jamais ressortir tel quel. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://exemple.test/livres/3")] + [InlineData("//exemple.test/")] + [InlineData("/livres/abc/edition")] + public void Ne_sort_jamais_de_lapplication(string? chemin) + { + var parent = RemonteeRoutes.Parent(chemin); + + Assert.StartsWith("/", parent); + Assert.DoesNotContain("//", parent); + Assert.DoesNotContain(":", parent); + } +}