Add permanent table index rail layout

This commit is contained in:
2026-03-21 14:56:36 +01:00
parent 338842dba9
commit bed85b9778
5 changed files with 230 additions and 270 deletions

View File

@@ -62,6 +62,7 @@ It is intentionally implementation-focused:
| 2026-03-21 | Post-P2 fix 2 | Completed | Rebuilt the shell omnibox as a dedicated command palette instead of a repurposed drawer, with shell-owned overlay markup, explicit viewport-safe geometry, autofocus, Escape and navigation close behavior, and a stable scrollable result body. |
| 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. |
### Lessons Learned
@@ -93,6 +94,7 @@ It is intentionally implementation-focused:
- A command palette is not just a styled drawer. It needs shell-owned geometry, predictable focus behavior, and a bounded scroll region; treating it as a generic side panel led directly to the layout regressions found in Phase 2.
- 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.
## Target Outcomes
@@ -454,7 +456,7 @@ Build the shared interaction infrastructure needed by multiple destinations befo
| Task | Status | Notes |
| --- | --- | --- |
| `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` | Pending | Replace the selector dropdown shell with the permanent left rail layout. |
| `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.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. |

View File

@@ -11,16 +11,7 @@
<PageTitle>Critical Tables</PageTitle>
<section class="panel tables-page">
<TablesPageHeader
ReferenceData="referenceData"
SelectedTableReference="SelectedTableReference"
SelectedTableSlug="selectedTableSlug"
IsTableMenuOpen="isTableMenuOpen"
IsTableSelectionDisabled="IsTableSelectionDisabled"
IsPinned="PinnedTablesState.IsPinned"
OnToggleTableMenu="ToggleTableMenu"
OnCloseTableMenu="CloseTableMenu"
OnSelectTable="SelectTableAsync" />
<TablesPageHeader />
@if (referenceData is null)
{
@@ -30,7 +21,19 @@
{
<p class="muted">No critical tables are available yet.</p>
}
else if (!hasResolvedStoredTableSelection)
else
{
<div class="tables-reference-layout">
<aside class="tables-reference-rail">
<TablesIndexRail
Tables="referenceData.CriticalTables"
SelectedTableSlug="selectedTableSlug"
IsPinned="PinnedTablesState.IsPinned"
OnSelectTable="SelectTableAsync" />
</aside>
<div class="tables-reference-main">
@if (!hasResolvedStoredTableSelection)
{
<p class="muted">Restoring table context...</p>
}
@@ -60,6 +63,9 @@
OnOpenEditor="OpenCellEditorAsync" />
</div>
}
</div>
</div>
}
</section>
@if (isCurationOpen)
@@ -127,7 +133,6 @@
private string? curationQuickParseError;
private int? curatingResultId;
private CriticalCellEditorModel? curationModel;
private bool isTableMenuOpen;
private bool hasResolvedStoredTableSelection;
private CriticalTableReference? SelectedTableReference =>
referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase));
@@ -138,25 +143,9 @@
isReferenceDataLoading = false;
}
private void ToggleTableMenu()
{
if (IsTableSelectionDisabled)
{
return;
}
isTableMenuOpen = !isTableMenuOpen;
}
private void CloseTableMenu()
{
isTableMenuOpen = false;
}
private async Task SelectTableAsync(string tableSlug)
{
selectedTableSlug = tableSlug;
isTableMenuOpen = false;
await LoadTableDetailAsync();
await PersistAndSyncTableContextAsync();
}

View File

@@ -0,0 +1,59 @@
<section class="tables-index-rail" aria-labelledby="tables-index-heading">
<div class="tables-index-rail-header">
<h2 id="tables-index-heading" class="tables-index-title">Table Index</h2>
<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)
{
<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>
</section>
@code {
[Parameter]
public IReadOnlyList<CriticalTableReference> Tables { get; set; } = Array.Empty<CriticalTableReference>();
[Parameter]
public string SelectedTableSlug { get; set; } = string.Empty;
[Parameter]
public Func<string, bool>? IsPinned { get; set; }
[Parameter]
public EventCallback<string> OnSelectTable { get; set; }
private bool GetIsPinned(string tableSlug) => IsPinned?.Invoke(tableSlug) ?? false;
private string GetTableOptionCssClass(CriticalTableReference table)
{
var classes = new List<string>();
if (string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-selected");
}
classes.Add(table.CurationPercentage >= 100 ? "is-curated" : "needs-curation");
return string.Join(' ', classes);
}
}

View File

@@ -1,105 +1,7 @@
<div class="table-browser-toolbar">
<div class="table-selector">
<label id="critical-table-selector-label">Table</label>
<div class="table-select-shell">
<button
type="button"
class="input-shell table-select-trigger"
aria-haspopup="listbox"
aria-expanded="@IsTableMenuOpen"
aria-labelledby="critical-table-selector-label critical-table-selector-value"
@onclick="() => OnToggleTableMenu.InvokeAsync()"
disabled="@IsTableSelectionDisabled">
<span class="table-select-trigger-copy">
<span id="critical-table-selector-value" class="table-select-trigger-title">@GetSelectedTableLabel()</span>
</span>
@if (SelectedTableReference is { } selected)
{
<span class="table-select-trigger-chips">
@if (GetIsPinned(selected.Key))
{
<StatusChip Tone="accent">Pinned</StatusChip>
}
<span class="chip">@($"{selected.CurationPercentage}%")</span>
</span>
}
</button>
@if (IsTableMenuOpen && ReferenceData is not null)
{
<button type="button" class="table-selector-backdrop" @onclick="() => OnCloseTableMenu.InvokeAsync()" aria-label="Close table selector"></button>
<div class="table-select-menu" role="listbox" aria-labelledby="critical-table-selector-label">
@foreach (var table in ReferenceData.CriticalTables)
{
<button
type="button"
role="option"
aria-selected="@string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)"
class="table-select-option @GetTableOptionCssClass(table)"
@onclick="() => OnSelectTable.InvokeAsync(table.Key)">
<span class="table-select-option-main">
<strong class="table-select-option-title">@table.Label</strong>
</span>
<span class="table-select-option-chips">
@if (GetIsPinned(table.Key))
{
<StatusChip Tone="accent">Pinned</StatusChip>
}
<span class="chip">@($"{table.CurationPercentage}%")</span>
</span>
</button>
}
<header class="tables-page-header">
<div class="tables-page-header-copy">
<p class="tables-page-eyebrow">Reference</p>
<h1 class="panel-title">Critical Tables</h1>
<p class="tables-page-intro">Browse the index, open a table, and read the result directly from the grid without leaving the page.</p>
</div>
}
</div>
</div>
<p class="table-browser-toolbar-copy">Choose a table, then read from the roll band on the left across to the result you need.</p>
</div>
@code {
[Parameter]
public LookupReferenceData? ReferenceData { get; set; }
[Parameter]
public CriticalTableReference? SelectedTableReference { get; set; }
[Parameter]
public string SelectedTableSlug { get; set; } = string.Empty;
[Parameter]
public bool IsTableMenuOpen { get; set; }
[Parameter]
public bool IsTableSelectionDisabled { get; set; }
[Parameter]
public Func<string, bool>? IsPinned { get; set; }
[Parameter]
public EventCallback OnToggleTableMenu { get; set; }
[Parameter]
public EventCallback OnCloseTableMenu { get; set; }
[Parameter]
public EventCallback<string> OnSelectTable { get; set; }
private bool GetIsPinned(string tableSlug) => IsPinned?.Invoke(tableSlug) ?? false;
private string GetSelectedTableLabel() => SelectedTableReference?.Label ?? "Select a table";
private string GetTableOptionCssClass(CriticalTableReference table)
{
var classes = new List<string>();
if (string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-selected");
}
classes.Add(table.CurationPercentage >= 100 ? "is-curated" : "needs-curation");
return string.Join(' ', classes);
}
}
</header>

View File

@@ -1115,150 +1115,148 @@ pre,
gap: 1rem;
}
.table-browser-toolbar {
.tables-page-header {
display: grid;
gap: 0.85rem;
gap: 0.5rem;
}
.table-selector {
display: flex;
flex-direction: column;
.tables-page-header-copy {
display: grid;
gap: 0.35rem;
max-width: 52rem;
}
.tables-page-eyebrow {
margin: 0;
color: var(--accent-strong);
font-family: var(--font-ui);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.tables-page-intro,
.tables-index-copy,
.table-browser-reading-hint,
.table-browser-edit-hint {
margin: 0;
color: var(--ink-soft);
}
.tables-reference-layout {
display: grid;
grid-template-columns: minmax(17rem, 20rem) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.tables-reference-rail {
position: sticky;
top: calc(var(--shell-header-height) + 1rem);
}
.tables-index-rail {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 18px;
background: var(--surface-card-subtle);
border: 1px solid rgba(127, 96, 55, 0.16);
box-shadow: 0 16px 26px rgba(41, 22, 11, 0.06);
}
.tables-index-rail-header {
display: grid;
gap: 0.35rem;
}
.table-select-shell {
position: relative;
.tables-index-title {
margin: 0;
color: var(--ink-strong);
font-family: var(--font-display);
font-size: clamp(1.1rem, 1rem + 0.4vw, 1.35rem);
}
.table-select-trigger {
.tables-index-list {
display: grid;
gap: 0.35rem;
}
.table-index-option {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
gap: 0.75rem;
width: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid transparent;
border-radius: 12px;
background: rgba(255, 250, 242, 0.72);
color: inherit;
text-align: left;
padding: 0.55rem 0.7rem;
border: 1px solid rgba(127, 96, 55, 0.28);
border-radius: 8px;
background: rgba(255, 252, 247, 0.96);
box-shadow: none;
appearance: none;
transition: border-color 0.16s ease, background-color 0.16s ease;
transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.table-select-trigger:hover,
.table-select-trigger:focus-visible {
border-color: rgba(184, 121, 59, 0.4);
background: rgba(255, 248, 239, 0.98);
.table-index-option:hover,
.table-index-option:focus-visible {
border-color: rgba(184, 121, 59, 0.28);
background: rgba(255, 247, 235, 0.94);
outline: none;
}
.table-select-trigger-copy {
display: block;
.table-index-option.is-selected {
border-color: rgba(41, 22, 11, 0.18);
background: rgba(248, 238, 221, 0.98);
}
.table-index-option.is-curated {
background: rgba(102, 138, 83, 0.12);
}
.table-index-option.is-curated:hover,
.table-index-option.is-curated:focus-visible {
background: rgba(102, 138, 83, 0.18);
}
.table-index-option.needs-curation {
background: rgba(184, 121, 59, 0.12);
}
.table-index-option.needs-curation:hover,
.table-index-option.needs-curation:focus-visible {
background: rgba(184, 121, 59, 0.18);
}
.table-index-option-copy {
display: grid;
gap: 0.18rem;
min-width: 0;
}
.table-select-trigger-title {
.table-index-option-title {
color: var(--ink);
font-family: var(--font-ui);
font-size: 0.98rem;
font-weight: 600;
}
.table-select-trigger-chips,
.table-select-option-chips {
.table-index-option-meta {
color: var(--ink-soft);
font-size: 0.8rem;
}
.table-index-option-chips {
display: flex;
align-items: center;
gap: 0.25rem;
justify-content: flex-end;
}
.table-selector-backdrop {
position: fixed;
inset: 0;
border: none;
background: transparent;
padding: 0;
z-index: 15;
}
.table-select-menu {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 0.1rem);
z-index: 20;
display: grid;
gap: 0;
padding: 0.1rem;
border-radius: 8px;
background: rgba(255, 250, 242, 0.98);
border: 1px solid rgba(127, 96, 55, 0.22);
box-shadow: 0 10px 18px rgba(41, 22, 11, 0.1);
max-height: min(28rem, 60vh);
overflow: auto;
}
.table-select-option {
border: none;
border-radius: 0;
background: transparent;
padding: 0.35rem 0.45rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
text-align: left;
transition: background-color 0.16s ease, outline-color 0.16s ease;
}
.table-select-option:hover,
.table-select-option:focus-visible {
outline: 1px solid rgba(41, 22, 11, 0.16);
outline-offset: -1px;
}
.table-select-option.is-selected {
outline: 1px solid rgba(41, 22, 11, 0.14);
outline-offset: -1px;
}
.table-select-option.is-curated {
background: rgba(102, 138, 83, 0.12);
}
.table-select-option.is-curated:hover,
.table-select-option.is-curated:focus-visible {
background: rgba(102, 138, 83, 0.18);
}
.table-select-option.needs-curation {
background: rgba(184, 121, 59, 0.12);
}
.table-select-option.needs-curation:hover,
.table-select-option.needs-curation:focus-visible {
background: rgba(184, 121, 59, 0.18);
}
.table-select-option-main {
display: block;
.tables-reference-main {
min-width: 0;
}
.table-select-option-title {
color: var(--ink);
font-family: var(--font-ui);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table-browser-toolbar-copy,
.table-browser-reading-hint,
.table-browser-edit-hint {
margin: 0;
color: var(--ink-soft);
}
.table-shell {
border-radius: 20px;
padding: 1.2rem;
@@ -1283,6 +1281,16 @@ pre,
white-space: nowrap;
}
@media (max-width: 1023px) {
.tables-reference-layout {
grid-template-columns: minmax(0, 1fr);
}
.tables-reference-rail {
position: static;
}
}
.table-shell .table-scroll {
overflow-x: auto;
}