@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