Extract hosts file filter into GroupedSelectDropdown and builder

Add shared Models.Ui grouped-select model, IGroupedSelectBuilder, optional
trigger summary API, and GroupedSelectDropdown component. Hosts page uses
HostsFileFilterBuilder and hosts-specific option row styling for full paths.

Remove HostsSourcePathDisplay and file filter menu records in favor of the
builder. Bump version to 0.0.8.
This commit is contained in:
2026-04-07 18:15:23 +10:00
parent 002e9a5db5
commit e86a2b6bd9
13 changed files with 839 additions and 392 deletions
@@ -0,0 +1,111 @@
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Ui;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public sealed class HostsFileFilterBuilderTests
{
private static HostsPageRow Row(HostsRowSourceKind kind, string path, bool isComment = false) =>
new(
Id: "id",
SourceKind: kind,
SourcePath: path,
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "127.0.0.1",
Names: [],
EffectiveNames: [],
IsComment: isComment,
LineNumber: 1);
[Fact]
public void Build_InjectsManagedPath_WhenNotInRows()
{
var managed = "/data/managed.hosts";
var builder = new HostsFileFilterBuilder([], managed);
var model = builder.Build();
Assert.NotNull(model.AllOption);
var managedSection = model.Sections.Single();
Assert.Equal("Managed hosts", managedSection.Label);
Assert.Equal("managed", managedSection.Kind);
Assert.Single(managedSection.Options);
Assert.Equal(managed, managedSection.Options[0].Value);
Assert.Equal(0, managedSection.Options[0].Count);
}
[Fact]
public void Build_OrdersGroupsManagedSystemAddn_AndCountsNonComments()
{
var rows = new[]
{
Row(HostsRowSourceKind.SystemHosts, "/etc/hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts", isComment: true),
Row(HostsRowSourceKind.AddnHosts, "/extra/b.hosts"),
};
var builder = new HostsFileFilterBuilder(rows, managedHostsPath: null);
var model = builder.Build();
Assert.Equal(3, model.Sections.Count);
Assert.Equal("managed", model.Sections[0].Kind);
Assert.Equal("system", model.Sections[1].Kind);
Assert.Equal("addn", model.Sections[2].Kind);
Assert.Single(model.Sections[0].Options);
Assert.Equal(1, model.Sections[0].Options[0].Count);
}
[Fact]
public void GetSummaryText_AllFiles_ShowsRecordCount()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
Assert.Equal("All files · 1 records", builder.GetSummaryText(""));
Assert.Equal("All files · 1 records", builder.GetSummaryText(" "));
}
[Fact]
public void GetSummaryText_SelectedFile_UsesShortLabelAndPrefix()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/my.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
Assert.Equal("Managed · my.hosts (1)", builder.GetSummaryText("/m/my.hosts"));
}
[Fact]
public void GetSummaryText_UnknownPath_FallsBackToShortLabel()
{
var builder = new HostsFileFilterBuilder([], null);
Assert.Equal("orphan.txt", builder.GetSummaryText("/tmp/orphan.txt"));
}
[Fact]
public void GetTriggerSummary_SingleSource_SplitsPrefixPrimaryAndCount()
{
var rows = new[] { Row(HostsRowSourceKind.AddnHosts, "/etc/extra.d/hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
var s = builder.GetTriggerSummary("/etc/extra.d/hosts");
Assert.Equal(GroupedSelectTriggerSummaryKind.SingleSource, s.Kind);
Assert.Equal("Additional", s.CategoryPrefix);
Assert.Equal("hosts", s.PrimaryLabel);
Assert.Equal("1", s.SecondaryMeta);
Assert.Equal("Additional · hosts (1)", s.AccessibleFullText);
}
[Fact]
public void GetTriggerSummary_AllSources_WithRecords_HasSecondaryMeta()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
var s = builder.GetTriggerSummary("");
Assert.Equal(GroupedSelectTriggerSummaryKind.AllSources, s.Kind);
Assert.Null(s.CategoryPrefix);
Assert.Equal("All files", s.PrimaryLabel);
Assert.Equal("1 records", s.SecondaryMeta);
}
}
@@ -1,55 +0,0 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public sealed class HostsSourcePathDisplayTests
{
private static HostsPageRow Row(HostsRowSourceKind kind, string path, bool isComment = false) =>
new(
Id: "id",
SourceKind: kind,
SourcePath: path,
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "127.0.0.1",
Names: [],
EffectiveNames: [],
IsComment: isComment,
LineNumber: 1);
[Fact]
public void BuildFileFilterMenuGroups_InjectsManagedPath_WhenNotInRows()
{
var managed = "/data/managed.hosts";
var groups = HostsSourcePathDisplay.BuildFileFilterMenuGroups([], managed);
Assert.Single(groups);
Assert.Equal(HostsRowSourceKind.Managed, groups[0].SourceKind);
Assert.Single(groups[0].Files);
Assert.Equal(managed, groups[0].Files[0].Path);
Assert.Equal(0, groups[0].Files[0].RecordCount);
}
[Fact]
public void BuildFileFilterMenuGroups_OrdersGroupsManagedSystemAddn_AndCountsNonComments()
{
var rows = new[]
{
Row(HostsRowSourceKind.SystemHosts, "/etc/hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts", isComment: true),
Row(HostsRowSourceKind.AddnHosts, "/extra/b.hosts"),
};
var groups = HostsSourcePathDisplay.BuildFileFilterMenuGroups(rows, managedHostsPathAlwaysShow: null);
Assert.Equal(3, groups.Count);
Assert.Equal(HostsRowSourceKind.Managed, groups[0].SourceKind);
Assert.Equal(HostsRowSourceKind.SystemHosts, groups[1].SourceKind);
Assert.Equal(HostsRowSourceKind.AddnHosts, groups[2].SourceKind);
Assert.Single(groups[0].Files);
Assert.Equal(1, groups[0].Files[0].RecordCount);
}
}
+30 -115
View File
@@ -5,6 +5,7 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.PageStates @using DnsmasqWebUI.Models.Dnsmasq.PageStates
@using DnsmasqWebUI.Models.Hosts @using DnsmasqWebUI.Models.Hosts
@using DnsmasqWebUI.Models.Ui
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions @using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig @using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig
@@ -78,63 +79,31 @@ else
@bind="_search" @bind="_search"
@bind:event="oninput" /> @bind:event="oninput" />
<label class="visually-hidden" for="hosts-file-filter-trigger">Hosts file</label> <label class="visually-hidden" for="hosts-file-filter-trigger">Hosts file</label>
<div class="hosts-file-filter-wrap @(_fileFilterMenuOpen ? "hosts-file-filter-wrap--open" : null)"> <div class="hosts-file-filter-wrap">
@if (_fileFilterMenuOpen) <GroupedSelectDropdown TValue="string"
{ @ref="_hostsFileFilterDropdown"
<div class="hosts-file-filter-backdrop" @onclick="CloseFileFilterMenu" aria-hidden="true"></div> Builder="HostsFileFilterBuilder"
} @bind-Value="_sourceFileFilter"
<button type="button" @bind-Value:after="OnSourceFileFilterChangedAfterBind"
id="hosts-file-filter-trigger" TriggerId="hosts-file-filter-trigger"
class="hosts-file-filter-toggle hosts-toolbar-file-filter" MenuId="hosts-file-filter-menu"
title="Show entries from all loaded hosts files, or only one file" TriggerClass="hosts-toolbar-file-filter">
aria-label="Filter by hosts file" <OptionTemplate Context="ctx">
aria-haspopup="listbox"
aria-expanded="@(_fileFilterMenuOpen ? "true" : "false")"
aria-controls="hosts-file-filter-menu"
@onclick="ToggleFileFilterMenu"
@onclick:stopPropagation="true">
<span class="hosts-file-filter-toggle-label text-truncate">@FileFilterSummaryText</span>
<i class="bi bi-chevron-down hosts-file-filter-chevron flex-shrink-0" aria-hidden="true"></i>
</button>
@if (_fileFilterMenuOpen)
{
<div id="hosts-file-filter-menu"
class="hosts-file-filter-panel shadow"
role="listbox"
aria-label="Hosts files"
@onclick:stopPropagation="true">
<button type="button" <button type="button"
class="hosts-file-filter-option hosts-file-filter-option--all @(string.IsNullOrWhiteSpace(_sourceFileFilter) ? "hosts-file-filter-option--active" : null)" class="hosts-file-filter-option @(ctx.IsSelected ? "hosts-file-filter-option--active" : null)"
role="option" role="option"
aria-selected="@(string.IsNullOrWhiteSpace(_sourceFileFilter) ? "true" : "false")" aria-selected="@(ctx.IsSelected ? "true" : "false")"
@onclick="@(() => SelectSourceFileFilter(""))"> title="@(ctx.Option.Title ?? ctx.Option.Label)"
<span class="hosts-file-filter-option-main">All files</span> @onclick="ctx.OnSelect">
<span class="hosts-file-filter-count">@HostsTotalDataRowCount</span> <span class="hosts-file-filter-option-main hosts-file-filter-option-path"
title="@(ctx.Option.Title ?? ctx.Option.Label)">@(ctx.Option.Title ?? ctx.Option.Label)</span>
@if (ctx.Option.Count is not null)
{
<span class="hosts-file-filter-count">@ctx.Option.Count</span>
}
</button> </button>
@foreach (var group in FileFilterMenuGroups) </OptionTemplate>
{ </GroupedSelectDropdown>
<div class="hosts-file-filter-group hosts-file-filter-group--@FileFilterGroupKindClass(group.SourceKind)">
<div class="hosts-file-filter-group-header" role="presentation">
<span class="hosts-file-filter-group-title">@group.Title</span>
</div>
@foreach (var file in group.Files)
{
var path = file.Path;
var active = IsSourceFilePathSelected(path);
<button type="button"
class="hosts-file-filter-option @(active ? "hosts-file-filter-option--active" : null)"
role="option"
aria-selected="@(active ? "true" : "false")"
title="@path"
@onclick="@(() => SelectSourceFileFilter(path))">
<span class="hosts-file-filter-option-main hosts-file-filter-path" title="@path">@path</span>
<span class="hosts-file-filter-count">@file.RecordCount</span>
</button>
}
</div>
}
</div>
}
</div> </div>
</div> </div>
</div> </div>
@@ -619,72 +588,19 @@ else
ActiveFilter: null); ActiveFilter: null);
} }
private bool _fileFilterMenuOpen; private GroupedSelectDropdown<string>? _hostsFileFilterDropdown;
private IReadOnlyList<HostsFileFilterMenuGroup> FileFilterMenuGroups => private IGroupedSelectBuilder<string> HostsFileFilterBuilder =>
HostsSourcePathDisplay.BuildFileFilterMenuGroups(_unifiedRows, _status?.ManagedHostsFilePath); new HostsFileFilterBuilder(_unifiedRows, _status?.ManagedHostsFilePath);
private int HostsTotalDataRowCount => _unifiedRows.Count(r => !r.IsComment);
private string FileFilterSummaryText
{
get
{
if (string.IsNullOrWhiteSpace(_sourceFileFilter))
{
var n = HostsTotalDataRowCount;
return n == 0 ? "All files" : $"All files · {n} records";
}
var trimmed = _sourceFileFilter.Trim();
foreach (var g in FileFilterMenuGroups)
{
foreach (var f in g.Files)
{
if (string.Equals(f.Path, trimmed, StringComparison.OrdinalIgnoreCase))
{
var prefix = HostsSourcePathDisplay.GroupSummaryPrefix(g.SourceKind);
return $"{prefix} · {f.FileName} ({f.RecordCount})";
}
}
}
return HostsSourcePathDisplay.FormatOptionLabel(trimmed);
}
}
private void ToggleFileFilterMenu() => _fileFilterMenuOpen = !_fileFilterMenuOpen;
private void CloseFileFilterMenu() => _fileFilterMenuOpen = false;
private void SelectSourceFileFilter(string path)
{
_sourceFileFilter = path ?? "";
CloseFileFilterMenu();
OnSourceFileFilterChangedAfterBind();
}
private async Task OnHostsToolbarKeyDown(KeyboardEventArgs e) private async Task OnHostsToolbarKeyDown(KeyboardEventArgs e)
{ {
if (!_fileFilterMenuOpen || e.Key != "Escape") if (e.Key != "Escape")
return; return;
CloseFileFilterMenu(); if (_hostsFileFilterDropdown?.CloseMenu() == true)
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private bool IsSourceFilePathSelected(string path) =>
!string.IsNullOrWhiteSpace(_sourceFileFilter)
&& string.Equals(_sourceFileFilter.Trim(), path.Trim(), StringComparison.OrdinalIgnoreCase);
private static string FileFilterGroupKindClass(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "managed",
HostsRowSourceKind.SystemHosts => "system",
HostsRowSourceKind.AddnHosts => "addn",
HostsRowSourceKind.Hostsdir => "hostsdir",
_ => "other"
};
private void OnSourceFileFilterChangedAfterBind() private void OnSourceFileFilterChangedAfterBind()
{ {
InvalidateVisibleGroupsCache(); InvalidateVisibleGroupsCache();
@@ -950,9 +866,8 @@ else
if (_showRemoveHostsModal || _showInvalidHostEditModal) if (_showRemoveHostsModal || _showInvalidHostEditModal)
return; return;
if (_fileFilterMenuOpen && e.Key == "Escape") if (e.Key == "Escape" && (_hostsFileFilterDropdown?.CloseMenu() ?? false))
{ {
CloseFileFilterMenu();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
return; return;
} }
+16 -130
View File
@@ -74,85 +74,23 @@
max-width: none; max-width: none;
} }
/* File filter: wrapper keeps toolbar flex slot; menu layers above table when open. */ /* Flex slot for file filter; inner GroupedSelectDropdown fills width (matches pre-extract layout). */
.hosts-file-filter-wrap { .hosts-file-filter-wrap {
position: relative; position: relative;
flex: 0 0 auto; flex: 0 1 22rem;
min-width: 13.5rem; min-width: min(13.5rem, 100%);
max-width: 22rem; max-width: min(22rem, 100%);
width: auto; width: auto;
} }
.hosts-file-filter-wrap--open { .hosts-file-filter-wrap ::deep .grouped-select {
z-index: 1080; display: block;
}
.hosts-file-filter-backdrop {
position: fixed;
inset: 0;
z-index: 1070;
background: transparent;
}
.hosts-file-filter-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
width: 100%; width: 100%;
padding: 0.25rem 0.55rem;
font-size: 0.875rem;
line-height: 1.5;
text-align: left;
color: var(--bs-body-color);
background-color: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-sm);
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.hosts-file-filter-toggle:hover {
border-color: #bfc7d4;
}
.hosts-file-filter-wrap--open .hosts-file-filter-toggle {
border-color: #86b7fe;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.2);
}
.hosts-file-filter-toggle-label {
min-width: 0; min-width: 0;
max-width: none;
} }
.hosts-file-filter-chevron { .hosts-file-filter-wrap ::deep .hosts-file-filter-option {
font-size: 0.75rem;
opacity: 0.65;
transition: transform 0.15s ease;
}
.hosts-file-filter-wrap--open .hosts-file-filter-chevron {
transform: rotate(180deg);
}
.hosts-file-filter-panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: auto;
min-width: 100%;
width: max-content;
max-width: min(26rem, 92vw);
max-height: min(22rem, 55vh);
overflow-x: hidden;
overflow-y: auto;
z-index: 1085;
padding: 0.35rem;
background: var(--bs-body-bg, #fff);
border: 1px solid var(--bs-border-color, #e2e8f0);
border-radius: 10px;
}
.hosts-file-filter-option {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -170,32 +108,26 @@
cursor: pointer; cursor: pointer;
} }
.hosts-file-filter-option:hover { .hosts-file-filter-wrap ::deep .hosts-file-filter-option:hover {
background: rgba(15, 23, 42, 0.06); background: rgba(15, 23, 42, 0.06);
} }
.hosts-file-filter-option--active { .hosts-file-filter-wrap ::deep .hosts-file-filter-option--active {
background: rgba(13, 110, 253, 0.12); background: rgba(13, 110, 253, 0.12);
font-weight: 600; font-weight: 600;
} }
.hosts-file-filter-option--active:hover { .hosts-file-filter-wrap ::deep .hosts-file-filter-option--active:hover {
background: rgba(13, 110, 253, 0.18); background: rgba(13, 110, 253, 0.18);
} }
.hosts-file-filter-option--all { .hosts-file-filter-wrap ::deep .hosts-file-filter-option-main {
margin-bottom: 0.25rem;
border: 1px solid #e7ebf0;
background: linear-gradient(180deg, #fbfcfe 0%, #f4f6f9 100%);
}
.hosts-file-filter-option-main {
flex: 1 1 0%; flex: 1 1 0%;
min-width: 0; min-width: 0;
} }
/* Full path when width allows; when truncated, keep the end (usually the filename) visible. Full path in title for hover. */ /* Hosts-specific menu rows show the full path, but truncate from the left so the filename stays visible. */
.hosts-file-filter-path { .hosts-file-filter-wrap ::deep .hosts-file-filter-option-path {
display: block; display: block;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -205,7 +137,7 @@
unicode-bidi: plaintext; unicode-bidi: plaintext;
} }
.hosts-file-filter-count { .hosts-file-filter-wrap ::deep .hosts-file-filter-count {
flex-shrink: 0; flex-shrink: 0;
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
font-size: 0.7rem; font-size: 0.7rem;
@@ -217,57 +149,11 @@
border-radius: 999px; border-radius: 999px;
} }
.hosts-file-filter-option--active .hosts-file-filter-count { .hosts-file-filter-wrap ::deep .hosts-file-filter-option--active .hosts-file-filter-count {
color: #0d47a1; color: #0d47a1;
background: rgba(13, 110, 253, 0.2); background: rgba(13, 110, 253, 0.2);
} }
.hosts-file-filter-group {
margin-top: 0.35rem;
padding: 0.3rem 0.35rem 0.45rem;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
}
.hosts-file-filter-group:first-of-type {
margin-top: 0;
}
.hosts-file-filter-group--managed {
background: linear-gradient(180deg, #e8f4fd 0%, #dceef9 100%);
border-color: #b8d4ee;
}
.hosts-file-filter-group--system {
background: linear-gradient(180deg, #f1f5f9 0%, #e8edf3 100%);
border-color: #cbd5e1;
}
.hosts-file-filter-group--addn {
background: linear-gradient(180deg, #fff8e6 0%, #fff0cc 100%);
border-color: #e9d48a;
}
.hosts-file-filter-group--hostsdir {
background: linear-gradient(180deg, #ecfdf3 0%, #dff6e8 100%);
border-color: #a7e3c0;
}
.hosts-file-filter-group--other {
background: #f8fafc;
}
.hosts-file-filter-group-header {
padding: 0.2rem 0.35rem 0.45rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #334155;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
margin-bottom: 0.15rem;
}
.hosts-toolbar-select { .hosts-toolbar-select {
min-width: 9rem; min-width: 9rem;
max-width: 14rem; max-width: 14rem;
@@ -0,0 +1,187 @@
@typeparam TValue
@using DnsmasqWebUI.Models.Ui
<div class="grouped-select @(_open ? "grouped-select--open" : null)"
@onkeydown="OnKeyDown">
@if (_open)
{
<div class="grouped-select__backdrop" @onclick="Close" aria-hidden="true"></div>
}
@{
var triggerSummaryProvider = Builder as IGroupedSelectTriggerSummary<TValue>;
var triggerSummary = triggerSummaryProvider?.GetTriggerSummary(Value);
var summaryText = triggerSummary?.AccessibleFullText ?? Builder.GetSummaryText(Value);
var triggerKindClass = triggerSummary?.Kind switch
{
GroupedSelectTriggerSummaryKind.SingleSource => "grouped-select__trigger--filtered",
GroupedSelectTriggerSummaryKind.UnknownSource => "grouped-select__trigger--unknown",
_ => null
};
}
<button type="button"
id="@TriggerId"
class="grouped-select__trigger @TriggerClass @triggerKindClass"
title="@($"{Builder.TriggerTitle} — {summaryText}")"
aria-label="@($"{Builder.TriggerAriaLabel}. Current: {summaryText}")"
aria-haspopup="listbox"
aria-expanded="@(_open ? "true" : "false")"
aria-controls="@MenuId"
@onclick="Toggle"
@onclick:stopPropagation="true">
@if (triggerSummary is not null)
{
<span class="grouped-select__trigger-label grouped-select__trigger-label--rich">
@if (!string.IsNullOrEmpty(triggerSummary.CategoryPrefix))
{
<span class="grouped-select__trigger-prefix">@triggerSummary.CategoryPrefix</span>
<span class="grouped-select__trigger-sep" aria-hidden="true">·</span>
}
<span class="grouped-select__trigger-primary text-truncate">@triggerSummary.PrimaryLabel</span>
@if (!string.IsNullOrEmpty(triggerSummary.SecondaryMeta))
{
@if (triggerSummary.Kind == GroupedSelectTriggerSummaryKind.SingleSource)
{
<span class="grouped-select__trigger-count" aria-hidden="true">@triggerSummary.SecondaryMeta</span>
}
else
{
<span class="grouped-select__trigger-meta" aria-hidden="true">· @triggerSummary.SecondaryMeta</span>
}
}
</span>
}
else
{
<span class="grouped-select__trigger-label text-truncate">@Builder.GetSummaryText(Value)</span>
}
<i class="bi bi-chevron-down grouped-select__chevron flex-shrink-0" aria-hidden="true"></i>
</button>
@if (_open)
{
var model = Builder.Build();
<div id="@MenuId"
class="grouped-select__panel shadow"
role="listbox"
aria-label="@Builder.MenuAriaLabel"
@onclick:stopPropagation="true">
@if (model.AllOption is not null)
{
var allSelected = IsSelected(model.AllOption.Value);
@if (AllOptionTemplate is not null)
{
@AllOptionTemplate(new GroupedSelectAllOptionContext<TValue>(
model.AllOption,
allSelected,
EventCallback.Factory.Create(this, () => Select(model.AllOption!.Value))))
}
else
{
var option = model.AllOption;
<button type="button"
class="grouped-select__option grouped-select__option--all @(allSelected ? "grouped-select__option--active" : null)"
role="option"
aria-selected="@(allSelected ? "true" : "false")"
@onclick="async () => await Select(option.Value)">
<span class="grouped-select__option-main">@option.Label</span>
@if (option.Count is not null)
{
<span class="grouped-select__count">@option.Count</span>
}
</button>
}
}
@foreach (var grp in model.Sections.OrderBy(s => s.Order))
{
<div class="grouped-select__group grouped-select__group--@(grp.Kind ?? "other")">
@if (GroupHeaderTemplate is not null)
{
@GroupHeaderTemplate(grp)
}
else
{
<div class="grouped-select__group-header" role="presentation">@grp.Label</div>
}
@foreach (var option in grp.Options)
{
<div @key="option.Value">
@if (OptionTemplate is not null)
{
var active = IsSelected(option.Value);
@OptionTemplate(new GroupedSelectOptionSelectContext<TValue>(
option,
active,
EventCallback.Factory.Create(this, () => Select(option.Value))))
}
else
{
var active = IsSelected(option.Value);
<button type="button"
class="grouped-select__option @(active ? "grouped-select__option--active" : null)"
role="option"
aria-selected="@(active ? "true" : "false")"
title="@(option.Title ?? option.Label)"
@onclick="async () => await Select(option.Value)">
<span class="grouped-select__option-main"
title="@(option.Title ?? option.Label)">@option.Label</span>
@if (option.Count is not null)
{
<span class="grouped-select__count">@option.Count</span>
}
</button>
}
</div>
}
</div>
}
</div>
}
</div>
@code {
[Parameter, EditorRequired] public IGroupedSelectBuilder<TValue> Builder { get; set; } = default!;
[Parameter] public TValue? Value { get; set; }
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }
[Parameter] public string TriggerId { get; set; } = "grouped-select-trigger";
[Parameter] public string MenuId { get; set; } = "grouped-select-menu";
[Parameter] public string TriggerClass { get; set; } = "";
[Parameter] public RenderFragment<GroupedSelectSection<TValue>>? GroupHeaderTemplate { get; set; }
[Parameter] public RenderFragment<GroupedSelectOptionSelectContext<TValue>>? OptionTemplate { get; set; }
[Parameter] public RenderFragment<GroupedSelectAllOptionContext<TValue>>? AllOptionTemplate { get; set; }
private bool _open;
public bool CloseMenu()
{
if (!_open)
return false;
_open = false;
StateHasChanged();
return true;
}
private void Toggle() => _open = !_open;
private void Close()
{
_open = false;
}
private async Task Select(TValue? value)
{
_open = false;
await ValueChanged.InvokeAsync(value);
}
private bool IsSelected(TValue? candidate) =>
EqualityComparer<TValue?>.Default.Equals(Value, candidate);
private Task OnKeyDown(KeyboardEventArgs e)
{
if (_open && e.Key == "Escape")
{
_open = false;
StateHasChanged();
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,259 @@
/* Sizing is owned by the parent (e.g. Hosts .hosts-file-filter-wrap); default fills container width. */
.grouped-select {
position: relative;
width: 100%;
box-sizing: border-box;
}
.grouped-select--open {
z-index: 1080;
}
.grouped-select__backdrop {
position: fixed;
inset: 0;
z-index: 1070;
background: transparent;
}
.grouped-select__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
width: 100%;
min-height: calc(1.5em + 0.75rem);
padding: 0.375rem 0.65rem;
font-size: 0.8125rem;
line-height: 1.4;
text-align: left;
color: var(--bs-body-color);
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #d8dee9;
border-radius: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
transition: border-color 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.grouped-select__trigger:hover {
border-color: #b8c4d4;
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
}
.grouped-select__trigger--filtered {
border-color: #93b9f0;
background: linear-gradient(180deg, #f6f9ff 0%, #eef4fc 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.grouped-select__trigger--filtered:hover {
border-color: #7aa8e8;
}
.grouped-select__trigger--unknown {
border-color: #e9c46c;
background: linear-gradient(180deg, #fffdf8 0%, #fff6e5 100%);
}
.grouped-select--open .grouped-select__trigger {
border-color: #86b7fe;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
.grouped-select__trigger-label {
min-width: 0;
flex: 1 1 auto;
}
.grouped-select__trigger-label--rich {
display: flex;
align-items: center;
gap: 0.28rem;
min-width: 0;
}
.grouped-select__trigger-prefix {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #64748b;
}
.grouped-select__trigger--filtered .grouped-select__trigger-prefix {
color: #2563eb;
}
.grouped-select__trigger-sep {
flex-shrink: 0;
opacity: 0.45;
font-weight: 500;
color: #64748b;
}
.grouped-select__trigger-primary {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
color: #0f172a;
}
.grouped-select__trigger-meta {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.grouped-select__trigger-count {
flex-shrink: 0;
min-width: 1.35rem;
padding: 0.12rem 0.4rem;
font-size: 0.68rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.2;
text-align: center;
color: #1e40af;
background: rgba(37, 99, 235, 0.12);
border-radius: 999px;
}
.grouped-select__chevron {
font-size: 0.8rem;
opacity: 0.55;
transition: transform 0.15s ease, opacity 0.12s ease;
}
.grouped-select__trigger:hover .grouped-select__chevron {
opacity: 0.85;
}
.grouped-select--open .grouped-select__chevron {
transform: rotate(180deg);
}
.grouped-select__panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: auto;
min-width: 100%;
width: max-content;
max-width: min(26rem, 92vw);
max-height: min(22rem, 55vh);
overflow-x: hidden;
overflow-y: auto;
z-index: 1085;
padding: 0.35rem;
background: var(--bs-body-bg, #fff);
border: 1px solid var(--bs-border-color, #e2e8f0);
border-radius: 10px;
}
.grouped-select__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
width: 100%;
padding: 0.4rem 0.55rem;
margin: 0;
font-size: 0.8125rem;
line-height: 1.35;
text-align: left;
color: #1e293b;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
}
.grouped-select__option:hover {
background: rgba(15, 23, 42, 0.06);
}
.grouped-select__option--active {
background: rgba(13, 110, 253, 0.12);
font-weight: 600;
}
.grouped-select__option--active:hover {
background: rgba(13, 110, 253, 0.18);
}
.grouped-select__option--all {
margin-bottom: 0.25rem;
border: 1px solid #e7ebf0;
background: linear-gradient(180deg, #fbfcfe 0%, #f4f6f9 100%);
}
.grouped-select__option-main {
flex: 1 1 0%;
min-width: 0;
}
.grouped-select__count {
flex-shrink: 0;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.3;
color: #475569;
background: rgba(15, 23, 42, 0.06);
border-radius: 999px;
}
.grouped-select__option--active .grouped-select__count {
color: #0d47a1;
background: rgba(13, 110, 253, 0.2);
}
.grouped-select__group {
margin-top: 0.35rem;
padding: 0.3rem 0.35rem 0.45rem;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
}
.grouped-select__group:first-of-type {
margin-top: 0;
}
.grouped-select__group--managed {
background: linear-gradient(180deg, #e8f4fd 0%, #dceef9 100%);
border-color: #b8d4ee;
}
.grouped-select__group--system {
background: linear-gradient(180deg, #f1f5f9 0%, #e8edf3 100%);
border-color: #cbd5e1;
}
.grouped-select__group--addn {
background: linear-gradient(180deg, #fff8e6 0%, #fff0cc 100%);
border-color: #e9d48a;
}
.grouped-select__group--hostsdir {
background: linear-gradient(180deg, #ecfdf3 0%, #dff6e8 100%);
border-color: #a7e3c0;
}
.grouped-select__group--other {
background: #f8fafc;
}
.grouped-select__group-header {
padding: 0.2rem 0.35rem 0.45rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #334155;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
margin-bottom: 0.15rem;
}
+1 -1
View File
@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>0.0.7</Version> <Version>0.0.8</Version>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). --> <!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
<DisableBuildCompression>true</DisableBuildCompression> <DisableBuildCompression>true</DisableBuildCompression>
@@ -0,0 +1,174 @@
using DnsmasqWebUI.Models.Ui;
namespace DnsmasqWebUI.Models.Hosts;
public sealed class HostsFileFilterBuilder : IGroupedSelectBuilder<string>, IGroupedSelectTriggerSummary<string>
{
private readonly IReadOnlyList<HostsPageRow> _rows;
private readonly string? _managedHostsPath;
public HostsFileFilterBuilder(IReadOnlyList<HostsPageRow> rows, string? managedHostsPath)
{
_rows = rows;
_managedHostsPath = managedHostsPath;
}
public string TriggerTitle =>
"Show entries from all loaded hosts files, or only one file";
public string TriggerAriaLabel => "Filter by hosts file";
public string MenuAriaLabel => "Hosts files";
public GroupedSelectModel<string> Build()
{
var byPath = _rows
.Where(r => !string.IsNullOrWhiteSpace(r.SourcePath))
.GroupBy(r => r.SourcePath.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(g => new
{
Path = g.Key,
SourceKind = g.First().SourceKind,
Count = g.Count(r => !r.IsComment)
})
.ToList();
var managed = _managedHostsPath?.Trim();
if (!string.IsNullOrWhiteSpace(managed)
&& !byPath.Any(x => string.Equals(x.Path, managed, StringComparison.OrdinalIgnoreCase)))
{
byPath.Add(new
{
Path = managed,
SourceKind = HostsRowSourceKind.Managed,
Count = 0
});
}
var sections = byPath
.GroupBy(x => x.SourceKind)
.OrderBy(g => SourceOrder(g.Key))
.Select(g => new GroupedSelectSection<string>(
Label: GroupTitle(g.Key),
Kind: GroupKind(g.Key),
Order: SourceOrder(g.Key),
Options: g
.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
.Select(x => new GroupedSelectOption<string>(
Value: x.Path,
Label: FormatOptionLabel(x.Path),
Count: x.Count,
Title: x.Path))
.ToList()))
.ToList();
return new GroupedSelectModel<string>(
AllOption: new GroupedSelectOption<string>(
Value: "",
Label: "All files",
Count: _rows.Count(r => !r.IsComment),
Title: null),
Sections: sections);
}
public string GetSummaryText(string? selectedValue) =>
GetTriggerSummary(selectedValue).AccessibleFullText;
public GroupedSelectTriggerSummary GetTriggerSummary(string? selectedValue)
{
var model = Build();
if (string.IsNullOrWhiteSpace(selectedValue))
{
var count = model.AllOption?.Count ?? 0;
if (count == 0)
{
return new GroupedSelectTriggerSummary(
AccessibleFullText: "All files",
Kind: GroupedSelectTriggerSummaryKind.AllSources,
CategoryPrefix: null,
PrimaryLabel: "All files",
SecondaryMeta: null);
}
return new GroupedSelectTriggerSummary(
AccessibleFullText: $"All files · {count} records",
Kind: GroupedSelectTriggerSummaryKind.AllSources,
CategoryPrefix: null,
PrimaryLabel: "All files",
SecondaryMeta: $"{count} records");
}
var selected = selectedValue.Trim();
foreach (var section in model.Sections)
{
foreach (var option in section.Options)
{
if (string.Equals(option.Value, selected, StringComparison.OrdinalIgnoreCase))
{
var count = option.Count ?? 0;
var prefix = SummaryPrefix(section.Kind);
return new GroupedSelectTriggerSummary(
AccessibleFullText: $"{prefix} · {option.Label} ({count})",
Kind: GroupedSelectTriggerSummaryKind.SingleSource,
CategoryPrefix: prefix,
PrimaryLabel: option.Label,
SecondaryMeta: count.ToString());
}
}
}
var fallback = FormatOptionLabel(selected);
return new GroupedSelectTriggerSummary(
AccessibleFullText: fallback,
Kind: GroupedSelectTriggerSummaryKind.UnknownSource,
CategoryPrefix: null,
PrimaryLabel: fallback,
SecondaryMeta: null);
}
private static string FormatOptionLabel(string path)
{
if (string.IsNullOrWhiteSpace(path))
return path;
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var name = Path.GetFileName(trimmed);
return string.IsNullOrEmpty(name) ? path : name;
}
private static int SourceOrder(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => 0,
HostsRowSourceKind.SystemHosts => 1,
HostsRowSourceKind.AddnHosts => 2,
HostsRowSourceKind.Hostsdir => 3,
_ => 99
};
private static string GroupTitle(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "Managed hosts",
HostsRowSourceKind.SystemHosts => "System hosts",
HostsRowSourceKind.AddnHosts => "Additional hosts",
HostsRowSourceKind.Hostsdir => "Hosts directory",
_ => "Hosts files"
};
private static string GroupKind(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "managed",
HostsRowSourceKind.SystemHosts => "system",
HostsRowSourceKind.AddnHosts => "addn",
HostsRowSourceKind.Hostsdir => "hostsdir",
_ => "other"
};
private static string SummaryPrefix(string? kind) => kind switch
{
"managed" => "Managed",
"system" => "System",
"addn" => "Additional",
"hostsdir" => "Hosts dir",
_ => "Hosts"
};
}
@@ -1,10 +0,0 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>One hosts file row in the filter menu (path, short name, non-comment line count).</summary>
public sealed record HostsFileFilterMenuItem(string Path, string FileName, int RecordCount);
/// <summary>A source-kind section in the hosts file filter menu.</summary>
public sealed record HostsFileFilterMenuGroup(
HostsRowSourceKind SourceKind,
string Title,
IReadOnlyList<HostsFileFilterMenuItem> Files);
@@ -1,81 +0,0 @@
namespace DnsmasqWebUI.Models.Hosts;
/// <summary>Labels for hosts <see cref="HostsPageRow.SourcePath"/> in UI (e.g. file filter dropdown).</summary>
public static class HostsSourcePathDisplay
{
public static string FormatOptionLabel(string path)
{
if (string.IsNullOrWhiteSpace(path))
return path;
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var name = Path.GetFileName(trimmed);
return string.IsNullOrEmpty(name) ? path : name;
}
/// <summary>
/// Builds grouped menu data: paths grouped by <see cref="HostsRowSourceKind"/> with per-file record counts
/// (non-comment lines). Ensures <paramref name="managedHostsPathAlwaysShow"/> appears even when there are no managed rows yet.
/// </summary>
public static IReadOnlyList<HostsFileFilterMenuGroup> BuildFileFilterMenuGroups(
IReadOnlyList<HostsPageRow> rows,
string? managedHostsPathAlwaysShow)
{
var byPath = rows
.Where(r => !string.IsNullOrWhiteSpace(r.SourcePath))
.GroupBy(r => r.SourcePath.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(g => (
Path: g.Key,
Kind: g.First().SourceKind,
Count: g.Count(r => !r.IsComment)))
.ToList();
var managedTrim = managedHostsPathAlwaysShow?.Trim();
if (!string.IsNullOrEmpty(managedTrim)
&& !byPath.Any(e => string.Equals(e.Path, managedTrim, StringComparison.OrdinalIgnoreCase)))
byPath.Add((managedTrim, HostsRowSourceKind.Managed, 0));
return byPath
.GroupBy(e => e.Kind)
.OrderBy(g => SourceKindOrder(g.Key))
.Select(g =>
{
var files = g.OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
.Select(e => new HostsFileFilterMenuItem(e.Path, FormatOptionLabel(e.Path), e.Count))
.ToList();
return new HostsFileFilterMenuGroup(
g.Key,
GroupMenuTitle(g.Key),
files);
})
.ToList();
}
public static string GroupMenuTitle(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "Managed hosts",
HostsRowSourceKind.SystemHosts => "System hosts",
HostsRowSourceKind.AddnHosts => "Additional hosts",
HostsRowSourceKind.Hostsdir => "Hosts directory",
_ => "Hosts files"
};
public static string GroupSummaryPrefix(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => "Managed",
HostsRowSourceKind.SystemHosts => "System",
HostsRowSourceKind.AddnHosts => "Additional",
HostsRowSourceKind.Hostsdir => "Hosts dir",
_ => "Hosts"
};
private static int SourceKindOrder(HostsRowSourceKind kind) => kind switch
{
HostsRowSourceKind.Managed => 0,
HostsRowSourceKind.SystemHosts => 1,
HostsRowSourceKind.AddnHosts => 2,
HostsRowSourceKind.Hostsdir => 3,
_ => 99
};
}
@@ -0,0 +1,26 @@
namespace DnsmasqWebUI.Models.Ui;
public sealed record GroupedSelectOption<TValue>(
TValue Value,
string Label,
int? Count = null,
string? Title = null);
public sealed record GroupedSelectSection<TValue>(
string Label,
IReadOnlyList<GroupedSelectOption<TValue>> Options,
string? Kind = null,
int Order = 0);
public sealed record GroupedSelectModel<TValue>(
GroupedSelectOption<TValue>? AllOption,
IReadOnlyList<GroupedSelectSection<TValue>> Sections);
public interface IGroupedSelectBuilder<TValue>
{
GroupedSelectModel<TValue> Build();
string GetSummaryText(TValue? selectedValue);
string TriggerTitle { get; }
string TriggerAriaLabel { get; }
string MenuAriaLabel { get; }
}
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Models.Ui;
public sealed record GroupedSelectAllOptionContext<TValue>(
GroupedSelectOption<TValue> Option,
bool IsSelected,
EventCallback OnSelect);
public sealed record GroupedSelectOptionSelectContext<TValue>(
GroupedSelectOption<TValue> Option,
bool IsSelected,
EventCallback OnSelect);
@@ -0,0 +1,22 @@
namespace DnsmasqWebUI.Models.Ui;
public enum GroupedSelectTriggerSummaryKind
{
AllSources,
SingleSource,
UnknownSource
}
/// <summary>Optional structured lines for the collapsed trigger; use with <see cref="IGroupedSelectTriggerSummary{TValue}"/>.</summary>
public sealed record GroupedSelectTriggerSummary(
string AccessibleFullText,
GroupedSelectTriggerSummaryKind Kind,
string? CategoryPrefix,
string PrimaryLabel,
string? SecondaryMeta);
/// <summary>Builders that can supply structured trigger content for richer styling.</summary>
public interface IGroupedSelectTriggerSummary<TValue>
{
GroupedSelectTriggerSummary GetTriggerSummary(TValue? selectedValue);
}