Add permanent table index rail layout
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
59
src/RolemasterDb.App/Components/Tables/TablesIndexRail.razor
Normal file
59
src/RolemasterDb.App/Components/Tables/TablesIndexRail.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user