Fait saisir un par un ce qui se saisissait sur une ligne à séparateurs

Les thèmes et les auteurs souffraient du défaut corrigé hier sur les unes :
un champ relié à chaque frappe sur une propriété qui découpait puis
recomposait la liste, donc un séparateur intapable. Ils passent au même
geste — écrire, Entrée, puis modifier ou retirer chaque ligne — et le
composant est partagé plutôt que recopié une troisième fois.

Le bloc des rôles se fond dans la liste des auteurs : il listait les mêmes
noms une seconde fois. Renommer une ligne conserve son rôle, parce qu'on
corrige un nom au lieu de reconstruire la liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-22 00:11:37 +02:00
co-authored by Claude Opus 5
parent e46fa99e3b
commit 75f962ab46
7 changed files with 419 additions and 264 deletions
@@ -32,77 +32,15 @@
</div>
@*
⚠️ Un titre à la fois, et surtout PAS une ligne de titres séparés par des points-virgules.
Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis recomposait
la liste : la saisie était donc réécrite sous les doigts — un point-virgule tapé disparaissait
(il ouvrait une valeur vide, aussitôt élaguée), et l'espace qui le suit était rogné par le
même élagage. Autrement dit, le séparateur du champ était le seul caractère qu'on ne pouvait
pas y taper.
Ici la liste est la donnée : on écrit un titre, Entrée l'y range, et chaque titre se modifie
ou se retire ensuite. Une virgule (« Ukraine, deux ans après ») n'a plus rien à couper,
puisque plus rien ne découpe.
⚠️ Un titre à la fois, et surtout PAS une ligne de titres séparés par des points-virgules :
voir SaisieListe, qui porte le pourquoi. Une virgule dans un titre (« Ukraine, deux ans
après ») n'a plus rien à couper, puisque plus rien ne découpe.
*@
<div class="champ">
<label class="champ-libelle" for="@_idChamp">
À la une <span class="champ-aide">(un titre à la fois : tapez-le, puis Entrée)</span>
</label>
<div class="ligne-creation">
<input id="@_idChamp" class="champ-saisie" type="text" @bind="_titreSaisi"
@bind:event="oninput" @onkeydown="SurToucheArticle"
placeholder="Ukraine, deux ans après" autocomplete="off" />
<button type="button" class="bouton bouton-compact"
disabled="@(string.IsNullOrWhiteSpace(_titreSaisi))" @onclick="ValiderArticle">
@(_indexModifie is null ? "Ajouter" : "Enregistrer")
</button>
@if (_indexModifie is not null)
{
<button type="button" class="bouton bouton-compact bouton-discret"
@onclick="AnnulerModification">
Annuler
</button>
}
</div>
@if (_messageArticle is not null)
{
<p class="message-discret" role="status">@_messageArticle</p>
}
@if (Saisie.Articles.Count > 0)
{
<ul class="liste-unes">
@for (var rang = 0; rang < Saisie.Articles.Count; rang++)
{
@* La copie locale est indispensable : sans elle, les fermetures des deux
boutons captureraient la variable de boucle, donc son état final. *@
var index = rang;
var titre = Saisie.Articles[rang];
<li class="ligne-une" @key="index">
<span class="ligne-une-titre">@titre</span>
<div class="actions-tome">
<button type="button" class="bouton bouton-compact"
aria-label="@($"Modifier « {titre} »")"
@onclick="() => ModifierArticle(index)">
Modifier
</button>
<button type="button" class="bouton bouton-compact bouton-danger"
aria-label="@($"Supprimer « {titre} »")"
@onclick="() => SupprimerArticle(index)">
Supprimer
</button>
</div>
</li>
}
</ul>
}
</div>
<SaisieListe Valeurs="Saisie.Articles" Libelle="À la une"
Aide=" (un titre à la fois : tapez-le, puis Entrée)"
Placeholder="Ukraine, deux ans après"
MessageDoublon="Ce titre est déjà à la une de ce numéro."
OnChangement="Notifier" />
@*
⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne
@@ -165,108 +103,6 @@
/// </remarks>
private void Notifier() => _ = OnChangement.InvokeAsync();
/// <summary>Identifiant du champ « à la une », pour que son libellé le désigne vraiment.</summary>
private readonly string _idChamp = $"une-{Guid.NewGuid():N}";
/// <summary>Titre en cours de frappe. <b>Il n'est relié à rien d'autre</b> : voir plus haut.</summary>
private string? _titreSaisi;
/// <summary>Rang du titre qu'on modifie — <c>null</c> = on en ajoute un nouveau.</summary>
private int? _indexModifie;
private string? _messageArticle;
/// <summary>Saisie servie au rendu précédent, pour repérer que le parent en a changé.</summary>
private AjoutNumeroRevue? _saisiePrecedente;
/// <summary>
/// Le parent remplace la saisie entière (numéro enregistré, autre numéro ouvert en
/// modification) : ce qui restait dans le champ appartenait au numéro d'avant.
/// </summary>
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
RemettreAZero();
}
}
private void RemettreAZero()
{
_titreSaisi = null;
_indexModifie = null;
_messageArticle = null;
}
/// <summary>
/// Entrée range le titre, comme le bouton.
/// </summary>
/// <remarks>
/// ⚠️ Cette touche ne remonte <b>pas</b> à <c>OnEntree</c> : celui-ci enregistre le numéro
/// entier, et l'on serait alors bien en peine de saisir deux titres à la une.
/// </remarks>
private void SurToucheArticle(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
ValiderArticle();
}
}
private void ValiderArticle()
{
var titre = _titreSaisi?.Trim();
if (string.IsNullOrEmpty(titre))
{
return;
}
// Même règle de doublon que partout ailleurs : au sens de la normalisation, et le
// libellé déjà rangé est celui qui reste.
var cle = NormalisationTexte.Normaliser(titre);
var deja = Saisie.Articles.FindIndex(a => NormalisationTexte.Normaliser(a) == cle);
if (deja >= 0 && deja != _indexModifie)
{
_messageArticle = "Ce titre est déjà à la une de ce numéro.";
return;
}
if (_indexModifie is { } index)
{
Saisie.Articles[index] = titre;
}
else
{
Saisie.Articles.Add(titre);
}
RemettreAZero();
Notifier();
}
/// <summary>Reprend un titre dans le champ : on le corrige là où on l'a écrit.</summary>
private void ModifierArticle(int index)
{
_messageArticle = null;
_indexModifie = index;
_titreSaisi = Saisie.Articles[index];
}
private void AnnulerModification() => RemettreAZero();
private void SupprimerArticle(int index)
{
Saisie.Articles.RemoveAt(index);
// Le titre qu'on modifiait vient peut-être de disparaître, et les rangs suivants ont
// glissé : reprendre la modification écrirait sur le mauvais titre.
RemettreAZero();
Notifier();
}
/// <summary>
/// Date affichée par le champ, en <b>local</b>.
/// </summary>
+59 -51
View File
@@ -21,9 +21,9 @@
@*
⚠️ 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
Type de document, puis thèmes, puis auteurs (chacun avec son rôle) : 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
@@ -46,61 +46,48 @@
</div>
</fieldset>
<label class="champ">
@* Point-virgule, comme le champ des auteurs juste en dessous : trois champs voisins du
même formulaire ne doivent pas se saisir de trois façons. La virgule reste acceptée —
un thème n'en contient jamais, contrairement à un titre d'article à la une. *@
<span class="champ-libelle">
Thèmes <span class="champ-aide">(séparés par des points-virgules)</span>
</span>
<input class="champ-saisie" type="text" @bind="Saisie.ThemesTexte" @bind:event="oninput"
placeholder="Fantasy ; aventure ; space opera" autocomplete="off" />
</label>
@*
⚠️ Thèmes et auteurs se saisissent UN PAR UN, et non plus sur une ligne à séparateurs.
Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis
recomposait la liste : le point-virgule tapé y était élagué (entrée vide) et l'espace
qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
Voir CLAUDE.md.
*@
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà sur ce livre."
OnChangement="StateHasChanged" />
@*
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.
Le serveur rapproche ensuite les variantes sûres d'un auteur déjà connu.
*@
<label class="champ">
<span class="champ-libelle">
Auteur<span class="champ-aide"> (plusieurs : séparez par « ; »)</span>
</span>
<input class="champ-saisie" type="text" @bind="Saisie.Auteur" @bind:event="oninput"
placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" />
</label>
Un auteur par ligne, et son RÔLE avec lui.
@*
Les rôles n'apparaissent qu'à partir de DEUX auteurs, et c'est délibéré : un roman n'a
qu'un signataire dont le rôle ne se pose pas, et une ligne de boutons sous chaque saisie
n'apprendrait rien. La question « qui écrit, qui dessine ? » ne se pose qu'à plusieurs.
Le défaut reste « non précisé » : on ne remplit pas une case à la place de l'utilisateur.
*@
@if (Saisie.Auteurs.Count > 1)
{
<fieldset class="champ">
<legend class="champ-libelle">
Rôles<span class="champ-aide"> (facultatif — utile pour une BD)</span>
</legend>
⚠️ Le rôle vit sur le LIEN, pas sur la personne : le même auteur scénarise un album et
dessine le suivant. C'est aussi pourquoi renommer une ligne conserve son rôle
(NomsAuteurs) — on corrige un nom, on ne désigne pas quelqu'un d'autre.
@foreach (var (auteur, index) in Saisie.Auteurs.Select((a, i) => (a, i)))
⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman
n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ».
*@
<SaisieListe Valeurs="_noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
Placeholder="Prénom Nom, ou Nom, Prénom"
MessageDoublon="Cet auteur est déjà sur ce livre."
OnChangement="StateHasChanged">
<Complement Context="index">
@if (Saisie.Auteurs.Count > 1)
{
<div class="ligne-role" @key="index">
<span class="nom-role">@auteur.Nom</span>
<div class="segments">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(auteur.Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
<div class="segments segments-role" role="group"
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
}
</fieldset>
}
</Complement>
</SaisieListe>
@*
Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc
@@ -212,6 +199,27 @@
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
/// <summary>
/// Les noms des auteurs, vus comme une liste de chaînes par <c>SaisieListe</c>.
/// </summary>
/// <remarks>
/// ⚠️ Sa <b>référence doit rester stable</b> tant que la saisie ne change pas : le composant
/// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à
/// chaque rendu ce qui est en cours de frappe.
/// </remarks>
private NomsAuteurs _noms = default!;
private EnregistrementLivre? _saisiePrecedente;
protected override void OnParametersSet()
{
if (!ReferenceEquals(_saisiePrecedente, Saisie))
{
_saisiePrecedente = Saisie;
_noms = new NomsAuteurs(Saisie.Auteurs);
}
}
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer";
+80
View File
@@ -0,0 +1,80 @@
using System.Collections;
using MaBibli.Shared.Dtos;
namespace MaBibli.Client.Composants;
/// <summary>
/// Vue « liste de noms » des auteurs d'une saisie, pour que <c>SaisieListe</c> les remplisse
/// comme il remplit les thèmes.
/// </summary>
/// <remarks>
/// ⚠️ <b>Un auteur n'est pas un nom : il porte aussi son rôle sur ce livre</b> (scénario,
/// dessin…), qui vit sur le lien et non sur la personne. Renommer par l'indexeur <b>conserve
/// donc le rôle</b> — c'est ce qui remplace la reprise des rôles que faisait
/// <c>EnregistrementLivre.Decouper</c> quand le champ des noms se reconstruisait à chaque
/// frappe : ici la ligne n'est pas reconstruite, elle est corrigée.
/// <para>
/// La liste sous-jacente est celle de la saisie, modifiée en place : cette vue n'en garde
/// aucune copie qui pourrait diverger.
/// </para>
/// </remarks>
public sealed class NomsAuteurs(IList<AuteurSaisi> auteurs) : IList<string>
{
public int Count => auteurs.Count;
public bool IsReadOnly => false;
public string this[int index]
{
get => auteurs[index].Nom;
set => auteurs[index] = auteurs[index] with { Nom = value };
}
public void Add(string nom) => auteurs.Add(new AuteurSaisi(nom));
public void Insert(int index, string nom) => auteurs.Insert(index, new AuteurSaisi(nom));
public void RemoveAt(int index) => auteurs.RemoveAt(index);
public void Clear() => auteurs.Clear();
public bool Contains(string nom) => IndexOf(nom) >= 0;
public int IndexOf(string nom)
{
for (var i = 0; i < auteurs.Count; i++)
{
if (auteurs[i].Nom == nom)
{
return i;
}
}
return -1;
}
public bool Remove(string nom)
{
var index = IndexOf(nom);
if (index < 0)
{
return false;
}
auteurs.RemoveAt(index);
return true;
}
public void CopyTo(string[] tableau, int index)
{
foreach (var auteur in auteurs)
{
tableau[index++] = auteur.Nom;
}
}
public IEnumerator<string> GetEnumerator() => auteurs.Select(a => a.Nom).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
+208
View File
@@ -0,0 +1,208 @@
@*
Un champ qui remplit une LISTE : on écrit une valeur, Entrée la range, et chaque ligne se
modifie ou se retire ensuite.
⚠️ Il remplace partout le « champ unique à séparateur » (thèmes, articles à la une), qui
était intapable : relié à chaque frappe sur une propriété qui découpait puis recomposait la
ligne, il élaguait le point-virgule tapé (entrée vide) et rognait l'espace qui le suit. Le
seul caractère qu'on ne pouvait pas y taper était son propre séparateur. Voir CLAUDE.md.
⚠️ Le composant écrit dans une liste PRÊTÉE par son parent : il doit donc le prévenir
(OnChangement), sinon tout ce que le parent calcule à partir d'elle reste figé.
*@
<div class="champ">
<label class="champ-libelle" for="@_idChamp">
@Libelle
@if (!string.IsNullOrWhiteSpace(Aide))
{
<span class="champ-aide">@Aide</span>
}
</label>
<div class="ligne-creation">
<input id="@_idChamp" class="champ-saisie" type="text" @bind="_saisie"
@bind:event="oninput" @onkeydown="SurTouche"
placeholder="@Placeholder" autocomplete="off" />
<button type="button" class="bouton bouton-compact"
disabled="@(string.IsNullOrWhiteSpace(_saisie))" @onclick="Valider">
@(_indexModifie is null ? "Ajouter" : "Enregistrer")
</button>
@if (_indexModifie is not null)
{
<button type="button" class="bouton bouton-compact bouton-discret"
@onclick="Annuler">
Annuler
</button>
}
</div>
@if (_message is not null)
{
<p class="message-discret" role="status">@_message</p>
}
@if (Valeurs.Count > 0)
{
<ul class="liste-saisie">
@for (var rang = 0; rang < Valeurs.Count; rang++)
{
@* La copie locale est indispensable : sans elle, les fermetures des boutons
captureraient la variable de boucle, donc son état final. *@
var index = rang;
var valeur = Valeurs[rang];
<li class="ligne-saisie" @key="index">
<span class="ligne-saisie-valeur">@valeur</span>
@* Ce que le parent veut poser sur la ligne — les rôles d'un auteur, par
exemple. Rien par défaut. *@
@Complement?.Invoke(index)
<div class="actions-tome">
<button type="button" class="bouton bouton-compact"
aria-label="@($"Modifier « {valeur} »")"
@onclick="() => Modifier(index)">
Modifier
</button>
<button type="button" class="bouton bouton-compact bouton-danger"
aria-label="@($"Supprimer « {valeur} »")"
@onclick="() => Supprimer(index)">
Supprimer
</button>
</div>
</li>
}
</ul>
}
</div>
@code {
/// <summary>La liste remplie, modifiée <b>en place</b> : c'est celle du parent.</summary>
[Parameter, EditorRequired] public IList<string> Valeurs { get; set; } = default!;
[Parameter, EditorRequired] public string Libelle { get; set; } = default!;
[Parameter] public string? Aide { get; set; }
[Parameter] public string? Placeholder { get; set; }
/// <summary>Message affiché quand la valeur tapée est déjà dans la liste.</summary>
[Parameter] public string MessageDoublon { get; set; } = "C'est déjà dans la liste.";
/// <summary>
/// Ce que le parent ajoute à chaque ligne — les rôles d'un auteur, par exemple.
/// </summary>
[Parameter] public RenderFragment<int>? Complement { get; set; }
/// <summary>
/// Prévient le parent que la liste a changé.
/// </summary>
/// <remarks>
/// ⚠️ Sans cela, un bouton du parent qui dépend de la liste reste figé : Blazor ne redessine
/// que le composant qui a traité l'événement.
/// </remarks>
[Parameter] public EventCallback OnChangement { get; set; }
private readonly string _idChamp = $"liste-{Guid.NewGuid():N}";
/// <summary>Valeur en cours de frappe. <b>Elle n'est reliée à rien d'autre</b>.</summary>
private string? _saisie;
/// <summary>Rang de la valeur qu'on modifie — <c>null</c> = on en ajoute une nouvelle.</summary>
private int? _indexModifie;
private string? _message;
/// <summary>Liste servie au rendu précédent, pour repérer que le parent en a changé.</summary>
private IList<string>? _precedente;
/// <summary>
/// Le parent a changé de liste (autre fiche, formulaire vidé après enregistrement) : ce qui
/// restait dans le champ appartenait à la précédente.
/// </summary>
protected override void OnParametersSet()
{
if (!ReferenceEquals(_precedente, Valeurs))
{
_precedente = Valeurs;
RemettreAZero();
}
}
private void RemettreAZero()
{
_saisie = null;
_indexModifie = null;
_message = null;
}
/// <remarks>
/// ⚠️ Cette touche ne remonte pas au parent : celui-ci enregistre la fiche entière, et l'on
/// serait bien en peine de saisir deux valeurs.
/// </remarks>
private void SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
Valider();
}
}
private void Valider()
{
var valeur = _saisie?.Trim();
if (string.IsNullOrEmpty(valeur))
{
return;
}
// Doublon au sens de la normalisation, comme partout ailleurs : le libellé déjà rangé
// est celui qui reste.
var cle = NormalisationTexte.Normaliser(valeur);
var deja = Valeurs.ToList().FindIndex(v => NormalisationTexte.Normaliser(v) == cle);
if (deja >= 0 && deja != _indexModifie)
{
_message = MessageDoublon;
return;
}
if (_indexModifie is { } index)
{
Valeurs[index] = valeur;
}
else
{
Valeurs.Add(valeur);
}
RemettreAZero();
Notifier();
}
/// <summary>Reprend une valeur dans le champ : on la corrige là où on l'a écrite.</summary>
private void Modifier(int index)
{
_message = null;
_indexModifie = index;
_saisie = Valeurs[index];
}
private void Annuler() => RemettreAZero();
private void Supprimer(int index)
{
Valeurs.RemoveAt(index);
// La valeur qu'on modifiait vient peut-être de disparaître, et les rangs suivants ont
// glissé : reprendre la modification écrirait sur la mauvaise.
RemettreAZero();
Notifier();
}
private void Notifier() => _ = OnChangement.InvokeAsync();
}