Files
mabibli/MaBibli.Client/Composants/ChampsNumeroRevue.razor
T
mathieuandClaude Opus 5 e46fa99e3b Prévient l'écran quand la saisie d'un numéro change sous ses pieds
« Ajouter ce numéro » restait grisé quel que soit le numéro tapé, et ne se
dégrisait qu'après un détour par « Modifier la revue » puis « Enregistrer ».
Ce n'était pas une validation fautive : Blazor ne redessine que le composant
qui a traité l'événement, et le bouton vit dans l'écran quand le champ vit
dans le composant. Le détour redessinait le parent, rien de plus.

Les champs du numéro passent donc tous par une propriété qui invoque
OnChangement — y compris ceux dont aucun bouton ne dépend aujourd'hui, pour
que la correction ne s'oublie pas au premier champ ajouté.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 23:41:16 +02:00

302 lines
11 KiB
Plaintext

@*
Les champs d'un numéro de revue, partagés par l'ajout et la modification.
Un seul exemplaire, et pas deux blocs jumeaux : le formulaire d'ajout et celui de
modification doivent proposer exactement les mêmes champs, sinon une couverture ou une une
saisies à la création ne seraient plus modifiables ensuite — ce qui est précisément le cas
d'usage (on ajoute un numéro le jour où on l'achète, on en recopie la une plus tard).
⚠️ Aucun champ ne se lie DIRECTEMENT à « Saisie » : chacun passe par une propriété qui
prévient le parent (OnChangement). Blazor ne redessine que le composant qui a traité
l'événement — une frappe ici ne re-rendait donc pas l'écran qui porte le bouton
« Ajouter ce numéro », et celui-ci restait grisé jusqu'à ce qu'autre chose redessine le
parent (enregistrer la revue, par exemple). Voir CLAUDE.md.
*@
<div class="champs-numero">
<label class="champ">
<span class="champ-libelle">Numéro</span>
<input class="champ-saisie" type="text" placeholder="43" @bind="Numero"
@bind:event="oninput" @onkeydown="SurTouche" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Parution <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="date" @bind="Parution" />
</label>
<label class="champ">
<span class="champ-libelle">Note <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" @bind="Note" @bind:event="oninput"
placeholder="dossier sur l'eau" autocomplete="off" />
</label>
</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.
*@
<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>
@*
⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne
la revue, pas la parution. Et le projet ne stocke aucun fichier — « ebooks : fiches
uniquement » vaut ici aussi.
*@
<label class="champ">
<span class="champ-libelle">
Couverture <span class="champ-aide">(adresse d'une image, facultatif)</span>
</span>
<input class="champ-saisie" type="url" @bind="CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
@code {
[Parameter, EditorRequired] public AjoutNumeroRevue Saisie { get; set; } = default!;
/// <summary>
/// Signale au parent que la saisie a changé, pour qu'il se redessine.
/// </summary>
/// <remarks>
/// ⚠️ <b>Sans cela, un bouton du parent qui dépend de la saisie reste figé.</b> Blazor ne
/// redessine que le composant ayant traité l'événement : « Ajouter ce numéro » vit dans
/// l'écran de la revue, le champ « Numéro » vit ici, et le bouton restait donc désactivé
/// tant que rien d'autre ne re-rendait le parent — constaté en usage, où il fallait passer
/// par « Modifier la revue » puis « Enregistrer » pour le débloquer.
/// <para>
/// Tous les champs préviennent, pas seulement le numéro : aucun autre bouton n'en dépend
/// aujourd'hui, mais faire dépendre la correction de « lequel commande quoi » la referait
/// oublier au premier champ ajouté.
/// </para>
/// </remarks>
[Parameter] public EventCallback OnChangement { get; set; }
/// <summary>
/// Les champs du numéro, servis par des propriétés plutôt que liés à <see cref="Saisie"/> :
/// c'est le seul endroit d'où prévenir le parent à coup sûr.
/// </summary>
private string Numero
{
get => Saisie.Numero;
set { Saisie.Numero = value; Notifier(); }
}
private string? Note
{
get => Saisie.Note;
set { Saisie.Note = value; Notifier(); }
}
private string? CoverUrl
{
get => Saisie.CoverUrl;
set { Saisie.CoverUrl = value; Notifier(); }
}
/// <remarks>
/// Le parent est prévenu sans être attendu : <c>InvokeAsync</c> ne fait que demander un
/// nouveau rendu, et un setter de propriété ne peut de toute façon rien attendre.
/// </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>
/// <remarks>
/// ⚠️ Le <c>&lt;input type="date"&gt;</c> produit une date sans fuseau. Elle est déclarée
/// locale ici et convertie en UTC, comme toutes les dates du projet : sans cela la parution
/// se décale d'un jour pour la moitié du globe. La conversion se fait dans le composant
/// pour que les deux écrans qui s'en servent ne puissent pas l'oublier chacun de leur côté.
/// </remarks>
private DateTime? Parution
{
get => Saisie.DateParution?.ToLocalTime().Date;
set
{
Saisie.DateParution = value is { } date
? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime()
: null;
Notifier();
}
}
[Parameter] public EventCallback<KeyboardEventArgs> OnEntree { get; set; }
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await OnEntree.InvokeAsync(e);
}
}
}