370 lines
13 KiB
Plaintext
370 lines
13 KiB
Plaintext
<section class="tables-index-rail" aria-labelledby="tables-index-heading" tabindex="0" @onkeydown="HandleRailKeyDown">
|
|
<div class="tables-index-rail-header">
|
|
<h2 id="tables-index-heading" class="tables-index-title">Table Index</h2>
|
|
</div>
|
|
|
|
<div class="tables-index-controls">
|
|
<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"
|
|
@onkeydown:stopPropagation="true"/>
|
|
|
|
@if (familyFilters.Count > 1)
|
|
{
|
|
<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" 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" 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)
|
|
{
|
|
<div class="tables-index-empty">No tables match the current search.</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="tables-index-list" 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, "Home", StringComparison.Ordinal))
|
|
{
|
|
MoveActiveOptionToBoundary(useLast: false);
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(args.Key, "End", StringComparison.Ordinal))
|
|
{
|
|
MoveActiveOptionToBoundary(useLast: true);
|
|
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 MoveActiveOptionToBoundary(bool useLast)
|
|
{
|
|
if (keyboardOptions.Count == 0)
|
|
{
|
|
activeOptionSlug = null;
|
|
return;
|
|
}
|
|
|
|
activeOptionSlug = useLast ? keyboardOptions[^1].Key : keyboardOptions[0].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>();
|
|
|
|
if (string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
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"
|
|
aria-current="@(string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase) ? "true" : null)"
|
|
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>;
|
|
|
|
} |