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:
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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><input></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);
|
||||||
|
|||||||
Reference in New Issue
Block a user