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" /> -
- @if (_fileFilterMenuOpen) - { - - } - - @if (_fileFilterMenuOpen) - { -
+
+ + - @foreach (var group in FileFilterMenuGroups) - { -
- - @foreach (var file in group.Files) - { - var path = file.Path; - var active = IsSourceFilePathSelected(path); - - } -
- } -
- } + +
@@ -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); +}