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

@@ -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