Add searchable table index rail behaviors
This commit is contained in:
@@ -63,6 +63,7 @@ It is intentionally implementation-focused:
|
||||
| 2026-03-21 | Post-P2 fix 3 | Completed | Moved omnibox overlay ownership from the header subtree into `AppShell` itself via a shared omnibox state service and a top-level palette host, which restored full-screen backdrop coverage and reliable outside-click close behavior. |
|
||||
| 2026-03-21 | P3.1 | Completed | Split `Tables.razor` into focused table components for the selector header, context bar, canvas, and legend while leaving loading, deep-link synchronization, and dialog state in the page host. |
|
||||
| 2026-03-21 | P3.2 | Completed | Replaced the floating table picker with a permanent left-rail layout, converted the old selector component into a real page header, and kept the current selection flow intact inside the new reference frame. |
|
||||
| 2026-03-21 | P3.3 | Completed | Added rail search, family filters, pinned and recent sections, curated status chips, and keyboard up/down plus Enter handling on top of the new permanent table index rail. |
|
||||
|
||||
### Lessons Learned
|
||||
|
||||
@@ -95,6 +96,7 @@ It is intentionally implementation-focused:
|
||||
- Backdrop and outside-click behavior depend on overlay ownership as much as CSS. If the trigger owns the overlay inside a sticky header subtree, fixed-position assumptions can break; shell-level overlays should be rendered by the shell, not by individual header controls.
|
||||
- The `Tables` rewrite is safer when orchestration and rendering are separated early. Keeping loading, persistence, and dialog state in the page host while extracting render-only components makes later layout and interaction changes much lower risk.
|
||||
- The `Tables` navigation model needs its own persistent geometry before advanced behaviors land. Converting the selector to a real rail first keeps later search and keyboard work from being tangled up with another structural rewrite.
|
||||
- Rail keyboard behavior is easiest to maintain when it works from one deduplicated option order, even if the UI shows multiple sections. Keeping one internal option list avoids separate arrow-key state per section.
|
||||
|
||||
## Target Outcomes
|
||||
|
||||
@@ -457,7 +459,7 @@ Build the shared interaction infrastructure needed by multiple destinations befo
|
||||
| --- | --- | --- |
|
||||
| `P3.1` | Completed | `Tables.razor` now acts as the stateful host while selector/header/canvas/legend rendering lives in dedicated `Components/Tables` components. |
|
||||
| `P3.2` | Completed | The old dropdown picker is gone; `/tables` now uses a permanent left rail and a real page header while keeping the current selection flow intact. |
|
||||
| `P3.3` | Pending | Add search, keyboard navigation, pinned/recent sections, curated percentage chips, and family filtering to the rail. |
|
||||
| `P3.3` | Completed | The rail now supports search-as-you-type, family filters, pinned and recent sections, curated status chips, and a deduplicated arrow/Enter keyboard path. |
|
||||
| `P3.4` | Pending | Introduce the sticky context bar with roll jump and mode/filter controls. |
|
||||
| `P3.5` | Pending | Rework the canvas for sticky headers, sticky roll bands, stronger reading emphasis, and density control. |
|
||||
| `P3.6` | Pending | Remove visible resting-state action stacks from non-selected cells. |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -1171,6 +1171,57 @@ pre,
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tables-index-controls {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tables-index-search-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tables-index-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tables-family-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tables-family-filter {
|
||||
border: 1px solid rgba(127, 96, 55, 0.2);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 242, 0.86);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
.tables-family-filter:hover,
|
||||
.tables-family-filter:focus-visible {
|
||||
border-color: rgba(184, 121, 59, 0.3);
|
||||
background: rgba(255, 244, 228, 0.94);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tables-family-filter.is-selected {
|
||||
background: rgba(188, 117, 43, 0.16);
|
||||
border-color: rgba(188, 117, 43, 0.3);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.tables-index-title {
|
||||
margin: 0;
|
||||
color: var(--ink-strong);
|
||||
@@ -1183,6 +1234,35 @@ pre,
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tables-index-section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tables-index-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tables-index-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.tables-index-section-count {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tables-index-empty {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-index-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -1210,6 +1290,10 @@ pre,
|
||||
background: rgba(248, 238, 221, 0.98);
|
||||
}
|
||||
|
||||
.table-index-option.is-active {
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 148, 136, 0.3);
|
||||
}
|
||||
|
||||
.table-index-option.is-curated {
|
||||
background: rgba(102, 138, 83, 0.12);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user