Add searchable table index rail behaviors

This commit is contained in:
2026-03-21 14:59:09 +01:00
parent bed85b9778
commit 88018e047e
4 changed files with 409 additions and 21 deletions

View File

@@ -28,6 +28,8 @@
<TablesIndexRail
Tables="referenceData.CriticalTables"
SelectedTableSlug="selectedTableSlug"
PinnedTableSlugs="PinnedTablesState.Items.Select(item => item.Slug).ToArray()"
RecentTableSlugs="RecentTablesState.Items.Select(item => item.Slug).ToArray()"
IsPinned="PinnedTablesState.IsPinned"
OnSelectTable="SelectTableAsync" />
</aside>

View File

@@ -4,46 +4,319 @@
<p class="tables-index-copy">Choose a table, then read from the roll band across to the result you need.</p>
</div>
<div class="tables-index-list" role="listbox" aria-label="Critical tables">
@foreach (var table in Tables)
<div class="tables-index-controls" @onkeydown="HandleRailKeyDown">
<label class="tables-index-search-label" for="tables-index-search">Search tables</label>
<input
id="tables-index-search"
class="input-shell tables-index-search"
type="search"
placeholder="Search tables"
value="@searchText"
@oninput="HandleSearchInput" />
@if (familyFilters.Count > 1)
{
<button
type="button"
role="option"
aria-selected="@string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)"
class="table-index-option @GetTableOptionCssClass(table)"
@onclick="() => OnSelectTable.InvokeAsync(table.Key)">
<span class="table-index-option-copy">
<strong class="table-index-option-title">@table.Label</strong>
<span class="table-index-option-meta">@table.Family</span>
</span>
<span class="table-index-option-chips">
@if (GetIsPinned(table.Key))
{
<StatusChip Tone="accent">Pinned</StatusChip>
}
<span class="chip">@($"{table.CurationPercentage}%")</span>
</span>
</button>
<div class="tables-family-filters" aria-label="Filter table families">
@foreach (var family in familyFilters)
{
var isAllFilter = string.IsNullOrEmpty(family);
var label = isAllFilter ? "All families" : family;
<button
type="button"
class="@GetFamilyFilterCssClass(family)"
aria-pressed="@IsFamilyFilterSelected(family)"
@onclick="() => SelectFamilyFilter(family)">
@label
</button>
}
</div>
}
</div>
@if (pinnedTables.Count > 0)
{
<div class="tables-index-section">
<div class="tables-index-section-header">
<h3>Pinned</h3>
<span class="tables-index-section-count">@pinnedTables.Count</span>
</div>
<div class="tables-index-list" role="listbox" aria-label="Pinned critical tables">
@foreach (var table in pinnedTables)
{
@RenderTableOption(table)
}
</div>
</div>
}
@if (recentTables.Count > 0)
{
<div class="tables-index-section">
<div class="tables-index-section-header">
<h3>Recent</h3>
<span class="tables-index-section-count">@recentTables.Count</span>
</div>
<div class="tables-index-list" role="listbox" aria-label="Recent critical tables">
@foreach (var table in recentTables)
{
@RenderTableOption(table)
}
</div>
</div>
}
<div class="tables-index-section">
<div class="tables-index-section-header">
<h3>All tables</h3>
<span class="tables-index-section-count">@filteredTables.Count</span>
</div>
@if (filteredTables.Count == 0)
{
<p class="tables-index-empty">No tables match the current search.</p>
}
else
{
<div class="tables-index-list" role="listbox" aria-label="Critical tables">
@foreach (var table in filteredTables)
{
@RenderTableOption(table)
}
</div>
}
</div>
</section>
@code {
private readonly List<string> familyFilters = new();
private readonly List<CriticalTableReference> filteredTables = new();
private readonly List<CriticalTableReference> pinnedTables = new();
private readonly List<CriticalTableReference> recentTables = new();
private readonly List<CriticalTableReference> keyboardOptions = new();
private string searchText = string.Empty;
private string selectedFamily = string.Empty;
private string? activeOptionSlug;
[Parameter]
public IReadOnlyList<CriticalTableReference> Tables { get; set; } = Array.Empty<CriticalTableReference>();
[Parameter]
public string SelectedTableSlug { get; set; } = string.Empty;
[Parameter]
public IReadOnlyList<string> PinnedTableSlugs { get; set; } = Array.Empty<string>();
[Parameter]
public IReadOnlyList<string> RecentTableSlugs { get; set; } = Array.Empty<string>();
[Parameter]
public Func<string, bool>? IsPinned { get; set; }
[Parameter]
public EventCallback<string> OnSelectTable { get; set; }
protected override void OnParametersSet()
{
BuildFamilyFilters();
BuildSections();
EnsureActiveOption();
}
private bool GetIsPinned(string tableSlug) => IsPinned?.Invoke(tableSlug) ?? false;
private void BuildFamilyFilters()
{
familyFilters.Clear();
familyFilters.Add(string.Empty);
foreach (var family in Tables
.Select(table => table.Family)
.Where(family => !string.IsNullOrWhiteSpace(family))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(family => family, StringComparer.OrdinalIgnoreCase))
{
familyFilters.Add(family);
}
}
private void BuildSections()
{
filteredTables.Clear();
pinnedTables.Clear();
recentTables.Clear();
keyboardOptions.Clear();
foreach (var table in Tables)
{
if (!MatchesFilters(table))
{
continue;
}
filteredTables.Add(table);
}
foreach (var slug in PinnedTableSlugs)
{
var table = filteredTables.FirstOrDefault(item => string.Equals(item.Key, slug, StringComparison.OrdinalIgnoreCase));
if (table is not null)
{
pinnedTables.Add(table);
}
}
foreach (var slug in RecentTableSlugs)
{
var table = filteredTables.FirstOrDefault(item => string.Equals(item.Key, slug, StringComparison.OrdinalIgnoreCase));
if (table is not null && pinnedTables.All(item => !string.Equals(item.Key, table.Key, StringComparison.OrdinalIgnoreCase)))
{
recentTables.Add(table);
}
}
foreach (var table in pinnedTables)
{
AddKeyboardOption(table);
}
foreach (var table in recentTables)
{
AddKeyboardOption(table);
}
foreach (var table in filteredTables)
{
AddKeyboardOption(table);
}
}
private void AddKeyboardOption(CriticalTableReference table)
{
if (keyboardOptions.Any(item => string.Equals(item.Key, table.Key, StringComparison.OrdinalIgnoreCase)))
{
return;
}
keyboardOptions.Add(table);
}
private bool MatchesFilters(CriticalTableReference table)
{
if (!string.IsNullOrEmpty(selectedFamily) &&
!string.Equals(table.Family, selectedFamily, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrWhiteSpace(searchText))
{
return true;
}
return table.Label.Contains(searchText, StringComparison.OrdinalIgnoreCase)
|| table.Key.Contains(searchText, StringComparison.OrdinalIgnoreCase)
|| table.Family.Contains(searchText, StringComparison.OrdinalIgnoreCase);
}
private void HandleSearchInput(ChangeEventArgs args)
{
searchText = args.Value?.ToString()?.Trim() ?? string.Empty;
BuildSections();
EnsureActiveOption();
}
private void SelectFamilyFilter(string family)
{
selectedFamily = family;
BuildSections();
EnsureActiveOption();
}
private bool IsFamilyFilterSelected(string family) =>
string.Equals(selectedFamily, family, StringComparison.OrdinalIgnoreCase);
private string GetFamilyFilterCssClass(string family) =>
IsFamilyFilterSelected(family)
? "tables-family-filter is-selected"
: "tables-family-filter";
private void HandleRailKeyDown(KeyboardEventArgs args)
{
if (keyboardOptions.Count == 0)
{
return;
}
if (string.Equals(args.Key, "ArrowDown", StringComparison.Ordinal))
{
MoveActiveOption(1);
return;
}
if (string.Equals(args.Key, "ArrowUp", StringComparison.Ordinal))
{
MoveActiveOption(-1);
return;
}
if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(activeOptionSlug))
{
_ = OnSelectTable.InvokeAsync(activeOptionSlug);
}
}
private void MoveActiveOption(int offset)
{
if (keyboardOptions.Count == 0)
{
activeOptionSlug = null;
return;
}
var currentIndex = keyboardOptions.FindIndex(item => string.Equals(item.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase));
if (currentIndex < 0)
{
currentIndex = keyboardOptions.FindIndex(item => string.Equals(item.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase));
}
var nextIndex = currentIndex < 0
? 0
: Math.Clamp(currentIndex + offset, 0, keyboardOptions.Count - 1);
activeOptionSlug = keyboardOptions[nextIndex].Key;
}
private void EnsureActiveOption()
{
if (keyboardOptions.Count == 0)
{
activeOptionSlug = null;
return;
}
if (!string.IsNullOrWhiteSpace(activeOptionSlug) &&
keyboardOptions.Any(item => string.Equals(item.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase)))
{
return;
}
if (!string.IsNullOrWhiteSpace(SelectedTableSlug))
{
var selectedOption = keyboardOptions.FirstOrDefault(item => string.Equals(item.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase));
if (selectedOption is not null)
{
activeOptionSlug = selectedOption.Key;
return;
}
}
activeOptionSlug = keyboardOptions[0].Key;
}
private string GetCurationTone(CriticalTableReference table) =>
table.CurationPercentage >= 100 ? "success" : "warning";
private string GetTableOptionCssClass(CriticalTableReference table)
{
var classes = new List<string>();
@@ -53,7 +326,34 @@
classes.Add("is-selected");
}
if (string.Equals(table.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active");
}
classes.Add(table.CurationPercentage >= 100 ? "is-curated" : "needs-curation");
return string.Join(' ', classes);
}
private void SetActiveOption(string tableSlug) => activeOptionSlug = tableSlug;
private RenderFragment RenderTableOption(CriticalTableReference table) => @<button
type="button"
role="option"
aria-selected="@string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)"
class="table-index-option @GetTableOptionCssClass(table)"
@onfocus="() => SetActiveOption(table.Key)"
@onclick="() => OnSelectTable.InvokeAsync(table.Key)">
<span class="table-index-option-copy">
<strong class="table-index-option-title">@table.Label</strong>
<span class="table-index-option-meta">@table.Family</span>
</span>
<span class="table-index-option-chips">
@if (GetIsPinned(table.Key))
{
<StatusChip Tone="accent">Pinned</StatusChip>
}
<StatusChip Tone="@GetCurationTone(table)">@($"{table.CurationPercentage}%")</StatusChip>
</span>
</button>;
}