mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
<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)">
|
||||
@if (_fileFilterMenuOpen)
|
||||
{
|
||||
<div class="hosts-file-filter-backdrop" @onclick="CloseFileFilterMenu" aria-hidden="true"></div>
|
||||
}
|
||||
<div class="hosts-file-filter-wrap">
|
||||
<GroupedSelectDropdown TValue="string"
|
||||
@ref="_hostsFileFilterDropdown"
|
||||
Builder="HostsFileFilterBuilder"
|
||||
@bind-Value="_sourceFileFilter"
|
||||
@bind-Value:after="OnSourceFileFilterChangedAfterBind"
|
||||
TriggerId="hosts-file-filter-trigger"
|
||||
MenuId="hosts-file-filter-menu"
|
||||
TriggerClass="hosts-toolbar-file-filter">
|
||||
<OptionTemplate Context="ctx">
|
||||
<button type="button"
|
||||
id="hosts-file-filter-trigger"
|
||||
class="hosts-file-filter-toggle hosts-toolbar-file-filter"
|
||||
title="Show entries from all loaded hosts files, or only one file"
|
||||
aria-label="Filter by hosts file"
|
||||
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"
|
||||
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"
|
||||
aria-selected="@(string.IsNullOrWhiteSpace(_sourceFileFilter) ? "true" : "false")"
|
||||
@onclick="@(() => SelectSourceFileFilter(""))">
|
||||
<span class="hosts-file-filter-option-main">All files</span>
|
||||
<span class="hosts-file-filter-count">@HostsTotalDataRowCount</span>
|
||||
</button>
|
||||
@foreach (var group in FileFilterMenuGroups)
|
||||
aria-selected="@(ctx.IsSelected ? "true" : "false")"
|
||||
title="@(ctx.Option.Title ?? ctx.Option.Label)"
|
||||
@onclick="ctx.OnSelect">
|
||||
<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)
|
||||
{
|
||||
<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>
|
||||
<span class="hosts-file-filter-count">@ctx.Option.Count</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</OptionTemplate>
|
||||
</GroupedSelectDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,72 +588,19 @@ else
|
||||
ActiveFilter: null);
|
||||
}
|
||||
|
||||
private bool _fileFilterMenuOpen;
|
||||
private GroupedSelectDropdown<string>? _hostsFileFilterDropdown;
|
||||
|
||||
private IReadOnlyList<HostsFileFilterMenuGroup> 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<string> HostsFileFilterBuilder =>
|
||||
new HostsFileFilterBuilder(_unifiedRows, _status?.ManagedHostsFilePath);
|
||||
|
||||
private async Task OnHostsToolbarKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (!_fileFilterMenuOpen || e.Key != "Escape")
|
||||
if (e.Key != "Escape")
|
||||
return;
|
||||
CloseFileFilterMenu();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>0.0.7</Version>
|
||||
<Version>0.0.8</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
|
||||
<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);
|
||||
}
|
||||
Reference in New Issue
Block a user