Polish multi-value editors and hosts inline editing.

Align the domain custom editor with the default multi-value layout, keep item numbering in edit mode, and restore unaffected single-value editor styling. Refine hosts inline editing with safer blur handling, explicit cancel actions, and better behavior when blank edits are abandoned.
This commit is contained in:
2026-04-07 17:36:52 +10:00
parent 02a840799b
commit 002e9a5db5
11 changed files with 500 additions and 320 deletions
@@ -172,6 +172,16 @@
margin-bottom: 0.35rem;
}
/* Option label + field-level badges (editable / ok / cancel) on one row */
::deep .ec-field-label-row {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.5rem;
flex: 0 1 auto;
min-width: 0;
}
::deep .ec-field:last-child {
margin-bottom: 0;
}
@@ -206,7 +216,7 @@
/* When a field is active: value + badges in one block so any click inside (checkbox, input, ok/cancel) doesn't deactivate */
::deep .ec-field-value-when-editing {
display: inline-flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
@@ -239,11 +249,6 @@
word-break: break-word;
}
/* Row-level ok/cancel (and revert) for multi-value: right-align on the row below the list */
::deep .ec-field .ec-field-value-multi + .status-badge-inline {
margin-left: auto;
}
::deep .ec-field-value-list {
margin: 0;
padding-left: 1.25rem;
@@ -262,18 +267,24 @@
margin-bottom: 0;
}
/* Multivalue display mode: editable entry row (no list number; align with list content) */
::deep .ec-field-value-list li.ec-multi-list-entry-point {
list-style: none;
margin-left: -1.25rem;
margin-top: 0.15rem;
}
::deep .ec-field-value-list li.ec-multi-list-empty-hint {
list-style: none;
margin-left: -1.25rem;
}
/* Shared with default and custom (e.g. domain) multi-value editors */
::deep .ec-field-value-list li.ec-multi-list-add-item {
list-style: none;
margin-left: -1.25rem;
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px dashed var(--bs-border-color, #dee2e6);
}
::deep .ec-field-value-list li.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
margin-left: auto;
}
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
::deep .ec-field-value-item {
display: inline;
@@ -300,7 +311,11 @@
margin-left: auto;
}
@media (max-width: 991.98px) {
@media (max-width: 575.98px) {
::deep .ec-field-value-when-editing {
flex-wrap: wrap;
}
::deep .ec-field-value-when-editing > .status-badge-inline {
flex-basis: 100%;
margin-left: 0;
@@ -6,8 +6,7 @@
@inject INotificationService NotificationService
<div class="ec-field-value ec-field-value-multi ec-domain-rule-editor" @onclick:stopPropagation="true">
@* ul: avoid <ol> markers (scoped CSS does not inherit .ec-multi-list-add-item list-style rules from the generic multi editor). *@
<ul class="ec-field-value-list ec-domain-rule-list">
<ol class="ec-field-value-list ec-domain-rule-list">
@if (_displayItems.Count > 0)
{
@for (var i = 0; i < _displayItems.Count; i++)
@@ -19,116 +18,152 @@
var rowValue = item.Value;
<li class="ec-domain-rule-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-domain-rule-row-editing" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-domain-rule-form">
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
<label class="form-label">Edit as raw line</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-raw")"
checked="@_draftUseRaw"
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
<label class="form-check-label" for="@($"{anchorId}-raw")">Edit as single line (advanced)</label>
</div>
@if (_draftUseRaw)
{
<textarea class="form-control form-control-sm font-monospace" rows="2"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
}
else
{
<div>
<label class="form-label" for="@($"{anchorId}-dom")">Domain</label>
<input id="@($"{anchorId}-dom")" class="form-control form-control-sm"
placeholder="e.g. home.arpa or #"
@bind="_draftDomain" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
<div class="ec-domain-rule-row-body">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-domain-rule-form">
<div class="ec-domain-rule-toolbar">
<div class="ec-domain-rule-toolbar__group">
<span class="form-label mb-0">Editor mode</span>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@DomainRuleEditorHelpText"
aria-label="@DomainRuleEditorHelpText"
@onclick="ShowDomainRuleEditorHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(false)">
Guided
</button>
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(true)">
Raw line
</button>
</div>
</div>
<div>
<label class="form-label" for="@($"{anchorId}-scope")">Applies to</label>
<select id="@($"{anchorId}-scope")" class="form-select form-select-sm"
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
<option value="unconditional">Any client address (default)</option>
<option value="range">Limit to this subnet or IP</option>
<option value="interface">Limit to subnets on this interface</option>
</select>
</div>
@if (_draftScopeSelect == "range")
@if (_draftUseRaw)
{
<input class="form-control form-control-sm font-monospace"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]" />
}
else
{
<div>
<label class="form-label" for="@($"{anchorId}-mid")">Subnet or IP</label>
<input id="@($"{anchorId}-mid")" class="form-control form-control-sm"
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
@bind="_draftMiddle" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
<label class="form-label" for="@($"{anchorId}-dom")">Domain</label>
<input id="@($"{anchorId}-dom")" class="form-control form-control-sm"
placeholder="e.g. home.arpa or #"
@bind="_draftDomain" @bind:event="oninput" />
<div class="ec-domain-rule-hint-row">
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@HashDomainHelpText"
aria-label="@HashDomainHelpText"
@onclick="ShowHashDomainHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc")">
Also add matching <code>--local</code> forward and reverse DNS
</label>
</div>
}
else if (_draftScopeSelect == "interface")
{
<div>
<label class="form-label" for="@($"{anchorId}-if")">Interface</label>
<input id="@($"{anchorId}-if")" class="form-control form-control-sm"
placeholder="e.g. eth0 or br0"
@bind="_draftMiddle" @bind:event="oninput" />
<label class="form-label" for="@($"{anchorId}-scope")">Applies to</label>
<select id="@($"{anchorId}-scope")" class="form-select form-select-sm"
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
<option value="unconditional">Any client address (default)</option>
<option value="range">Limit to this subnet or IP</option>
<option value="interface">Limit to subnets on this interface</option>
</select>
</div>
@if (_draftScopeSelect == "range")
{
<div>
<label class="form-label" for="@($"{anchorId}-mid")">Subnet or IP</label>
<input id="@($"{anchorId}-mid")" class="form-control form-control-sm"
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
@bind="_draftMiddle" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc")">
Also add matching <code>--local</code> forward and reverse DNS
</label>
</div>
}
else if (_draftScopeSelect == "interface")
{
<div>
<label class="form-label" for="@($"{anchorId}-if")">Interface</label>
<input id="@($"{anchorId}-if")" class="form-control form-control-sm"
placeholder="e.g. eth0 or br0"
@bind="_draftMiddle" @bind:event="oninput" />
</div>
}
else if (_draftScopeSelect == "unconditional")
{
<div class="form-check ec-domain-rule-local-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc2")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc2")">
Append <code>,local</code>
</label>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@LocalSuffixHelpText"
aria-label="@LocalSuffixHelpText"
@onclick="ShowLocalSuffixHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
}
}
else if (_draftScopeSelect == "unconditional")
{
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc2")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc2")">
Append <code>,local</code> (same as a lone <code>--local</code> for this name; uncommon)
</label>
</div>
}
}
<div class="ec-domain-rule-actions">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
<div class="ec-domain-rule-actions">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</div>
</div>
</div>
}
else
{
<div class="ec-domain-rule-summary">
<span class="ec-field-value-item">@DisplayLine(rowValue)</span>
@if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
{
<code class="text-muted small">@rowValue</code>
}
<span class="status-badge-inline">
<OptionStateBadge
AnchorId="@anchorId"
Source="@item.Source"
OptionName="@OptionName"
Value="@rowValue"
IsInteractive="@IsEditMode"
ShowEditableBadge="@(!itemReadonly)"
EditableAsButton="@(!itemReadonly)"
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
@if (!itemReadonly)
}
else
{
<div class="ec-domain-rule-summary">
<span class="ec-field-value-item">@DisplayLine(rowValue)</span>
@if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
<code class="text-muted small">@rowValue</code>
}
</span>
</div>
}
<span class="status-badge-inline">
<OptionStateBadge
AnchorId="@anchorId"
Source="@item.Source"
OptionName="@OptionName"
Value="@rowValue"
IsInteractive="@IsEditMode"
ShowEditableBadge="@(!itemReadonly)"
EditableAsButton="@(!itemReadonly)"
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
@if (!itemReadonly)
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
</div>
}
</div>
</li>
}
}
@@ -145,19 +180,37 @@
@if (_addingNew)
{
<div class="ec-domain-rule-form">
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
<label class="form-label">Edit as raw line</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="domain-add-raw"
checked="@_draftUseRaw"
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
<label class="form-check-label" for="domain-add-raw">Edit as single line (advanced)</label>
<div class="ec-domain-rule-toolbar">
<div class="ec-domain-rule-toolbar__group">
<span class="form-label mb-0">Editor mode</span>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@DomainRuleEditorHelpText"
aria-label="@DomainRuleEditorHelpText"
@onclick="ShowDomainRuleEditorHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(false)">
Guided
</button>
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(true)">
Raw line
</button>
</div>
</div>
@if (_draftUseRaw)
{
<textarea class="form-control form-control-sm font-monospace" rows="2"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
<input class="form-control form-control-sm font-monospace"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]" />
}
else
{
@@ -166,6 +219,16 @@
<input id="domain-add-dom" class="form-control form-control-sm"
placeholder="e.g. home.arpa or #"
@bind="_draftDomain" @bind:event="oninput" />
<div class="ec-domain-rule-hint-row">
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@HashDomainHelpText"
aria-label="@HashDomainHelpText"
@onclick="ShowHashDomainHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
</div>
<div>
<label class="form-label" for="domain-add-scope">Applies to</label>
@@ -181,8 +244,9 @@
<div>
<label class="form-label" for="domain-add-mid">Subnet or IP</label>
<input id="domain-add-mid" class="form-control form-control-sm"
placeholder="e.g. 192.168.0.0/24"
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
@bind="_draftMiddle" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="domain-add-loc"
@@ -197,18 +261,25 @@
<div>
<label class="form-label" for="domain-add-if">Interface</label>
<input id="domain-add-if" class="form-control form-control-sm"
placeholder="e.g. eth0"
placeholder="e.g. eth0 or br0"
@bind="_draftMiddle" @bind:event="oninput" />
</div>
}
else if (_draftScopeSelect == "unconditional")
{
<div class="form-check">
<div class="form-check ec-domain-rule-local-check">
<input type="checkbox" class="form-check-input" id="domain-add-loc2"
@bind="_draftAddLocal" />
<label class="form-check-label" for="domain-add-loc2">
Append <code>,local</code> (uncommon)
Append <code>,local</code>
</label>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@LocalSuffixHelpText"
aria-label="@LocalSuffixHelpText"
@onclick="ShowLocalSuffixHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
}
}
@@ -230,7 +301,7 @@
}
</li>
}
</ul>
</ol>
</div>
@code {
@@ -354,6 +425,12 @@
_draftUseRaw = useRaw;
}
private void ShowDomainRuleEditorHelp() => NotificationService.ShowInfo(DomainRuleEditorHelpText);
private void ShowLocalSuffixHelp() => NotificationService.ShowInfo(LocalSuffixHelpText);
private void ShowHashDomainHelp() => NotificationService.ShowInfo(HashDomainHelpText);
private void OnDraftScopeAfterChange()
{
if (_draftScopeSelect == "unconditional")
@@ -490,4 +567,13 @@
await OnValuesChanged.InvokeAsync(next);
StateHasChanged();
}
private const string DomainRuleEditorHelpText =
"domain= sets the suffix dnsmasq uses for DHCP and local DNS naming. Guided mode builds the rule from fields; Raw line mode lets you enter the exact dnsmasq config line directly.";
private const string LocalSuffixHelpText =
"Appending ,local behaves like a matching --local rule for this domain, making dnsmasq answer locally instead of forwarding it upstream. This is uncommon for an unconditional domain= rule.";
private const string HashDomainHelpText =
"Use # as the domain value to take the search domain from resolv.conf instead of hard-coding a suffix in dnsmasq.";
}
@@ -7,32 +7,22 @@
background-color: var(--bs-body-bg, #fff);
}
.ec-domain-rule-editor .ec-field-value-list,
.ec-domain-rule-list {
list-style: none;
/* Keep <ol> markers (decimal) like the default multi-value editor; do not reset list-style here. */
.ec-domain-rule-editor .ec-field-value-list {
margin-bottom: 0;
padding-left: 0;
}
.ec-domain-rule-list > li {
list-style: none;
}
.ec-domain-rule-intro {
font-size: 0.8rem;
color: var(--bs-secondary-color, #6c757d);
margin-bottom: 0.35rem;
line-height: 1.35;
}
.ec-domain-rule-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 2rem;
}
.ec-domain-rule-row.ec-domain-rule-row-editing {
.ec-domain-rule-row-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ec-domain-rule-row.ec-domain-rule-row-editing .ec-domain-rule-row-body {
align-items: stretch;
}
@@ -52,13 +42,77 @@
color: var(--bs-secondary-color, #6c757d);
}
.ec-domain-rule-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem 0.75rem;
flex-wrap: wrap;
}
.ec-domain-rule-toolbar__group {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.ec-domain-rule-mode-toggle {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 999px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
.ec-domain-rule-mode-btn {
border: 0;
border-radius: 999px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
font-weight: 600;
color: #475569;
background: transparent;
box-shadow: none;
}
.ec-domain-rule-mode-btn:hover,
.ec-domain-rule-mode-btn:focus-visible {
color: #0f172a;
background: rgba(148, 163, 184, 0.12);
}
.ec-domain-rule-mode-btn--active,
.ec-domain-rule-mode-btn--active:hover,
.ec-domain-rule-mode-btn--active:focus-visible {
color: #fff;
background: linear-gradient(180deg, #0d6efd 0%, #0a58ca 100%);
}
.ec-domain-rule-help {
line-height: 1;
text-decoration: none;
}
.ec-domain-rule-help:hover,
.ec-domain-rule-help:focus-visible {
color: var(--bs-primary) !important;
}
.ec-domain-rule-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.35rem;
}
.ec-domain-rule-editor .ec-field-value-list li.ec-multi-list-add-item .ec-domain-rule-actions .status-badge-inline,
.ec-domain-rule-actions .status-badge-inline {
margin-left: 0;
}
.ec-domain-rule-summary {
display: flex;
align-items: center;
@@ -77,3 +131,20 @@
color: var(--bs-secondary-color, #6c757d);
margin-top: -0.25rem;
}
.ec-domain-rule-hint-row {
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
.ec-domain-rule-local-check {
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.ec-domain-rule-local-check .form-check-label {
flex: 1 1 auto;
min-width: 0;
}
@@ -38,42 +38,10 @@ else
</li>
}
}
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
{
<li class="ec-multi-list-entry-point">
<OptionStateBadge
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
</li>
}
else if (Context.EffectiveItems.Count == 0)
else
{
<li class="ec-multi-list-empty-hint">
@if (Context.ShowEditableBadge)
{
<span class="text-muted">(none)</span>
<OptionStateBadge
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
}
else
{
<span class="text-muted">(none)</span>
}
<span class="text-muted">(none)</span>
</li>
}
</ol>
@@ -49,22 +49,22 @@ else if (Descriptor.IsMultiValue)
{
@* Custom multi: row badge only while this field is active (ok/cancel/revert). Otherwise per-item / entry badges in the fragment suffice — avoids duplicating "readonly" when RowSource matches item sources. *@
var customMultiActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
<li class="ec-field @(customMultiActiveEditor ? "ec-field-editing" : "")">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
<span class="ec-field-value">
<CascadingValue Value="@customMultiActiveEditor">
<CascadingValue Value="@IsEditMode">
@customMulti(_multiContext)
</CascadingValue>
<li class="ec-field ec-field--multi-value @(customMultiActiveEditor ? "ec-field-editing" : "")">
<div class="ec-field-label-row">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
@if (!Descriptor.IsCapabilityDisabled)
{
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@(customMultiActiveEditor ? _multiContext.RowSource : null)" OptionName="@Descriptor.OptionName" Value="@_multiContext.EffectiveItems"
IsInteractive="@IsEditMode" ShowEditableBadge="true" HasPendingChange="@_multiContext.HasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@customMultiActiveEditor"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
</div>
<CascadingValue Value="@customMultiActiveEditor">
<CascadingValue Value="@IsEditMode">
@customMulti(_multiContext)
</CascadingValue>
</span>
@if (customMultiActiveEditor)
{
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiContext.RowSource" OptionName="@Descriptor.OptionName" Value="@_multiContext.EffectiveItems"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiContext.HasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
</CascadingValue>
</li>
}
else if (_multiContext is not null)
@@ -19,47 +19,49 @@
var rowValue = item.Value;
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input class="form-control form-control-sm ec-multi-list-input"
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
@bind="_draftValue" @bind:event="oninput" />
<div class="ec-multi-list-row-body">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input class="form-control form-control-sm ec-multi-list-input"
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
@bind="_draftValue" @bind:event="oninput" />
</div>
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
</div>
<span class="status-badge-inline">
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
Title="Apply change" AriaLabel="Apply change"
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
Title="Discard edit" AriaLabel="Cancel edit"
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
</span>
</div>
}
else
{
<span class="ec-field-value-item">@rowValue</span>
}
else
{
<span class="ec-field-value-item">@rowValue</span>
<span class="status-badge-inline">
<OptionStateBadge
AnchorId="@anchorId"
Source="@item.Source"
OptionName="@OptionName"
Value="@rowValue"
IsInteractive="@IsEditMode"
ShowEditableBadge="@(!itemReadonly)"
EditableAsButton="@(!itemReadonly)"
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
@if (!itemReadonly)
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
}
<span class="status-badge-inline">
<OptionStateBadge
AnchorId="@anchorId"
Source="@item.Source"
OptionName="@OptionName"
Value="@rowValue"
IsInteractive="@IsEditMode"
ShowEditableBadge="@(!itemReadonly)"
EditableAsButton="@(!itemReadonly)"
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
@if (!itemReadonly)
{
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
Title="Remove this value" AriaLabel="Remove"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
}
</div>
</li>
}
}
@@ -13,14 +13,18 @@
}
.ec-multi-list-row {
min-height: 2rem;
}
.ec-multi-list-row-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
min-width: 0;
}
.ec-multi-list-row.ec-multi-list-row-with-controls {
.ec-multi-list-row.ec-multi-list-row-with-controls .ec-multi-list-row-body {
align-items: flex-start;
}
@@ -47,18 +51,18 @@
width: 100%;
}
.ec-multi-list-row .ec-field-value-item {
.ec-multi-list-row-body .ec-field-value-item {
min-width: 0;
}
.ec-multi-list-row .status-badge-inline {
.ec-multi-list-row-body .status-badge-inline {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.ec-multi-list-row .form-control.ec-multi-list-input {
.ec-multi-list-row-body .form-control.ec-multi-list-input {
max-width: none;
flex-shrink: 0;
width: 100%;
@@ -86,7 +90,7 @@
max-width: none;
}
.ec-multi-list-add-item .status-badge-inline {
.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
margin-left: auto;
}
@@ -2,8 +2,18 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
<li class="ec-field">
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
<li class="ec-field ec-field--multi-value @(Context.IsActiveEditor ? "ec-field-editing" : "")">
<div class="ec-field-label-row">
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
@if (Context.IsActiveEditor || Context.ShowEditableBadge)
{
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@(Context.IsActiveEditor ? Context.RowSource : null)" OptionName="@Context.Descriptor.OptionName" Value="@Context.EffectiveItems"
IsInteractive="@Context.IsEditMode" ShowEditableBadge="@Context.ShowEditableBadge" HasPendingChange="@Context.HasPendingOrDraft"
EditableAsButton="true" FieldKey="@Context.FieldKey" IsActiveEditor="@Context.IsActiveEditor"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
</div>
@if (Context.IsActiveEditor)
{
<EffectiveConfigMultiValueListEditor
@@ -16,11 +26,6 @@
Behavior="@(MultiDesc?.Behavior)"
Validator="@(MultiDesc?.Validator)"
OnValuesChanged="@ItemsChanged" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Context.RowSource" OptionName="@Context.Descriptor.OptionName" Value="@Context.EffectiveItems"
IsInteractive="@Context.IsEditMode" ShowEditableBadge="@Context.ShowEditableBadge" HasPendingChange="@Context.HasPendingOrDraft"
EditableAsButton="true" FieldKey="@Context.FieldKey" IsActiveEditor="@Context.IsActiveEditor"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
else
{
@@ -44,42 +49,10 @@
</li>
}
}
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
{
<li class="ec-multi-list-entry-point">
<OptionStateBadge
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
</li>
}
else if (Context.EffectiveItems.Count == 0)
{
<li class="ec-multi-list-empty-hint">
@if (Context.ShowEditableBadge)
{
<span class="text-muted">(none)</span>
<OptionStateBadge
OptionName="@Context.Descriptor.OptionName"
ShowEditableBadge="true"
EditableAsButton="true"
FieldKey="@Context.FieldKey"
IsActiveEditor="false"
IsInteractive="true"
HasPendingChange="@Context.HasPendingOrDraft"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
}
else
{
<span class="text-muted">(none)</span>
}
<span class="text-muted">(none)</span>
</li>
}
</ol>
+68 -34
View File
@@ -230,6 +230,12 @@ else
@onclick="OnManagedHeaderSaveAsync">
<i class="bi bi-check-lg" aria-hidden="true"></i><span>Save</span>
</button>
<button type="button"
class="btn hosts-managed-action-btn hosts-managed-action-btn--cancel"
title="Discard this row edit"
@onclick="OnManagedHeaderCancelAsync">
<i class="bi bi-x-lg" aria-hidden="true"></i><span>Cancel</span>
</button>
}
else if (ManagedSelectionCount == 1)
{
@@ -865,6 +871,11 @@ else
await TryFinishHostEditFromKeyboardAsync();
}
private async Task OnManagedHeaderCancelAsync()
{
await DiscardCurrentEditAsync();
}
private async Task OnManagedHeaderEditAsync()
{
DeactivateEffectiveConfigFieldIfActive();
@@ -1057,6 +1068,11 @@ else
private void RevealHostFieldValidation() => _showHostFieldValidation = true;
private bool IsCurrentEditBlank =>
string.IsNullOrWhiteSpace(_editAddress)
&& ParseNamesList(_editNames).Count == 0
&& string.IsNullOrWhiteSpace(_editInlineComment);
private void DeactivateEffectiveConfigFieldIfActive()
{
if (Session.Ui.ActiveFieldKey != null)
@@ -1072,41 +1088,8 @@ else
private async Task OnInvalidEditDiscardAsync()
{
if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
{
_showInvalidHostEditModal = false;
CancelEdit();
await InvokeAsync(StateHasChanged);
return;
}
var list = CloneEntries(CurrentManagedEntries).ToList();
var entry = list.FirstOrDefault(MatchesEditingId);
var wasNew = entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal);
if (wasNew)
{
list.Remove(entry!);
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
_status.ManagedHostsFilePath);
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
}
_showInvalidHostEditModal = false;
var prevId = _editingRowId;
CancelEdit();
if (wasNew)
_selectedManagedRowIds.Clear();
else
{
_selectedManagedRowIds.Clear();
if (!string.IsNullOrEmpty(prevId))
_selectedManagedRowIds.Add(prevId);
}
await InvokeAsync(StateHasChanged);
await DiscardCurrentEditAsync();
}
private async Task OnEditFieldKeyDown(KeyboardEventArgs e)
@@ -1131,6 +1114,12 @@ else
if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId))
return;
if (IsCurrentEditBlank)
{
await DiscardCurrentEditAsync();
return;
}
if (IsCurrentEditValid)
await CommitAndEndInlineEditAsync();
else
@@ -1168,6 +1157,10 @@ else
{
try
{
var recentCueInteraction = await _hostsTableScrollCueModule.InvokeAsync<bool>("wasRecentCueInteraction");
if (recentCueInteraction)
return;
outside = await _hostsTableScrollCueModule.InvokeAsync<bool>("isFocusOutsideElement", _editingRowRef);
}
catch (Exception ex) when (ex is JSDisconnectedException or JSException or ObjectDisposedException or TaskCanceledException)
@@ -1178,6 +1171,12 @@ else
if (outside)
{
if (IsCurrentEditBlank)
{
await DiscardCurrentEditAsync();
return;
}
if (IsCurrentEditValid)
await CommitAndEndInlineEditAsync();
else
@@ -1220,6 +1219,41 @@ else
await InvokeAsync(StateHasChanged);
}
private async Task DiscardCurrentEditAsync()
{
if (string.IsNullOrEmpty(_editingRowId) || _status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath))
{
CancelEdit();
await InvokeAsync(StateHasChanged);
return;
}
var list = CloneEntries(CurrentManagedEntries).ToList();
var entry = list.FirstOrDefault(MatchesEditingId);
var wasNew = entry != null && entry.Id.StartsWith("new:", StringComparison.Ordinal);
var prevId = _editingRowId;
if (wasNew)
{
list.Remove(entry!);
Session.Draft.SetManagedHostsDraft(
CloneEntries(_baselineManagedEntries),
list,
_status.ManagedHostsFilePath);
RebuildManagedOverlay();
InvalidateVisibleGroupsCache();
}
CancelEdit();
_showInvalidHostEditModal = false;
_selectedManagedRowIds.Clear();
if (!wasNew && !string.IsNullOrEmpty(prevId))
_selectedManagedRowIds.Add(prevId);
_selectionAnchorRowId = !wasNew ? prevId : null;
await InvokeAsync(StateHasChanged);
}
private bool MatchesEditingId(HostEntry e)
{
var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id;
@@ -376,7 +376,7 @@
/* Fixed column for actions avoids path/subtitle reflow when buttons show or hide. */
.hosts-group-header__path-row--with-managed-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 9.75rem);
grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 13.5rem);
align-items: center;
column-gap: 0.5rem;
}
@@ -477,6 +477,19 @@
border-color: rgba(8, 66, 152, 0.92);
}
.hosts-managed-action-btn--cancel {
color: #334155;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-color: rgba(148, 163, 184, 0.38);
}
.hosts-managed-action-btn--cancel:hover,
.hosts-managed-action-btn--cancel:focus-visible {
color: #0f172a;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
border-color: rgba(100, 116, 139, 0.42);
}
.hosts-managed-action-btn--edit {
color: #0f172a;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
@@ -589,7 +602,7 @@
@media (max-width: 640.98px) {
.hosts-group-header__path-row--with-managed-actions {
--hosts-managed-actions-col: 9rem;
--hosts-managed-actions-col: 12rem;
}
.hosts-group-header__managed-actions {
@@ -10,10 +10,16 @@ const POLL_MS = 100;
const BTN_LEFT_CLASS = 'app-table-cue-btn--left';
const BTN_RIGHT_CLASS = 'app-table-cue-btn--right';
const CUE_BTN_CLASS = 'app-table-cue-btn';
const RECENT_CUE_INTERACTION_MS = 300;
let pollId = null;
let resizeListener = null;
const scrollListeners = new WeakMap();
let lastCueInteractionAt = 0;
function markCueInteraction() {
lastCueInteractionAt = Date.now();
}
function applyScrollCue(scrollEl, hostEl) {
if (!scrollEl || !hostEl) return;
@@ -58,7 +64,9 @@ function ensureCueButtons(hostEl) {
leftBtn.type = 'button';
leftBtn.className = CUE_BTN_CLASS + ' ' + BTN_LEFT_CLASS;
leftBtn.setAttribute('aria-label', 'Scroll table left');
leftBtn.addEventListener('pointerdown', markCueInteraction);
leftBtn.addEventListener('click', function () {
markCueInteraction();
scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'left');
});
hostEl.appendChild(leftBtn);
@@ -69,7 +77,9 @@ function ensureCueButtons(hostEl) {
rightBtn.type = 'button';
rightBtn.className = CUE_BTN_CLASS + ' ' + BTN_RIGHT_CLASS;
rightBtn.setAttribute('aria-label', 'Scroll table right');
rightBtn.addEventListener('pointerdown', markCueInteraction);
rightBtn.addEventListener('click', function () {
markCueInteraction();
scrollToSide(hostEl.querySelector(WRAPPER_SELECTOR), 'right');
});
hostEl.appendChild(rightBtn);
@@ -170,3 +180,7 @@ export function isFocusOutsideElement(container) {
if (!active) return true;
return !container.contains(active);
}
export function wasRecentCueInteraction() {
return Date.now() - lastCueInteractionAt <= RECENT_CUE_INTERACTION_MS;
}