diff --git a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsFileFilterBuilderTests.cs b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsFileFilterBuilderTests.cs
new file mode 100644
index 0000000..c634b85
--- /dev/null
+++ b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsFileFilterBuilderTests.cs
@@ -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);
+ }
+}
diff --git a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsSourcePathDisplayTests.cs b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsSourcePathDisplayTests.cs
deleted file mode 100644
index 3c7df26..0000000
--- a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsSourcePathDisplayTests.cs
+++ /dev/null
@@ -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);
- }
-}
diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor
index 9ae45fd..060089d 100644
--- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor
+++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor
@@ -5,6 +5,7 @@
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.PageStates
@using DnsmasqWebUI.Models.Hosts
+@using DnsmasqWebUI.Models.Ui
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig
@@ -78,63 +79,31 @@ else
@bind="_search"
@bind:event="oninput" />
-
@@ -619,72 +588,19 @@ else
ActiveFilter: null);
}
- private bool _fileFilterMenuOpen;
+ private GroupedSelectDropdown? _hostsFileFilterDropdown;
- private IReadOnlyList FileFilterMenuGroups =>
- HostsSourcePathDisplay.BuildFileFilterMenuGroups(_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 IGroupedSelectBuilder HostsFileFilterBuilder =>
+ new HostsFileFilterBuilder(_unifiedRows, _status?.ManagedHostsFilePath);
private async Task OnHostsToolbarKeyDown(KeyboardEventArgs e)
{
- if (!_fileFilterMenuOpen || e.Key != "Escape")
+ if (e.Key != "Escape")
return;
- CloseFileFilterMenu();
- await InvokeAsync(StateHasChanged);
+ if (_hostsFileFilterDropdown?.CloseMenu() == true)
+ 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()
{
InvalidateVisibleGroupsCache();
@@ -950,9 +866,8 @@ else
if (_showRemoveHostsModal || _showInvalidHostEditModal)
return;
- if (_fileFilterMenuOpen && e.Key == "Escape")
+ if (e.Key == "Escape" && (_hostsFileFilterDropdown?.CloseMenu() ?? false))
{
- CloseFileFilterMenu();
await InvokeAsync(StateHasChanged);
return;
}
diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css
index c491e79..d527fd2 100644
--- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css
+++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor.css
@@ -74,85 +74,23 @@
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 {
position: relative;
- flex: 0 0 auto;
- min-width: 13.5rem;
- max-width: 22rem;
+ flex: 0 1 22rem;
+ min-width: min(13.5rem, 100%);
+ max-width: min(22rem, 100%);
width: auto;
}
-.hosts-file-filter-wrap--open {
- z-index: 1080;
-}
-
-.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;
+.hosts-file-filter-wrap ::deep .grouped-select {
+ display: block;
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;
+ max-width: none;
}
-.hosts-file-filter-chevron {
- 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 {
+.hosts-file-filter-wrap ::deep .hosts-file-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
@@ -170,32 +108,26 @@
cursor: pointer;
}
-.hosts-file-filter-option:hover {
+.hosts-file-filter-wrap ::deep .hosts-file-filter-option:hover {
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);
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);
}
-.hosts-file-filter-option--all {
- margin-bottom: 0.25rem;
- border: 1px solid #e7ebf0;
- background: linear-gradient(180deg, #fbfcfe 0%, #f4f6f9 100%);
-}
-
-.hosts-file-filter-option-main {
+.hosts-file-filter-wrap ::deep .hosts-file-filter-option-main {
flex: 1 1 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-file-filter-path {
+/* Hosts-specific menu rows show the full path, but truncate from the left so the filename stays visible. */
+.hosts-file-filter-wrap ::deep .hosts-file-filter-option-path {
display: block;
overflow: hidden;
white-space: nowrap;
@@ -205,7 +137,7 @@
unicode-bidi: plaintext;
}
-.hosts-file-filter-count {
+.hosts-file-filter-wrap ::deep .hosts-file-filter-count {
flex-shrink: 0;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
@@ -217,57 +149,11 @@
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;
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 {
min-width: 9rem;
max-width: 14rem;
diff --git a/src/DnsmasqWebUI/Components/Shared/GroupedSelectDropdown.razor b/src/DnsmasqWebUI/Components/Shared/GroupedSelectDropdown.razor
new file mode 100644
index 0000000..c350e4b
--- /dev/null
+++ b/src/DnsmasqWebUI/Components/Shared/GroupedSelectDropdown.razor
@@ -0,0 +1,187 @@
+@typeparam TValue
+@using DnsmasqWebUI.Models.Ui
+
+
+ @if (_open)
+ {
+
+ }
+ @{
+ var triggerSummaryProvider = Builder as IGroupedSelectTriggerSummary
;
+ 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
+ };
+ }
+
+ @if (_open)
+ {
+ var model = Builder.Build();
+
+ @if (model.AllOption is not null)
+ {
+ var allSelected = IsSelected(model.AllOption.Value);
+ @if (AllOptionTemplate is not null)
+ {
+ @AllOptionTemplate(new GroupedSelectAllOptionContext
(
+ model.AllOption,
+ allSelected,
+ EventCallback.Factory.Create(this, () => Select(model.AllOption!.Value))))
+ }
+ else
+ {
+ var option = model.AllOption;
+
+ }
+ }
+ @foreach (var grp in model.Sections.OrderBy(s => s.Order))
+ {
+
+ @if (GroupHeaderTemplate is not null)
+ {
+ @GroupHeaderTemplate(grp)
+ }
+ else
+ {
+
+ }
+ @foreach (var option in grp.Options)
+ {
+
+ @if (OptionTemplate is not null)
+ {
+ var active = IsSelected(option.Value);
+ @OptionTemplate(new GroupedSelectOptionSelectContext(
+ option,
+ active,
+ EventCallback.Factory.Create(this, () => Select(option.Value))))
+ }
+ else
+ {
+ var active = IsSelected(option.Value);
+
+ }
+
+ }
+
+ }
+
+ }
+
+
+@code {
+ [Parameter, EditorRequired] public IGroupedSelectBuilder Builder { get; set; } = default!;
+ [Parameter] public TValue? Value { get; set; }
+ [Parameter] public EventCallback 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>? GroupHeaderTemplate { get; set; }
+ [Parameter] public RenderFragment>? OptionTemplate { get; set; }
+ [Parameter] public RenderFragment>? 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.Default.Equals(Value, candidate);
+
+ private Task OnKeyDown(KeyboardEventArgs e)
+ {
+ if (_open && e.Key == "Escape")
+ {
+ _open = false;
+ StateHasChanged();
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/DnsmasqWebUI/Components/Shared/GroupedSelectDropdown.razor.css b/src/DnsmasqWebUI/Components/Shared/GroupedSelectDropdown.razor.css
new file mode 100644
index 0000000..ceb3ba8
--- /dev/null
+++ b/src/DnsmasqWebUI/Components/Shared/GroupedSelectDropdown.razor.css
@@ -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;
+}
diff --git a/src/DnsmasqWebUI/DnsmasqWebUI.csproj b/src/DnsmasqWebUI/DnsmasqWebUI.csproj
index 9a9f951..05179b7 100644
--- a/src/DnsmasqWebUI/DnsmasqWebUI.csproj
+++ b/src/DnsmasqWebUI/DnsmasqWebUI.csproj
@@ -3,7 +3,7 @@
net10.0
enable
- 0.0.7
+ 0.0.8
enable
true
diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterBuilder.cs b/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterBuilder.cs
new file mode 100644
index 0000000..aa9140a
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterBuilder.cs
@@ -0,0 +1,174 @@
+using DnsmasqWebUI.Models.Ui;
+
+namespace DnsmasqWebUI.Models.Hosts;
+
+public sealed class HostsFileFilterBuilder : IGroupedSelectBuilder, IGroupedSelectTriggerSummary
+{
+ private readonly IReadOnlyList _rows;
+ private readonly string? _managedHostsPath;
+
+ public HostsFileFilterBuilder(IReadOnlyList 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 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(
+ Label: GroupTitle(g.Key),
+ Kind: GroupKind(g.Key),
+ Order: SourceOrder(g.Key),
+ Options: g
+ .OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)
+ .Select(x => new GroupedSelectOption(
+ Value: x.Path,
+ Label: FormatOptionLabel(x.Path),
+ Count: x.Count,
+ Title: x.Path))
+ .ToList()))
+ .ToList();
+
+ return new GroupedSelectModel(
+ AllOption: new GroupedSelectOption(
+ 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"
+ };
+}
diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterMenuModels.cs b/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterMenuModels.cs
deleted file mode 100644
index 4e8b256..0000000
--- a/src/DnsmasqWebUI/Models/Hosts/HostsFileFilterMenuModels.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace DnsmasqWebUI.Models.Hosts;
-
-/// One hosts file row in the filter menu (path, short name, non-comment line count).
-public sealed record HostsFileFilterMenuItem(string Path, string FileName, int RecordCount);
-
-/// A source-kind section in the hosts file filter menu.
-public sealed record HostsFileFilterMenuGroup(
- HostsRowSourceKind SourceKind,
- string Title,
- IReadOnlyList Files);
diff --git a/src/DnsmasqWebUI/Models/Hosts/HostsSourcePathDisplay.cs b/src/DnsmasqWebUI/Models/Hosts/HostsSourcePathDisplay.cs
deleted file mode 100644
index 2b90396..0000000
--- a/src/DnsmasqWebUI/Models/Hosts/HostsSourcePathDisplay.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-namespace DnsmasqWebUI.Models.Hosts;
-
-/// Labels for hosts in UI (e.g. file filter dropdown).
-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;
- }
-
- ///
- /// Builds grouped menu data: paths grouped by with per-file record counts
- /// (non-comment lines). Ensures appears even when there are no managed rows yet.
- ///
- public static IReadOnlyList BuildFileFilterMenuGroups(
- IReadOnlyList 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
- };
-
-}
diff --git a/src/DnsmasqWebUI/Models/Ui/GroupedSelectModels.cs b/src/DnsmasqWebUI/Models/Ui/GroupedSelectModels.cs
new file mode 100644
index 0000000..82417e8
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/Ui/GroupedSelectModels.cs
@@ -0,0 +1,26 @@
+namespace DnsmasqWebUI.Models.Ui;
+
+public sealed record GroupedSelectOption(
+ TValue Value,
+ string Label,
+ int? Count = null,
+ string? Title = null);
+
+public sealed record GroupedSelectSection(
+ string Label,
+ IReadOnlyList> Options,
+ string? Kind = null,
+ int Order = 0);
+
+public sealed record GroupedSelectModel(
+ GroupedSelectOption? AllOption,
+ IReadOnlyList> Sections);
+
+public interface IGroupedSelectBuilder
+{
+ GroupedSelectModel Build();
+ string GetSummaryText(TValue? selectedValue);
+ string TriggerTitle { get; }
+ string TriggerAriaLabel { get; }
+ string MenuAriaLabel { get; }
+}
diff --git a/src/DnsmasqWebUI/Models/Ui/GroupedSelectTemplateContexts.cs b/src/DnsmasqWebUI/Models/Ui/GroupedSelectTemplateContexts.cs
new file mode 100644
index 0000000..9cad20a
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/Ui/GroupedSelectTemplateContexts.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Components;
+
+namespace DnsmasqWebUI.Models.Ui;
+
+public sealed record GroupedSelectAllOptionContext(
+ GroupedSelectOption Option,
+ bool IsSelected,
+ EventCallback OnSelect);
+
+public sealed record GroupedSelectOptionSelectContext(
+ GroupedSelectOption Option,
+ bool IsSelected,
+ EventCallback OnSelect);
diff --git a/src/DnsmasqWebUI/Models/Ui/GroupedSelectTriggerSummary.cs b/src/DnsmasqWebUI/Models/Ui/GroupedSelectTriggerSummary.cs
new file mode 100644
index 0000000..29ff1d2
--- /dev/null
+++ b/src/DnsmasqWebUI/Models/Ui/GroupedSelectTriggerSummary.cs
@@ -0,0 +1,22 @@
+namespace DnsmasqWebUI.Models.Ui;
+
+public enum GroupedSelectTriggerSummaryKind
+{
+ AllSources,
+ SingleSource,
+ UnknownSource
+}
+
+/// Optional structured lines for the collapsed trigger; use with .
+public sealed record GroupedSelectTriggerSummary(
+ string AccessibleFullText,
+ GroupedSelectTriggerSummaryKind Kind,
+ string? CategoryPrefix,
+ string PrimaryLabel,
+ string? SecondaryMeta);
+
+/// Builders that can supply structured trigger content for richer styling.
+public interface IGroupedSelectTriggerSummary
+{
+ GroupedSelectTriggerSummary GetTriggerSummary(TValue? selectedValue);
+}