From ae582367d6777043157c752c9452c5dded6995d6 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 21 Mar 2026 15:08:39 +0100 Subject: [PATCH] Enhance tables canvas reading states --- ...s_frontend_overhaul_implementation_plan.md | 4 +- .../Components/Pages/Tables.razor | 40 ++++- .../Components/Tables/TablesCanvas.razor | 157 ++++++++++++++++-- .../Components/Tables/TablesCellSelection.cs | 7 + .../Components/Tables/TablesContextBar.razor | 33 +++- .../Components/Tables/TablesDensityMode.cs | 7 + src/RolemasterDb.App/wwwroot/app.css | 97 +++++++++++ 7 files changed, 327 insertions(+), 18 deletions(-) create mode 100644 src/RolemasterDb.App/Components/Tables/TablesCellSelection.cs create mode 100644 src/RolemasterDb.App/Components/Tables/TablesDensityMode.cs diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index d19dc81..bdab30b 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -65,6 +65,7 @@ It is intentionally implementation-focused: | 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. | | 2026-03-21 | P3.4 | Completed | Added a sticky context bar with reference-mode tabs, variant and severity selectors, roll-jump state, and active filter chips, then wired those controls into page state and canvas filtering. | +| 2026-03-21 | P3.5 | Completed | Reworked the canvas with sticky headers, a sticky roll-band column, row and column emphasis driven by selection and roll-jump state, selected-cell treatment, and a comfortable/dense density toggle. | ### Lessons Learned @@ -99,6 +100,7 @@ It is intentionally implementation-focused: - 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. - The context bar controls should own one shared view-state model before the canvas gets more visual treatment. Wiring the filters into the host page now avoids a second refactor when row, column, and cell emphasis land. +- Canvas emphasis becomes maintainable once selection, roll-jump, and density are all fed through one explicit state model. That lets the grid respond to context without hiding selection logic inside CSS-only heuristics. ## Target Outcomes @@ -463,7 +465,7 @@ Build the shared interaction infrastructure needed by multiple destinations befo | `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` | 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` | Completed | The table surface now has a sticky context bar with mode tabs, variant/severity focus, roll-jump state, and active filter chips wired into host-page view state. | -| `P3.5` | Pending | Rework the canvas for sticky headers, sticky roll bands, stronger reading emphasis, and density control. | +| `P3.5` | Completed | The canvas now supports sticky headers and roll bands, row and column emphasis from selection and roll-jump state, selected-cell treatment, and a comfortable/dense density toggle. | | `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. | diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index b8a5f57..de7ca43 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -61,17 +61,23 @@ SelectedGroupKey="selectedGroupKey" SelectedColumnKey="selectedColumnKey" RollJumpValue="rollJumpValue" + DensityMode="densityMode" OnTogglePin="TogglePinnedTableAsync" OnModeChanged="UpdateReferenceModeAsync" OnGroupChanged="UpdateSelectedGroupAsync" OnColumnChanged="UpdateSelectedColumnAsync" - OnRollJumpChanged="UpdateRollJumpAsync" /> + OnRollJumpChanged="UpdateRollJumpAsync" + OnDensityChanged="UpdateDensityModeAsync" /> @@ -150,6 +156,8 @@ private string selectedGroupKey = string.Empty; private string selectedColumnKey = string.Empty; private string rollJumpValue = string.Empty; + private string densityMode = TablesDensityMode.Comfortable; + private TablesCellSelection? selectedCell; private bool hasResolvedStoredTableSelection; private CriticalTableReference? SelectedTableReference => referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase)); @@ -664,6 +672,23 @@ return Task.CompletedTask; } + private Task UpdateDensityModeAsync(string mode) + { + densityMode = NormalizeDensityMode(mode); + return Task.CompletedTask; + } + + private void SelectCell(TablesCellSelection selection) + { + if (tableDetail?.Cells.Any(cell => cell.ResultId == selection.ResultId) != true) + { + selectedCell = null; + return; + } + + selectedCell = selection; + } + private void NormalizeViewStateForCurrentDetail() { referenceMode = NormalizeMode(referenceMode); @@ -673,6 +698,8 @@ selectedGroupKey = string.Empty; selectedColumnKey = string.Empty; rollJumpValue = string.Empty; + densityMode = NormalizeDensityMode(densityMode); + selectedCell = null; return; } @@ -687,6 +714,12 @@ } rollJumpValue = NormalizeRollInput(rollJumpValue); + densityMode = NormalizeDensityMode(densityMode); + + if (selectedCell is not null && tableDetail.Cells.All(cell => cell.ResultId != selectedCell.ResultId)) + { + selectedCell = null; + } } private static string NormalizeMode(string? mode) => @@ -700,6 +733,11 @@ private static string NormalizeOptionalFilter(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + private static string NormalizeDensityMode(string? mode) => + string.Equals(mode, TablesDensityMode.Dense, StringComparison.Ordinal) + ? TablesDensityMode.Dense + : TablesDensityMode.Comfortable; + private static string NormalizeRollInput(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor b/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor index 4dc0240..4d5b443 100644 --- a/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor +++ b/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor @@ -1,12 +1,12 @@
-
+
@if (Detail.Groups.Count > 0) { @foreach (var group in visibleGroups) {
@group.Label
@@ -16,14 +16,14 @@ @foreach (var displayColumn in displayColumns) { -
+
@displayColumn.ColumnLabel
} @foreach (var rollBand in Detail.RollBands) { -
@rollBand.Label
+
@rollBand.Label
@foreach (var displayColumn in displayColumns) { if (TryGetCell(rollBand.Label, displayColumn.GroupKey, displayColumn.ColumnKey, out var resolvedCell) && resolvedCell is not null) @@ -32,7 +32,7 @@ @if (MatchesModeFilter(cell)) { -
+
@if (cell.IsCurated) @@ -45,6 +45,7 @@ type="button" class="critical-cell-action-button is-curation" title="Open the curation preview for this cell." + @onclick:stopPropagation="true" @onclick="() => OnOpenCuration.InvokeAsync(cell.ResultId)"> Needs Curation @@ -54,6 +55,7 @@ type="button" class="critical-cell-action-button is-edit" title="Open the full editor for this cell." + @onclick:stopPropagation="true" @onclick="() => OnOpenEditor.InvokeAsync(cell.ResultId)"> Edit @@ -105,6 +107,18 @@ [Parameter] public string SelectedColumnKey { get; set; } = string.Empty; + [Parameter] + public string RollJumpValue { get; set; } = string.Empty; + + [Parameter] + public string DensityMode { get; set; } = TablesDensityMode.Comfortable; + + [Parameter] + public TablesCellSelection? SelectedCell { get; set; } + + [Parameter] + public EventCallback OnSelectCell { get; set; } + [Parameter] public EventCallback OnOpenCuration { get; set; } @@ -175,10 +189,133 @@ _ => true }; - private static string BuildColumnSpanStyle(int span) => $"grid-column: span {span};"; + private string? ActiveRollBand => + !string.IsNullOrWhiteSpace(SelectedCell?.RollBand) + ? SelectedCell.RollBand + : ResolveRollJumpBandLabel(); - private static string GetCellCssClass(CriticalTableCellDetail cell) => - cell.IsCurated - ? "critical-table-cell is-curated" - : "critical-table-cell needs-curation"; + private string? ActiveColumnKey => + !string.IsNullOrWhiteSpace(SelectedCell?.ColumnKey) + ? SelectedCell.ColumnKey + : (!string.IsNullOrWhiteSpace(SelectedColumnKey) ? SelectedColumnKey : null); + + private string? ActiveGroupKey => + !string.IsNullOrWhiteSpace(SelectedCell?.GroupKey) + ? SelectedCell.GroupKey + : (!string.IsNullOrWhiteSpace(SelectedGroupKey) ? SelectedGroupKey : null); + + private string BuildGridCssClass() => + string.Equals(DensityMode, TablesDensityMode.Dense, StringComparison.Ordinal) + ? "is-dense" + : "is-comfortable"; + + private string BuildGroupHeaderCssClass(string groupKey) + { + var classes = new List { "critical-table-grid-header-cell", "critical-table-grid-group-header" }; + if (string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active-group"); + } + + return string.Join(' ', classes); + } + + private string BuildColumnHeaderCssClass(string? groupKey, string columnKey) + { + var classes = new List { "critical-table-grid-header-cell", "critical-table-grid-column-header" }; + + if (string.Equals(columnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active-column"); + } + + if (!string.IsNullOrWhiteSpace(groupKey) && + string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active-group"); + } + + return string.Join(' ', classes); + } + + private string BuildRollBandCssClass(string rollBandLabel) + { + var classes = new List { "critical-table-grid-header-cell", "critical-table-grid-roll-band" }; + if (string.Equals(rollBandLabel, ActiveRollBand, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active-row"); + } + + if (string.Equals(rollBandLabel, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-roll-target"); + } + + return string.Join(' ', classes); + } + + private string GetCellCssClass(CriticalTableCellDetail cell, string? groupKey) + { + var classes = new List + { + "critical-table-cell", + cell.IsCurated ? "is-curated" : "needs-curation" + }; + + if (string.Equals(cell.RollBand, ActiveRollBand, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active-row"); + } + + if (string.Equals(cell.ColumnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active-column"); + } + + if (!string.IsNullOrWhiteSpace(groupKey) && + string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active-group"); + } + + if (SelectedCell is not null && cell.ResultId == SelectedCell.ResultId) + { + classes.Add("is-selected-cell"); + } + + if (string.Equals(cell.RollBand, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-roll-target"); + } + + return string.Join(' ', classes); + } + + private string? ResolveRollJumpBandLabel() + { + if (!int.TryParse(RollJumpValue, out var targetRoll)) + { + return null; + } + + foreach (var rollBand in Detail.RollBands) + { + if (targetRoll < rollBand.MinRoll) + { + continue; + } + + if (rollBand.MaxRoll is null || targetRoll <= rollBand.MaxRoll.Value) + { + return rollBand.Label; + } + } + + return null; + } + + private Task SelectCell(CriticalTableCellDetail cell) => + OnSelectCell.InvokeAsync(new TablesCellSelection(cell.ResultId, cell.RollBand, cell.ColumnKey, cell.GroupKey)); + + private static string BuildColumnSpanStyle(int span) => $"grid-column: span {span};"; } diff --git a/src/RolemasterDb.App/Components/Tables/TablesCellSelection.cs b/src/RolemasterDb.App/Components/Tables/TablesCellSelection.cs new file mode 100644 index 0000000..110424e --- /dev/null +++ b/src/RolemasterDb.App/Components/Tables/TablesCellSelection.cs @@ -0,0 +1,7 @@ +namespace RolemasterDb.App.Components.Tables; + +public sealed record TablesCellSelection( + int ResultId, + string RollBand, + string ColumnKey, + string? GroupKey); diff --git a/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor b/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor index 17ef104..e5a27d7 100644 --- a/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor +++ b/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor @@ -13,12 +13,21 @@
- +
+ + + +
@if (Detail.Groups.Count > 1) @@ -104,6 +113,12 @@ new(TablesReferenceMode.Curated, "Curated") ]; + private readonly IReadOnlyList densityTabs = + [ + new(TablesDensityMode.Comfortable, "Comfortable"), + new(TablesDensityMode.Dense, "Dense") + ]; + [Parameter, EditorRequired] public CriticalTableDetail Detail { get; set; } = default!; @@ -122,6 +137,9 @@ [Parameter] public string RollJumpValue { get; set; } = string.Empty; + [Parameter] + public string DensityMode { get; set; } = TablesDensityMode.Comfortable; + [Parameter] public EventCallback OnTogglePin { get; set; } @@ -137,6 +155,9 @@ [Parameter] public EventCallback OnRollJumpChanged { get; set; } + [Parameter] + public EventCallback OnDensityChanged { 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." diff --git a/src/RolemasterDb.App/Components/Tables/TablesDensityMode.cs b/src/RolemasterDb.App/Components/Tables/TablesDensityMode.cs new file mode 100644 index 0000000..513030f --- /dev/null +++ b/src/RolemasterDb.App/Components/Tables/TablesDensityMode.cs @@ -0,0 +1,7 @@ +namespace RolemasterDb.App.Components.Tables; + +public static class TablesDensityMode +{ + public const string Comfortable = "comfortable"; + public const string Dense = "dense"; +} diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index bde5a03..f264dd8 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -1382,10 +1382,21 @@ pre, margin-top: 0.85rem; } +.tables-context-tab-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + .tables-context-mode-tabs { width: fit-content; } +.tables-context-density-tabs { + width: fit-content; +} + .tables-context-fields { display: flex; flex-wrap: wrap; @@ -1452,6 +1463,9 @@ pre, font-size: 1.5rem; border-top: 1px solid rgba(127, 96, 55, 0.2); border-left: 1px solid rgba(127, 96, 55, 0.2); + isolation: isolate; + --tables-group-header-top: 0; + --tables-column-header-top: 3.2rem; } .critical-table-grid-header-cell { @@ -1474,14 +1488,56 @@ pre, font-size: 2rem; } +.critical-table-grid-group-header { + position: sticky; + top: var(--tables-group-header-top); + z-index: 3; +} + +.critical-table-grid-column-header { + position: sticky; + top: var(--tables-column-header-top); + z-index: 3; +} + +.critical-table-grid.is-dense { + font-size: 1.2rem; + --tables-column-header-top: 2.6rem; +} + +.critical-table-grid.is-dense .critical-table-grid-header-cell { + padding: 0.25rem; +} + +.critical-table-grid.is-dense .critical-table-cell { + padding: 0.35rem; +} + .critical-table-grid-corner, .critical-table-grid-roll-band-header { background: rgba(255, 247, 230, 0.52); } +.critical-table-grid-corner { + position: sticky; + top: var(--tables-group-header-top); + left: 0; + z-index: 5; +} + +.critical-table-grid-roll-band-header { + position: sticky; + top: var(--tables-column-header-top); + left: 0; + z-index: 5; +} + .critical-table-grid-roll-band { background: rgba(255, 247, 230, 0.52); font-size: 1.5rem; + position: sticky; + left: 0; + z-index: 2; } .critical-table-cell { @@ -1492,6 +1548,8 @@ pre, border-right: 1px solid rgba(127, 96, 55, 0.2); border-bottom: 1px solid rgba(127, 96, 55, 0.2); box-sizing: border-box; + cursor: pointer; + transition: box-shadow 0.16s ease, background-color 0.16s ease, transform 0.16s ease; } .critical-table-cell.is-curated { @@ -1506,6 +1564,40 @@ pre, rgba(255, 255, 255, 0.85); } +.critical-table-cell.is-active-row, +.critical-table-grid-roll-band.is-active-row { + background: + linear-gradient(180deg, rgba(13, 148, 136, 0.12), rgba(13, 148, 136, 0.04)), + rgba(255, 255, 255, 0.9); +} + +.critical-table-grid-column-header.is-active-column, +.critical-table-cell.is-active-column { + box-shadow: inset 0 0 0 999px rgba(13, 148, 136, 0.06); +} + +.critical-table-grid-group-header.is-active-group, +.critical-table-grid-column-header.is-active-group, +.critical-table-cell.is-active-group { + box-shadow: inset 0 0 0 999px rgba(188, 117, 43, 0.05); +} + +.critical-table-cell.is-selected-cell { + box-shadow: inset 0 0 0 2px rgba(13, 148, 136, 0.62); + background: + linear-gradient(135deg, rgba(13, 148, 136, 0.16), transparent 38%), + rgba(255, 255, 255, 0.96); +} + +.critical-table-cell.is-roll-target::after, +.critical-table-grid-roll-band.is-roll-target::after { + content: ""; + position: absolute; + inset: 0; + border: 2px dashed rgba(13, 148, 136, 0.35); + pointer-events: none; +} + .critical-table-cell-shell { display: flex; flex-direction: column; @@ -2256,6 +2348,11 @@ pre, min-width: 0; } + .tables-context-tab-row { + flex-direction: column; + align-items: stretch; + } + .critical-editor-header, .critical-editor-footer { flex-direction: column;