diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor index c236f22..c31abcc 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor @@ -34,7 +34,7 @@ @fragment(Descriptor) } - + } @@ -63,7 +63,7 @@ else + DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" /> } } @@ -86,7 +86,7 @@ else private object? _valueAtActivation; private object? _draftValue; private bool _wasActive; - private bool _cancelRequested; + private ExitAction _exitAction = ExitAction.None; protected override void OnParametersSet() { @@ -100,11 +100,11 @@ else _hasPendingChange = pending != null; _effectiveValue = pending != null ? pending.NewValue : Descriptor.GetValue(); if (!_wasActive && IsActive) - _valueAtActivation = _effectiveValue; - if (_wasActive && !IsActive && !_cancelRequested) + _valueAtActivation = Descriptor.GetValue(); + if (_wasActive && !IsActive && _exitAction == ExitAction.None) _ = InvokeAsync(CommitIfChangedAsync); if (_wasActive && !IsActive) - _cancelRequested = false; + _exitAction = ExitAction.None; _wasActive = IsActive; } @@ -146,12 +146,30 @@ else await CommitIfChangedAsync(); } - private async Task HandleCancelEdit() + private async Task HandleConfirmEdit() { - _cancelRequested = true; - _draftValue = null; + await CommitIfChangedAsync(); + _exitAction = ExitAction.Confirm; if (Ui != null) await Ui.DeactivateFieldAsync(); } + private async Task HandleCancelEdit() + { + _draftValue = null; + _exitAction = ExitAction.Cancel; + if (Ui != null) + await Ui.DeactivateFieldAsync(); + } + + private async Task HandleRevertEdit() + { + _draftValue = null; + Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName); + _exitAction = ExitAction.Revert; + if (Ui != null) + await Ui.DeactivateFieldAsync(); + } + + private enum ExitAction { None, Confirm, Cancel, Revert } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor index 95e02f7..a185ef1 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor @@ -7,14 +7,14 @@ { - + } else { @DisplayValue - + } @@ -42,8 +42,12 @@ else [Parameter] public EventCallback DisplayValueChanged { get; set; } /// When Edit and not readonly: raised when the input loses focus; argument is the current value. [Parameter] public EventCallback OnBlur { get; set; } - /// When user clicks Cancel while this field is the active editor; parent should clear draft and deactivate. + /// When user clicks OK; parent should commit and deactivate. + [Parameter] public EventCallback OnConfirmRequested { get; set; } + /// When user clicks Cancel; parent should clear draft and deactivate. [Parameter] public EventCallback OnCancelRequested { get; set; } + /// When user clicks Revert; parent should revert pending, clear draft, and deactivate. + [Parameter] public EventCallback OnRevertRequested { get; set; } [CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; private string? _editValue; diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveModal.razor index d169c37..ed168a6 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveModal.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSaveModal.razor @@ -32,7 +32,7 @@ var change = c;
- @change.OptionName + @FormatValue(change.OldValue) diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor index 2feb114..04e7bef 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor @@ -93,6 +93,7 @@ await InvokeAsync(StateHasChanged); return; } + _optionHelpControllerRef?.CancelCloseFromLabelEnter(); _optionHelpKey = args.HelpKey; _optionHelpTitle = args.OptionLabel; _optionHelpAnchorId = args.AnchorId; diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css index 286b38e..f0d8667 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css @@ -232,20 +232,14 @@ margin-right: 0.25rem; } -/* Badge: vertically centered, inline with value so when it wraps it stays part of the value flow */ -::deep .ec-field .badge, -::deep .ec-field-value-item .badge { +/* StatusBadge inside field rows: alignment; spacing comes from StatusBadge .status-badge-inline */ +::deep .ec-field .status-badge, +::deep .ec-field-value-item .status-badge { display: inline-flex; align-items: center; line-height: 1; } -/* Inline badge (inside value): gap from text; when it wraps to own line the margin gives a subtle indent */ -::deep .ec-badge-inline { - margin-left: 0.35rem; - white-space: nowrap; -} - @media (max-width: 767.98px) { ::deep .ec-section-header { padding-left: 0.75rem; diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpController.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpController.razor index 5cd759d..914d718 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpController.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionHelpController.razor @@ -48,4 +48,10 @@ { ScheduleClose(); } + + /// Called when opening or switching help (e.g. user clicked another label); cancels any pending 300ms close so the timer from a previous label leave does not dismiss the new help. + public void CancelCloseFromLabelEnter() + { + CancelCloseTimer(); + } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionStateBadge.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionStateBadge.razor index 66d7db9..d435a70 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionStateBadge.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/OptionStateBadge.razor @@ -6,14 +6,16 @@ { @if (IsInteractive && Ui != null) { - + } else { - readonly + } } else if (ShowEditableBadge) @@ -22,31 +24,36 @@ else if (ShowEditableBadge) { @if (IsActiveEditor) { - + + + + @if (HasPendingChange) + { + + } + } else { - + } } else { - editable + } } @@ -55,22 +62,23 @@ else if (ShowEditableBadge) [Parameter] public ConfigValueSource? Source { get; set; } [Parameter] public string OptionName { get; set; } = ""; [Parameter] public object? Value { get; set; } - /// When true, render readonly as button and raise click/mouseleave via context. [Parameter] public bool IsInteractive { get; set; } - /// When true, show editable/pending badge for non-readonly fields. [Parameter] public bool ShowEditableBadge { get; set; } - /// When true, badge shows "pending" and warning style. [Parameter] public bool HasPendingChange { get; set; } - /// When true, editable badge is a button; otherwise a static span. [Parameter] public bool EditableAsButton { get; set; } = true; - /// When true, this field is the active editor; show Cancel instead of Edit and invoke OnCancelRequested on click. [Parameter] public bool IsActiveEditor { get; set; } - /// When user clicks Cancel (while IsActiveEditor). Invoke before deactivating so parent can clear draft. + [Parameter] public EventCallback OnConfirmRequested { get; set; } [Parameter] public EventCallback OnCancelRequested { get; set; } - /// Field key for activation via context when editable badge is clicked. + [Parameter] public EventCallback OnRevertRequested { get; set; } [Parameter] public string? FieldKey { get; set; } [CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; } + private async Task OnConfirmClick() + { + if (OnConfirmRequested.HasDelegate) + await OnConfirmRequested.InvokeAsync(); + } + private async Task OnCancelClick() { if (OnCancelRequested.HasDelegate) @@ -79,6 +87,12 @@ else if (ShowEditableBadge) await Ui.DeactivateFieldAsync(); } + private async Task OnRevertClick() + { + if (OnRevertRequested.HasDelegate) + await OnRevertRequested.InvokeAsync(); + } + private Task OnEditableClick() { if (Ui == null || string.IsNullOrEmpty(FieldKey)) return Task.CompletedTask; diff --git a/src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor b/src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor index 8577b0a..4d86db5 100644 --- a/src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor +++ b/src/DnsmasqWebUI/Components/Hosts/HostsFileSection.razor @@ -4,11 +4,11 @@ @Path @if (IsEditable) { - editable + } else { - read-only + }
diff --git a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor index 7006206..5f2f4b1 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor @@ -38,11 +38,11 @@ else if (_status != null)
  • @if (f.IsManaged) { - managed + } else { - @(f.Source.ToString().ToLowerInvariant()) + } @f.Path @if (exists == true) { ✓ } diff --git a/src/DnsmasqWebUI/Components/Shared/StatusBadge.razor b/src/DnsmasqWebUI/Components/Shared/StatusBadge.razor new file mode 100644 index 0000000..2d1aa92 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Shared/StatusBadge.razor @@ -0,0 +1,80 @@ +@namespace DnsmasqWebUI.Components.Shared + +@if (Clickable) +{ + +} +else +{ + + @ChildContent + @if (ChildContent == null && !string.IsNullOrWhiteSpace(IconClass)) + { + + @Text + } + else if (ChildContent == null) + { + @Text + } + +} + +@code { + [Parameter] public StatusBadgeKind Kind { get; set; } + [Parameter] public string Text { get; set; } = ""; + [Parameter] public bool Clickable { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public string? Title { get; set; } + [Parameter] public string? AriaLabel { get; set; } + [Parameter] public string? Id { get; set; } + [Parameter] public string? IconClass { get; set; } + [Parameter] public bool StopPropagation { get; set; } = true; + [Parameter] public bool PreventBlurOnMouseDown { get; set; } + [Parameter] public EventCallback OnClick { get; set; } + [Parameter] public EventCallback OnMouseLeave { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private string CssClass + { + get + { + var kindClass = Kind switch + { + StatusBadgeKind.Managed => "bg-primary", + StatusBadgeKind.SourceMain => "bg-secondary", + StatusBadgeKind.SourceInclude => "bg-secondary", + StatusBadgeKind.Editable => "bg-primary", + StatusBadgeKind.ReadOnly => "bg-secondary", + StatusBadgeKind.Pending => "bg-warning text-dark", + StatusBadgeKind.ActionOk => "bg-success", + StatusBadgeKind.ActionCancel => "bg-secondary", + StatusBadgeKind.ActionRevert => "bg-danger", + StatusBadgeKind.NeutralPill => "bg-light text-dark border rounded-pill", + _ => "bg-secondary" + }; + return $"badge status-badge {kindClass} {Class}".Trim(); + } + } +} diff --git a/src/DnsmasqWebUI/Components/Shared/StatusBadge.razor.css b/src/DnsmasqWebUI/Components/Shared/StatusBadge.razor.css new file mode 100644 index 0000000..7bd846a --- /dev/null +++ b/src/DnsmasqWebUI/Components/Shared/StatusBadge.razor.css @@ -0,0 +1,49 @@ +/* Shared badge: compact label size, alignment, no button border when clickable */ +.status-badge { + display: inline-flex; + align-items: center; + line-height: 1.2; + white-space: nowrap; + border: 0; + font-size: 0.75rem; + font-weight: 500; + padding: 0.2em 0.5em; + border-radius: 0.25rem; +} + +.status-badge + .status-badge { + margin-left: 0.35rem; +} + +.status-badge-inline { + margin-left: 0.35rem; +} + +/* Clickable badges: pointer, hover lift + glow, active press, focus for a11y */ +button.status-badge { + cursor: pointer; + transition: transform 0.2s ease-out, filter 0.2s ease-out, box-shadow 0.2s ease-out; +} + +button.status-badge:hover:not(:disabled) { + transform: scale(1.06) translateY(-2px); + filter: brightness(0.94); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +button.status-badge:active:not(:disabled) { + transform: scale(0.94) translateY(0); + filter: brightness(0.88); + box-shadow: 0 0 0 rgba(0, 0, 0, 0); + transition-duration: 0.1s; +} + +button.status-badge:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +button.status-badge:disabled { + opacity: 0.7; + cursor: not-allowed; +} diff --git a/src/DnsmasqWebUI/Components/Shared/StatusBadgeKind.cs b/src/DnsmasqWebUI/Components/Shared/StatusBadgeKind.cs new file mode 100644 index 0000000..139ab4e --- /dev/null +++ b/src/DnsmasqWebUI/Components/Shared/StatusBadgeKind.cs @@ -0,0 +1,16 @@ +namespace DnsmasqWebUI.Components.Shared; + +/// Semantic badge intent for ; maps to consistent CSS and behavior. +public enum StatusBadgeKind +{ + Managed, + SourceMain, + SourceInclude, + Editable, + ReadOnly, + Pending, + ActionOk, + ActionCancel, + ActionRevert, + NeutralPill +} diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor index 96e0b51..1d08719 100644 --- a/src/DnsmasqWebUI/Components/_Imports.razor +++ b/src/DnsmasqWebUI/Components/_Imports.razor @@ -12,6 +12,7 @@ @using DnsmasqWebUI.Models.Client.Abstractions @using DnsmasqWebUI.Infrastructure.Services.Abstractions @using DnsmasqWebUI.Components +@using DnsmasqWebUI.Components.Shared @using DnsmasqWebUI.Components.Modals @using DnsmasqWebUI.Components.Dnsmasq @using DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig