Split Tables page into focused components

This commit is contained in:
2026-03-21 14:53:47 +01:00
parent 5e8a129666
commit 338842dba9
7 changed files with 338 additions and 261 deletions

View File

@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
- Branch: `frontend/tables-overhaul`
- Last updated: `2026-03-21`
- Current focus: `Phase 2`
- Current focus: `Phase 3`
- Document mode: living plan and progress log
### Progress Log
@@ -61,6 +61,7 @@ It is intentionally implementation-focused:
| 2026-03-21 | Post-P2 fix 1 | Completed | Fixed the shell omnibox drawer regression by adding explicit shell offset variables, constraining drawer/body scrolling, and giving the omnibox its own backdrop geometry so the flyout opens within the visible viewport instead of collapsing into invalid top/bottom positioning. |
| 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. |
### Lessons Learned
@@ -91,6 +92,7 @@ It is intentionally implementation-focused:
- Shared overlay primitives should not depend on undeclared layout variables. If a drawer needs shell offsets, the shell must define them explicitly and overlay-specific backdrops should be adjustable instead of assuming full-screen dimming is always correct.
- 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.
## Target Outcomes
@@ -372,7 +374,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest
### Status
`In progress`
`Completed`
### Task Progress
@@ -443,6 +445,27 @@ Build the shared interaction infrastructure needed by multiple destinations befo
## Phase 3: `Tables` Reference Experience
### Status
`In progress`
### Task Progress
| 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.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. |
| `P3.6` | Pending | Remove visible resting-state action stacks from non-selected cells. |
| `P3.7` | Pending | Add the desktop selection-driven inspector. |
| `P3.8` | Pending | Add the mobile bottom-sheet inspector variant. |
| `P3.9` | Pending | Move legend/help to an on-demand secondary surface. |
| `P3.10` | Pending | Hide maintenance and developer noise in default reference mode. |
| `P3.11` | Pending | Preserve editor and curation entry points through the inspector. |
| `P3.12` | Pending | Normalize click/tap/keyboard selection and close the phase with a hardening pass. |
### Goal
Turn `/tables` into the canonical reference surface for reading and inspecting critical tables quickly.

View File

@@ -1,8 +1,6 @@
@page "/tables"
@rendermode InteractiveServer
@using System
@using System.Collections.Generic
@using System.Diagnostics.CodeAnalysis
@using System.Linq
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@@ -13,65 +11,16 @@
<PageTitle>Critical Tables</PageTitle>
<section class="panel tables-page">
<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="ToggleTableMenu"
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 (PinnedTablesState.IsPinned(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="CloseTableMenu" 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="() => SelectTableAsync(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 (PinnedTablesState.IsPinned(table.Key))
{
<StatusChip Tone="accent">Pinned</StatusChip>
}
<span class="chip">@($"{table.CurationPercentage}%")</span>
</span>
</button>
}
</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>
<TablesPageHeader
ReferenceData="referenceData"
SelectedTableReference="SelectedTableReference"
SelectedTableSlug="selectedTableSlug"
IsTableMenuOpen="isTableMenuOpen"
IsTableSelectionDisabled="IsTableSelectionDisabled"
IsPinned="PinnedTablesState.IsPinned"
OnToggleTableMenu="ToggleTableMenu"
OnCloseTableMenu="CloseTableMenu"
OnSelectTable="SelectTableAsync" />
@if (referenceData is null)
{
@@ -99,95 +48,16 @@
}
else if (tableDetail is { } detail)
{
var readingHint = detail.Groups.Count > 0
? "Find the roll band on the left, then read across to the group and severity you need."
: "Find the roll band on the left, then read across to the severity you need.";
<div class="table-shell">
<header class="table-browser-header">
<div>
<h2 class="panel-title">@detail.DisplayName</h2>
<p class="table-browser-reading-hint">@readingHint</p>
</div>
<div class="action-row">
<button type="button" class="btn btn-link" @onclick="TogglePinnedTableAsync">
@(PinnedTablesState.IsPinned(detail.Slug) ? "Unpin table" : "Pin table")
</button>
<p class="table-browser-edit-hint">Use the curation action or edit action on any filled result.</p>
</div>
</header>
<TablesContextBar
Detail="detail"
IsPinned="PinnedTablesState.IsPinned(detail.Slug)"
OnTogglePin="TogglePinnedTableAsync" />
@{
var displayColumns = GetDisplayColumns(detail);
var gridTemplateStyle = BuildGridTemplateStyle(detail);
}
<div class="table-scroll">
<div class="critical-table-grid" role="group" aria-label="@detail.DisplayName" style="@gridTemplateStyle">
@if (detail.Groups.Count > 0)
{
<div class="critical-table-grid-header-cell critical-table-grid-corner" aria-hidden="true"></div>
@foreach (var group in detail.Groups)
{
<div
class="critical-table-grid-header-cell critical-table-grid-group-header"
style="@BuildColumnSpanStyle(detail.Columns.Count)">
<span>@group.Label</span>
</div>
}
}
<div class="critical-table-grid-header-cell critical-table-grid-roll-band-header" aria-hidden="true"></div>
@foreach (var displayColumn in displayColumns)
{
<div class="critical-table-grid-header-cell critical-table-grid-column-header">
<span>@displayColumn.ColumnLabel</span>
</div>
}
@foreach (var rollBand in detail.RollBands)
{
<div class="critical-table-grid-header-cell critical-table-grid-roll-band">@rollBand.Label</div>
@foreach (var displayColumn in displayColumns)
{
@if (TryGetCell(rollBand.Label, displayColumn.GroupKey, displayColumn.ColumnKey, out var cell))
{
@RenderCriticalTableCell(cell)
}
else
{
@RenderEmptyCriticalTableCell()
}
}
}
</div>
</div>
@{
var legendEntries = detail.Legend ?? Array.Empty<CriticalTableLegendEntry>();
}
@if (legendEntries.Count > 0)
{
<div class="critical-legend">
<div class="critical-legend-header">
<h4>Reading help</h4>
<p class="muted">These symbols show the effects attached to a result at a glance.</p>
</div>
<div class="legend-grid">
@foreach (var entry in legendEntries)
{
<div class="legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<div>
<strong>@entry.Label</strong>
<span class="muted">@entry.Description</span>
</div>
</div>
}
</div>
</div>
}
<TablesCanvas
Detail="detail"
OnOpenCuration="OpenCellCurationAsync"
OnOpenEditor="OpenCellEditorAsync" />
</div>
}
</section>
@@ -237,7 +107,6 @@
private bool isDetailLoading;
private bool isReferenceDataLoading = true;
private string? detailError;
private Dictionary<(string RollBand, string? GroupKey, string ColumnKey), CriticalTableCellDetail>? cellIndex;
private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0;
private bool isEditorOpen;
private bool isEditorLoading;
@@ -297,14 +166,12 @@
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
tableDetail = null;
cellIndex = null;
return;
}
isDetailLoading = true;
detailError = null;
tableDetail = null;
cellIndex = null;
try
{
@@ -324,7 +191,6 @@
finally
{
isDetailLoading = false;
BuildCellIndex();
}
}
@@ -361,32 +227,6 @@
}
}
private void BuildCellIndex()
{
if (tableDetail?.Cells is null)
{
cellIndex = null;
return;
}
cellIndex = new Dictionary<(string, string?, string), CriticalTableCellDetail>();
foreach (var cell in tableDetail.Cells)
{
cellIndex[(cell.RollBand, cell.GroupKey, cell.ColumnKey)] = cell;
}
}
private bool TryGetCell(string rollBand, string? groupKey, string columnKey, [NotNullWhen(true)] out CriticalTableCellDetail? cell)
{
if (cellIndex is null)
{
cell = null;
return false;
}
return cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell);
}
private async Task OpenCellEditorAsync(int resultId)
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
@@ -744,36 +584,6 @@
}
}
private static string GetCellCssClass(CriticalTableCellDetail cell) =>
cell.IsCurated
? "critical-table-cell is-curated"
: "critical-table-cell needs-curation";
private static IReadOnlyList<(string? GroupKey, string ColumnKey, string ColumnLabel)> GetDisplayColumns(CriticalTableDetail detail)
{
if (detail.Groups.Count == 0)
{
return detail.Columns
.Select(column => ((string?)null, column.Key, column.Label))
.ToList();
}
return detail.Groups
.SelectMany(group => detail.Columns.Select(column => ((string?)group.Key, column.Key, column.Label)))
.ToList();
}
private static string BuildGridTemplateStyle(CriticalTableDetail detail)
{
var dataColumnCount = detail.Columns.Count * Math.Max(detail.Groups.Count, 1);
return $"grid-template-columns: max-content repeat({dataColumnCount}, minmax(0, 1fr));";
}
private static string BuildColumnSpanStyle(int span) => $"grid-column: span {span};";
private string GetSelectedTableLabel() =>
SelectedTableReference?.Label ?? "Select a table";
private Task TogglePinnedTableAsync()
{
if (SelectedTableReference is not { } selectedTable)
@@ -820,55 +630,4 @@
new(
TableSlug: selectedTableSlug,
Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
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);
}
private RenderFragment RenderCriticalTableCell(CriticalTableCellDetail cell) => @<div class="@GetCellCssClass(cell)">
<div class="critical-table-cell-shell">
<div class="critical-table-cell-actions">
@if (cell.IsCurated)
{
<span class="critical-cell-status-chip is-curated">Curated</span>
}
else
{
<button
type="button"
class="critical-cell-action-button is-curation"
title="Open the curation preview for this cell."
@onclick="() => OpenCellCurationAsync(cell.ResultId)">
Needs Curation
</button>
}
<button
type="button"
class="critical-cell-action-button is-edit"
title="Open the full editor for this cell."
@onclick="() => OpenCellEditorAsync(cell.ResultId)">
Edit
</button>
</div>
<CompactCriticalCell
Description="@(cell.Description ?? string.Empty)"
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
</div>
</div>;
private static RenderFragment RenderEmptyCriticalTableCell() => @<div class="critical-table-cell critical-table-cell-empty">
<span class="empty-cell">—</span>
</div>;
}

View File

@@ -0,0 +1,135 @@
<div class="table-scroll">
<div class="critical-table-grid" role="group" aria-label="@Detail.DisplayName" style="@gridTemplateStyle">
@if (Detail.Groups.Count > 0)
{
<div class="critical-table-grid-header-cell critical-table-grid-corner" aria-hidden="true"></div>
@foreach (var group in Detail.Groups)
{
<div
class="critical-table-grid-header-cell critical-table-grid-group-header"
style="@BuildColumnSpanStyle(Detail.Columns.Count)">
<span>@group.Label</span>
</div>
}
}
<div class="critical-table-grid-header-cell critical-table-grid-roll-band-header" aria-hidden="true"></div>
@foreach (var displayColumn in displayColumns)
{
<div class="critical-table-grid-header-cell critical-table-grid-column-header">
<span>@displayColumn.ColumnLabel</span>
</div>
}
@foreach (var rollBand in Detail.RollBands)
{
<div class="critical-table-grid-header-cell critical-table-grid-roll-band">@rollBand.Label</div>
@foreach (var displayColumn in displayColumns)
{
if (TryGetCell(rollBand.Label, displayColumn.GroupKey, displayColumn.ColumnKey, out var resolvedCell) && resolvedCell is not null)
{
var cell = resolvedCell;
<div class="@GetCellCssClass(cell)">
<div class="critical-table-cell-shell">
<div class="critical-table-cell-actions">
@if (cell.IsCurated)
{
<span class="critical-cell-status-chip is-curated">Curated</span>
}
else
{
<button
type="button"
class="critical-cell-action-button is-curation"
title="Open the curation preview for this cell."
@onclick="() => OnOpenCuration.InvokeAsync(cell.ResultId)">
Needs Curation
</button>
}
<button
type="button"
class="critical-cell-action-button is-edit"
title="Open the full editor for this cell."
@onclick="() => OnOpenEditor.InvokeAsync(cell.ResultId)">
Edit
</button>
</div>
<CompactCriticalCell
Description="@(cell.Description ?? string.Empty)"
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
</div>
</div>
}
else
{
<div class="critical-table-cell critical-table-cell-empty">
<span class="empty-cell">—</span>
</div>
}
}
}
</div>
</div>
<TablesLegend LegendEntries="@(Detail.Legend ?? Array.Empty<CriticalTableLegendEntry>())" />
@code {
private readonly Dictionary<(string RollBand, string? GroupKey, string ColumnKey), CriticalTableCellDetail> cellIndex = new();
private readonly List<(string? GroupKey, string ColumnKey, string ColumnLabel)> displayColumns = new();
private string gridTemplateStyle = string.Empty;
[Parameter, EditorRequired]
public CriticalTableDetail Detail { get; set; } = default!;
[Parameter]
public EventCallback<int> OnOpenCuration { get; set; }
[Parameter]
public EventCallback<int> OnOpenEditor { get; set; }
protected override void OnParametersSet()
{
cellIndex.Clear();
displayColumns.Clear();
foreach (var cell in Detail.Cells)
{
cellIndex[(cell.RollBand, cell.GroupKey, cell.ColumnKey)] = cell;
}
if (Detail.Groups.Count == 0)
{
foreach (var column in Detail.Columns)
{
displayColumns.Add((null, column.Key, column.Label));
}
}
else
{
foreach (var group in Detail.Groups)
{
foreach (var column in Detail.Columns)
{
displayColumns.Add((group.Key, column.Key, column.Label));
}
}
}
var dataColumnCount = Detail.Columns.Count * Math.Max(Detail.Groups.Count, 1);
gridTemplateStyle = $"grid-template-columns: max-content repeat({dataColumnCount}, minmax(0, 1fr));";
}
private bool TryGetCell(string rollBand, string? groupKey, string columnKey, out CriticalTableCellDetail? cell) =>
cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell);
private static string BuildColumnSpanStyle(int span) => $"grid-column: span {span};";
private static string GetCellCssClass(CriticalTableCellDetail cell) =>
cell.IsCurated
? "critical-table-cell is-curated"
: "critical-table-cell needs-curation";
}

View File

@@ -0,0 +1,28 @@
<header class="table-browser-header">
<div>
<h2 class="panel-title">@Detail.DisplayName</h2>
<p class="table-browser-reading-hint">@GetReadingHint()</p>
</div>
<div class="action-row">
<button type="button" class="btn btn-link" @onclick="() => OnTogglePin.InvokeAsync()">
@(IsPinned ? "Unpin table" : "Pin table")
</button>
<p class="table-browser-edit-hint">Use the curation action or edit action on any filled result.</p>
</div>
</header>
@code {
[Parameter, EditorRequired]
public CriticalTableDetail Detail { get; set; } = default!;
[Parameter]
public bool IsPinned { get; set; }
[Parameter]
public EventCallback OnTogglePin { get; set; }
private string GetReadingHint() =>
Detail.Groups.Count > 0
? "Find the roll band on the left, then read across to the group and severity you need."
: "Find the roll band on the left, then read across to the severity you need.";
}

View File

@@ -0,0 +1,26 @@
@if (LegendEntries.Count > 0)
{
<div class="critical-legend">
<div class="critical-legend-header">
<h4>Reading help</h4>
<p class="muted">These symbols show the effects attached to a result at a glance.</p>
</div>
<div class="legend-grid">
@foreach (var entry in LegendEntries)
{
<div class="legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<div>
<strong>@entry.Label</strong>
<span class="muted">@entry.Description</span>
</div>
</div>
}
</div>
</div>
}
@code {
[Parameter]
public IReadOnlyList<CriticalTableLegendEntry> LegendEntries { get; set; } = Array.Empty<CriticalTableLegendEntry>();
}

View File

@@ -0,0 +1,105 @@
<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>
}
</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);
}
}

View File

@@ -14,4 +14,5 @@
@using RolemasterDb.App.Components.Primitives
@using RolemasterDb.App.Components.Shell
@using RolemasterDb.App.Components.Shared
@using RolemasterDb.App.Components.Tables
@using RolemasterDb.App.Components.Tools