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
+33
View File
@@ -3237,6 +3237,39 @@ grisé à champ vide, dégrisé dès la première frappe, regrisé si l'on effac
617 tests au vert (618 avant : le test du découpage des unes n'a plus d'objet). 617 tests au vert (618 avant : le test du découpage des unes n'a plus d'objet).
### Thèmes et auteurs ont suivi — `SaisieListe`, un composant pour les trois (2026-08-22)
Les deux champs signalés comme « même mécanique, même défaut » ont été traités de la même
façon, et la troisième copie a été évitée : `Composants/SaisieListe.razor` porte le champ, la
liste, `Modifier`, `Supprimer`, le refus du doublon et la notification du parent. Les articles
à la une, les thèmes et les auteurs s'en servent.
⚠️ **Le fieldset « Rôles » a disparu, fondu dans la liste des auteurs.** Il y avait désormais
deux endroits qui listaient les mêmes noms. Les segments de rôle vivent sur la ligne de leur
auteur — et **la règle « seulement à partir de deux auteurs » est inchangée**, comme le défaut
`NonPrecise`.
⚠️ **Renommer un auteur conserve son rôle, par construction.** `NomsAuteurs` est une vue
`IList<string>` sur `EnregistrementLivre.Auteurs` dont l'indexeur fait `with { Nom = value }` :
on **corrige une ligne**, on ne la reconstruit pas. C'est ce qui remplace la reprise des rôles
par nom normalisé de `EnregistrementLivre.Decouper` — celle-ci n'existait que parce que le champ
se reconstruisait à chaque frappe. Elle reste en place pour le seul **préremplissage** depuis
une notice, qui donne les auteurs sur une ligne en une affectation.
⚠️ **La référence de la liste prêtée doit rester stable.** `SaisieListe` se remet à zéro quand
elle change d'identité (c'est ainsi qu'un formulaire vidé ou une autre fiche ne laissent pas
traîner une saisie) : construire la vue `NomsAuteurs` à chaque rendu ferait perdre ce qui est en
cours de frappe à chaque caractère. Elle n'est donc refaite que lorsque `Saisie` change.
`ThemesTexte` a disparu comme `ArticlesTexte`. `Auteur` reste, **uniquement pour le
préremplissage** — son commentaire le dit, pour qu'on ne la relie pas de nouveau à un `<input>`.
**Vérifié en exécution** (saisie manuelle puis édition d'une fiche, 320 px) : thèmes et auteurs
rangés un par un, doublon de casse refusé avec son message, rôles posés puis **conservés** en
corrigeant « Christophe » en « Christophé », livre enregistré et relu par l'API avec ses deux
rôles (`Scénario`, `Dessin`) et ses deux thèmes, fiche rouverte en édition avec ses listes
garnies, aucun débordement horizontal.
## La 7ᵉ série vérifiée en navigateur — trois défauts, tous invisibles aux tests (2026-08-21) ## La 7ᵉ série vérifiée en navigateur — trois défauts, tous invisibles aux tests (2026-08-21)
La série Q à Y s'était close sur un aveu : « **aucun** écran n'a été regardé s'afficher ». Le La série Q à Y s'était close sur un aveu : « **aucun** écran n'a été regardé s'afficher ». Le
@@ -32,77 +32,15 @@
</div> </div>
@* @*
⚠️ Un titre à la fois, et surtout PAS une ligne de titres séparés par des points-virgules. ⚠️ 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 voir SaisieListe, qui porte le pourquoi. Une virgule dans un titre (« Ukraine, deux ans
la liste : la saisie était donc réécrite sous les doigts — un point-virgule tapé disparaissait après ») n'a plus rien à couper, puisque plus rien ne découpe.
(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.
*@ *@
<div class="champ"> <SaisieListe Valeurs="Saisie.Articles" Libelle="À la une"
<label class="champ-libelle" for="@_idChamp"> Aide=" (un titre à la fois : tapez-le, puis Entrée)"
À la une <span class="champ-aide">(un titre à la fois : tapez-le, puis Entrée)</span> Placeholder="Ukraine, deux ans après"
</label> MessageDoublon="Ce titre est déjà à la une de ce numéro."
OnChangement="Notifier" />
<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>
@* @*
⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne ⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne
@@ -165,108 +103,6 @@
/// </remarks> /// </remarks>
private void Notifier() => _ = OnChangement.InvokeAsync(); 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> /// <summary>
/// Date affichée par le champ, en <b>local</b>. /// Date affichée par le champ, en <b>local</b>.
/// </summary> /// </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. ⚠️ 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 Type de document, puis thèmes, puis auteurs (chacun avec son rôle) : c'est l'ordre dans
regarde un livre qu'on tient en main, et surtout le type COMMANDE LA SUITE de la saisie — lequel on regarde un livre qu'on tient en main, et surtout le type COMMANDE LA SUITE de
les rôles ne se posent que là. Relégué en bas, il obligeait à descendre tout le formulaire 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. 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 ⚠️ Remonter le champ le rend visible ; le PRÉREMPLIR écrirait quelque chose de faux. Le
@@ -46,61 +46,48 @@
</div> </div>
</fieldset> </fieldset>
<label class="champ"> @*
@* Point-virgule, comme le champ des auteurs juste en dessous : trois champs voisins du ⚠️ Thèmes et auteurs se saisissent UN PAR UN, et non plus sur une ligne à séparateurs.
même formulaire ne doivent pas se saisir de trois façons. La virgule reste acceptée — Le champ unique se reliait à chaque frappe sur une propriété qui découpait puis
un thème n'en contient jamais, contrairement à un titre d'article à la une. *@ recomposait la liste : le point-virgule tapé y était élagué (entrée vide) et l'espace
<span class="champ-libelle"> qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
Thèmes <span class="champ-aide">(séparés par des points-virgules)</span> Voir CLAUDE.md.
</span> *@
<input class="champ-saisie" type="text" @bind="Saisie.ThemesTexte" @bind:event="oninput" <SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
placeholder="Fantasy ; aventure ; space opera" autocomplete="off" /> Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà sur ce livre."
</label> OnChangement="StateHasChanged" />
@* @*
Un seul champ, plusieurs auteurs : le point-virgule les sépare. La virgule est déjà Un auteur par ligne, et son RÔLE avec lui.
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>
@* ⚠️ Le rôle vit sur le LIEN, pas sur la personne : le même auteur scénarise un album et
Les rôles n'apparaissent qu'à partir de DEUX auteurs, et c'est délibéré : un roman n'a dessine le suivant. C'est aussi pourquoi renommer une ligne conserve son rôle
qu'un signataire dont le rôle ne se pose pas, et une ligne de boutons sous chaque saisie (NomsAuteurs) — on corrige un nom, on ne désigne pas quelqu'un d'autre.
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>
@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"> <div class="segments segments-role" role="group"
<span class="nom-role">@auteur.Nom</span> aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
<div class="segments"> @foreach (var role in Roles)
@foreach (var role in Roles) {
{ <button type="button"
<button type="button" class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
class="segment @(auteur.Role == role ? "segment-actif" : null)" @onclick="() => PoserRole(index, role)">
@onclick="() => PoserRole(index, role)"> @Libelles.RoleAuteur(role)
@Libelles.RoleAuteur(role) </button>
</button> }
}
</div>
</div> </div>
} }
</fieldset> </Complement>
} </SaisieListe>
@* @*
Le format n'est plus affiché dans les listes que pour les ebooks : un livre y est donc 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) => private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = 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, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer"; [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();
}
+16 -23
View File
@@ -1356,21 +1356,6 @@ body {
font-size: 0.85em; font-size: 0.85em;
} }
/* Un nom, sa rangée de rôles : la ligne se replie sur les écrans étroits plutôt que de
comprimer les boutons, qui doivent rester atteignables au pouce. */
.ligne-role {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.75rem;
margin-top: 0.4rem;
}
.nom-role {
font-weight: 600;
font-size: 0.9rem;
}
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
Menu principal (bandeau) Menu principal (bandeau)
@@ -1614,18 +1599,20 @@ body {
font-style: italic; font-style: italic;
} }
/* Saisie des unes : un titre par ligne, son texte à gauche et ses deux actions à droite. /* Saisie d'une liste (thèmes, auteurs, articles à la une) : une valeur par ligne, son texte
min-width: 0 sur le titre est ce qui fait tenir la ligne à 320 px un titre d'article à gauche et ses deux actions à droite.
min-width: 0 sur la valeur est ce qui fait tenir la ligne à 320 px un titre d'article
est long, et sans lui il pousserait « Supprimer » hors de l'écran (même leçon que la ligne est long, et sans lui il pousserait « Supprimer » hors de l'écran (même leçon que la ligne
de création du lot M1, et que l'arbre des séries). */ de création du lot M1, et que l'arbre des séries). */
.liste-unes { .liste-saisie {
list-style: none; list-style: none;
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
padding: 0; padding: 0;
} }
.ligne-une { .ligne-saisie {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -1633,18 +1620,24 @@ body {
border-bottom: 1px solid var(--mb-bord); border-bottom: 1px solid var(--mb-bord);
} }
.ligne-une-titre { .ligne-saisie-valeur {
flex: 1 1 auto; flex: 1 1 8rem;
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-style: italic;
} }
.ligne-une .actions-tome { .ligne-saisie .actions-tome {
flex: 0 0 auto; flex: 0 0 auto;
margin-top: 0; margin-top: 0;
} }
/* Les rôles d'un auteur passent à la ligne sous son nom dès que la place manque : trois
segments et deux boutons ne tiennent pas sur une ligne de téléphone. */
.segments-role {
flex: 1 1 100%;
margin: 0;
}
/* --- Créer depuis une liste : champ + bouton sur une ligne (lot M1) ---- /* --- Créer depuis une liste : champ + bouton sur une ligne (lot M1) ----
min-width: 0 sur le champ est ce qui fait tenir la ligne à 320 px : sans min-width: 0 sur le champ est ce qui fait tenir la ligne à 320 px : sans
lui, un élément de formulaire refuse de rétrécir sous sa largeur intrinsèque lui, un élément de formulaire refuse de rétrécir sous sa largeur intrinsèque
+15 -18
View File
@@ -67,10 +67,11 @@ public record EnregistrementLivre
/// Vue « une seule ligne » de <see cref="Auteurs"/>, pour les champs de saisie simples. /// Vue « une seule ligne » de <see cref="Auteurs"/>, pour les champs de saisie simples.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Non sérialisée : <see cref="Auteurs"/> est la forme de référence sur le fil. Cette /// ⚠️ <b>Ce n'est plus un champ de saisie</b> : le formulaire remplit la liste un auteur à
/// propriété existe pour que l'interface puisse lier un <c>&lt;input&gt;</c> unique sans /// la fois (voir <c>SaisieListe</c>), un champ relié à chaque frappe sur cette propriété
/// avoir à gérer une liste dynamique — un foyer saisit un auteur dans l'immense majorité /// ayant rendu son propre séparateur intapable. Elle ne sert plus qu'au <b>préremplissage</b>
/// des cas, et les rares livres à quatre auteurs se tapent avec des « ; ». /// depuis une notice, qui donne les auteurs sur une ligne — une seule affectation, jamais
/// une par caractère.
/// </remarks> /// </remarks>
[JsonIgnore] [JsonIgnore]
public string? Auteur public string? Auteur
@@ -79,13 +80,6 @@ public record EnregistrementLivre
set => Auteurs = Decouper(value, Auteurs); set => Auteurs = Decouper(value, Auteurs);
} }
[JsonIgnore]
public string? ThemesTexte
{
get => Themes.Count == 0 ? null : ListeSaisie.Joindre(Themes);
set => Themes = DecouperThemes(value);
}
/// <summary> /// <summary>
/// Découpe une saisie texte en auteurs, en écartant les entrées vides. /// Découpe une saisie texte en auteurs, en écartant les entrées vides.
/// </summary> /// </summary>
@@ -93,10 +87,10 @@ public record EnregistrementLivre
/// Auteurs déjà saisis, dont les <b>rôles</b> sont repris quand le nom réapparaît. /// Auteurs déjà saisis, dont les <b>rôles</b> sont repris quand le nom réapparaît.
/// </param> /// </param>
/// <remarks> /// <remarks>
/// ⚠️ Sans cette reprise, corriger une faute de frappe dans le champ des noms effacerait le /// ⚠️ La reprise des rôles a été écrite pour un champ qui se reconstruisait à chaque frappe.
/// rôle des auteurs voisins : le champ se relie à chaque frappe, et il reconstruirait la /// Ce champ n'existe plus — le formulaire corrige un nom sans toucher à son rôle
/// liste entière à « non précisé ». Le rapprochement se fait sur la forme normalisée du nom, /// (<c>NomsAuteurs</c>) — mais elle reste juste, et gratuite, pour un préremplissage qui
/// pour survivre à un accent ajouté après coup. /// écraserait sinon des rôles déjà posés.
/// </remarks> /// </remarks>
public static List<AuteurSaisi> Decouper(string? saisie, IEnumerable<AuteurSaisi>? connus = null) public static List<AuteurSaisi> Decouper(string? saisie, IEnumerable<AuteurSaisi>? connus = null)
{ {
@@ -120,9 +114,12 @@ public record EnregistrementLivre
} }
/// <summary> /// <summary>
/// Découpe la ligne des thèmes. Séparateur : le <b>point-virgule</b>, comme celui des /// Découpe une ligne de thèmes. Séparateur : le <b>point-virgule</b>, la virgule restant
/// auteurs juste au-dessus dans le même formulaire — la virgule reste acceptée, un thème /// acceptée — un thème n'en contient jamais.
/// n'en contenant jamais. /// <para>
/// ⚠️ Plus aucun champ ne s'y lie : les thèmes se saisissent un par un. Cela reste la façon
/// de relire une ligne venue d'ailleurs.
/// </para>
/// </summary> /// </summary>
public static List<string> DecouperThemes(string? saisie) => public static List<string> DecouperThemes(string? saisie) =>
ListeSaisie.Decouper(saisie); ListeSaisie.Decouper(saisie);