Sostituisci la tendina di reparto e motivo con un bottom sheet
La MudSelect apriva un riquadro bianco che galleggiava sopra le card bianche, senza titolo, senza sfondo oscurato, con la barra di scorrimento del desktop e le righe alte quanto una riga di testo. Era la cosa che piu' di tutte faceva sembrare l'app una pagina web. Ora il campo e' una riga tappabile che mostra il valore, e la scelta arriva da un bottom sheet con il titolo del campo e righe da 56 px con il segno tondo della scelta singola: la stessa forma degli altri selettori dell'app, usabile in piedi e con i guanti. Il confronto per capire quale opzione e' quella corrente passa per l'etichetta: Scheda.Reparto ricostruisce l'oggetto al volo da codice e descrizione, quindi non e' mai lo stesso oggetto di quelli in elenco e il confronto di riferimento non avrebbe segnato mai nulla. Reparto e motivo non passano piu' dalla validazione di MudForm, perche' non sono piu' input: il controllo degli obbligatori e' esplicito e l'errore si vede sul gruppo interessato, non solo nel messaggio in cima. Si accende dopo il primo tentativo di salvataggio, non prima: un form rosso all'apertura accusa l'utente di un errore che non ha ancora commesso. La scelta fatta si segna col tono informativo: un bordo corallo intorno a un riempimento azzurro si leggeva come un errore. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
@using SteUp.Shared.Components.Layout.Spinner
|
||||
@using SteUp.Shared.Components.SingleElements.Card.ModalForm
|
||||
@using SteUp.Shared.Components.SingleElements.MessageBox
|
||||
@using SteUp.Shared.Components.SingleElements.Picker
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Entities
|
||||
@using SteUp.Shared.Core.Enum
|
||||
@@ -51,39 +52,45 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()">
|
||||
<MudSelectExtended T="JtbFasiDto?" Variant="Variant.Text"
|
||||
@bind-Value="Scheda.Reparto" ToStringFunc="@(x => x?.Descrizione)"
|
||||
@bind-Value:after="OnAfterChangeReparto" Required="true"
|
||||
Placeholder="Scegli il reparto"
|
||||
RequiredError="Indica il reparto">
|
||||
@foreach (var fasi in SteupDataService.Reparti)
|
||||
{
|
||||
<MudSelectItemExtended Class="custom-item-select" Value="@fasi">
|
||||
@fasi.Descrizione
|
||||
</MudSelectItemExtended>
|
||||
}
|
||||
</MudSelectExtended>
|
||||
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()"
|
||||
Class="@(_showErrors && RepartoMancante ? "form-group--invalid" : "")">
|
||||
<SelectField TValue="JtbFasiDto" Title="Reparto"
|
||||
Items="SteupDataService.Reparti"
|
||||
ItemLabel="@(x => x.Descrizione)"
|
||||
Value="Scheda.Reparto" ValueChanged="OnRepartoChanged"
|
||||
Placeholder="Scegli il reparto" ReadOnly="ReadOnly"/>
|
||||
|
||||
@if (_showErrors && RepartoMancante)
|
||||
{
|
||||
<p class="form-error" role="alert">
|
||||
<i class="ri-error-warning-line" aria-hidden="true"></i>
|
||||
Indica il reparto
|
||||
</p>
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()">
|
||||
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()"
|
||||
Class="@(_showErrors && MotivoMancante ? "form-group--invalid" : "")">
|
||||
@if (Scheda.CodJfas.IsNullOrEmpty())
|
||||
{
|
||||
<p class="t-meta">Scegli prima il reparto.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelectExtended T="string?" Variant="Variant.Text"
|
||||
@bind-Value="Scheda.ActivityTypeId"
|
||||
@bind-Value:after="OnAfterChangeValue"
|
||||
Required="true" Placeholder="Scegli il motivo"
|
||||
RequiredError="Indica il motivo">
|
||||
@foreach (var type in SteupDataService.TipiAttività.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!)))
|
||||
{
|
||||
<MudSelectItemExtended Class="custom-item-select"
|
||||
Value="@type.ActivityTypeId">@type.ActivityTypeId</MudSelectItemExtended>
|
||||
}
|
||||
</MudSelectExtended>
|
||||
<SelectField TValue="string" Title="Motivo"
|
||||
Subtitle="@Scheda.DescrizioneReparto"
|
||||
Items="MotiviDelReparto"
|
||||
ItemLabel="@(x => x)"
|
||||
Value="Scheda.ActivityTypeId" ValueChanged="OnMotivoChanged"
|
||||
Placeholder="Scegli il motivo" ReadOnly="ReadOnly"/>
|
||||
|
||||
@if (_showErrors && MotivoMancante)
|
||||
{
|
||||
<p class="form-error" role="alert">
|
||||
<i class="ri-error-warning-line" aria-hidden="true"></i>
|
||||
Indica il motivo
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
@@ -591,12 +598,38 @@
|
||||
|
||||
#region Form
|
||||
|
||||
private void OnAfterChangeReparto()
|
||||
/// Gli obbligatori si segnalano solo dopo il primo tentativo di salvataggio:
|
||||
/// un form che si tinge di rosso prima che tu abbia fatto qualcosa accusa
|
||||
/// l'utente di un errore che non ha ancora commesso.
|
||||
private bool _showErrors;
|
||||
|
||||
private bool RepartoMancante => Scheda.Reparto is null;
|
||||
private bool MotivoMancante => Scheda.ActivityTypeId.IsNullOrEmpty();
|
||||
|
||||
/// I motivi disponibili dipendono dal reparto scelto.
|
||||
private List<string> MotiviDelReparto => SteupDataService.TipiAttività
|
||||
.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!))
|
||||
.Select(x => x.ActivityTypeId)
|
||||
.Where(x => !x.IsNullOrEmpty())
|
||||
.Select(x => x!)
|
||||
.ToList();
|
||||
|
||||
private void OnRepartoChanged(JtbFasiDto? reparto)
|
||||
{
|
||||
if (reparto is null) return;
|
||||
|
||||
Scheda.Reparto = reparto;
|
||||
// Cambiando reparto il motivo precedente non e' piu' fra quelli validi.
|
||||
Scheda.ActivityTypeId = null;
|
||||
OnAfterChangeValue();
|
||||
}
|
||||
|
||||
private void OnMotivoChanged(string? motivo)
|
||||
{
|
||||
Scheda.ActivityTypeId = motivo;
|
||||
OnAfterChangeValue();
|
||||
}
|
||||
|
||||
private void OnAfterChangeValue()
|
||||
{
|
||||
RecalcDirty();
|
||||
@@ -698,9 +731,13 @@
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
// Reparto e motivo non passano piu' dalla validazione di MudForm: sono
|
||||
// campi tappabili, non input. Si controllano qui, e l'errore si vede sul
|
||||
// gruppo interessato oltre che nel messaggio.
|
||||
_showErrors = true;
|
||||
await _form.Validate();
|
||||
|
||||
if (_form.IsValid)
|
||||
if (!RepartoMancante && !MotivoMancante && _form.IsValid)
|
||||
{
|
||||
await Save();
|
||||
return;
|
||||
@@ -710,6 +747,7 @@
|
||||
// sembra non fare nulla se i campi mancanti sono fuori schermo.
|
||||
Snackbar.Clear();
|
||||
Snackbar.Add("Compila i campi obbligatori, oppure esci salvando come bozza", Severity.Warning);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
@typeparam TValue
|
||||
@using SteUp.Shared.Components.Layout.Sheet
|
||||
|
||||
@*
|
||||
Scelta a valore singolo. Al posto della tendina: una riga tappabile che
|
||||
apre un bottom sheet con le opzioni a tutta larghezza.
|
||||
|
||||
La tendina di serie e' un residuo del web — galleggia bianca su bianco
|
||||
sopra le card, si porta dietro la barra di scorrimento del desktop e offre
|
||||
bersagli alti quanto una riga di testo. Qui l'elenco arriva dal basso, ha
|
||||
un titolo, righe da 56 px e la scelta corrente segnata: si usa in piedi,
|
||||
con una mano, anche con i guanti.
|
||||
*@
|
||||
|
||||
<button type="button" class="select-field" disabled="@ReadOnly" @onclick="Open"
|
||||
aria-haspopup="dialog" aria-label="@($"{Title}: {(HasValue ? SelectedLabel : Placeholder)}")">
|
||||
<span class="select-field__value @(HasValue ? "" : "select-field__value--empty")">
|
||||
@(HasValue ? SelectedLabel : Placeholder)
|
||||
</span>
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<i class="select-field__icon ri-arrow-down-s-line" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
|
||||
<ActionSheet @bind-Open="_open" Title="@Title" Subtitle="@Subtitle">
|
||||
<div class="stack stack-xs">
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
var selected = IsSelected(item);
|
||||
<button type="button" class="select-row select-row--single"
|
||||
aria-pressed="@(selected ? "true" : "false")"
|
||||
@onclick="@(() => Choose(item))">
|
||||
<span class="select-row__check" aria-hidden="true">
|
||||
<i class="ri-check-line"></i>
|
||||
</span>
|
||||
<span class="select-row__text">
|
||||
<span class="select-row__title">@ItemLabel(item)</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</ActionSheet>
|
||||
|
||||
@code {
|
||||
/// Titolo dello sheet: e' il nome del campo ("Reparto", "Motivo").
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = "";
|
||||
|
||||
[Parameter] public string? Subtitle { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public IEnumerable<TValue> Items { get; set; } = [];
|
||||
|
||||
[Parameter] public TValue? Value { get; set; }
|
||||
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }
|
||||
|
||||
/// Come si scrive un'opzione. Deve essere fornita: il ToString() di un DTO
|
||||
/// non e' un'etichetta.
|
||||
[Parameter, EditorRequired] public Func<TValue, string?> ItemLabel { get; set; } = _ => null;
|
||||
|
||||
[Parameter] public string Placeholder { get; set; } = "Scegli";
|
||||
[Parameter] public bool ReadOnly { get; set; }
|
||||
|
||||
private bool _open;
|
||||
|
||||
private bool HasValue => Value is not null;
|
||||
private string? SelectedLabel => Value is null ? null : ItemLabel(Value);
|
||||
|
||||
// Il confronto passa per l'etichetta, non per l'oggetto: il valore corrente
|
||||
// arriva ricostruito dall'entita' salvata (Scheda.Reparto lo crea al volo da
|
||||
// codice e descrizione), quindi non e' mai lo stesso oggetto di quelli in
|
||||
// elenco e il confronto di riferimento non segnerebbe mai nulla.
|
||||
private bool IsSelected(TValue item) =>
|
||||
Value is not null &&
|
||||
string.Equals(ItemLabel(item), SelectedLabel, StringComparison.Ordinal);
|
||||
|
||||
private void Open()
|
||||
{
|
||||
if (!ReadOnly) _open = true;
|
||||
}
|
||||
|
||||
private async Task Choose(TValue item)
|
||||
{
|
||||
_open = false;
|
||||
Value = item;
|
||||
await ValueChanged.InvokeAsync(item);
|
||||
}
|
||||
}
|
||||
@@ -387,8 +387,8 @@
|
||||
.chip[aria-pressed="true"],
|
||||
.chip--selected {
|
||||
background-color: var(--info-surface);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-dark);
|
||||
border-color: var(--info-solid);
|
||||
color: var(--info-ink);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
|
||||
@@ -99,10 +99,14 @@
|
||||
|
||||
.option:active { transform: scale(0.97); }
|
||||
|
||||
/* La scelta fatta si segna col tono informativo, non con laccento: un bordo
|
||||
corallo intorno a un riempimento azzurro si legge come un errore, non come
|
||||
"questa e la risposta". Laccento resta del solo pulsante primario. */
|
||||
.option[aria-pressed="true"] {
|
||||
font-weight: 600;
|
||||
background-color: var(--info-surface);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-dark);
|
||||
border-color: var(--info-solid);
|
||||
color: var(--info-ink);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -389,14 +393,56 @@
|
||||
.select-row__title {
|
||||
font-size: var(--body-lg-size);
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-row__meta {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--label-md-size);
|
||||
font-weight: 600;
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Campo a scelta singola — la riga che apre il bottom sheet
|
||||
Ha l'aspetto di un valore, non di un controllo: il chevron basta a dire
|
||||
che si tocca.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.select-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
padding: var(--space-xs) 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.select-field:active { opacity: 0.6; }
|
||||
.select-field:disabled { opacity: 1; }
|
||||
|
||||
.select-field__value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--body-lg-size);
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Il segnaposto e' testo secondario, non un valore: si distingue a colpo
|
||||
d'occhio da un campo gia' compilato. */
|
||||
.select-field__value--empty { font-weight: 400; color: var(--ink-muted); }
|
||||
|
||||
.select-field__icon { flex: 0 0 auto; font-size: 22px; color: var(--ink-muted); }
|
||||
|
||||
/* Scelta singola: il segno e' tondo come un radio, non quadrato come una
|
||||
casella. La forma dice gia' che si sceglie una cosa sola. */
|
||||
.select-row--single .select-row__check { border-radius: var(--radius-pill); }
|
||||
|
||||
Reference in New Issue
Block a user