diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index b555e18..d19dc81 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -64,6 +64,7 @@ It is intentionally implementation-focused: | 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. | | 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. | ### Lessons Learned @@ -97,6 +98,7 @@ It is intentionally implementation-focused: - 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. - 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. ## Target Outcomes @@ -460,7 +462,7 @@ Build the shared interaction infrastructure needed by multiple destinations befo | `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` | 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` | Pending | Introduce the sticky context bar with roll jump and mode/filter controls. | +| `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.6` | Pending | Remove visible resting-state action stacks from non-selected cells. | | `P3.7` | Pending | Add the desktop selection-driven inspector. | diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index b1be804..b8a5f57 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -57,10 +57,21 @@ + CurrentMode="referenceMode" + SelectedGroupKey="selectedGroupKey" + SelectedColumnKey="selectedColumnKey" + RollJumpValue="rollJumpValue" + OnTogglePin="TogglePinnedTableAsync" + OnModeChanged="UpdateReferenceModeAsync" + OnGroupChanged="UpdateSelectedGroupAsync" + OnColumnChanged="UpdateSelectedColumnAsync" + OnRollJumpChanged="UpdateRollJumpAsync" /> @@ -135,6 +146,10 @@ private string? curationQuickParseError; private int? curatingResultId; private CriticalCellEditorModel? curationModel; + private string referenceMode = TablesReferenceMode.Reference; + private string selectedGroupKey = string.Empty; + private string selectedColumnKey = string.Empty; + private string rollJumpValue = string.Empty; private bool hasResolvedStoredTableSelection; private CriticalTableReference? SelectedTableReference => referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase)); @@ -170,14 +185,17 @@ if (tableDetail is null) { detailError = "The selected table could not be loaded."; + NormalizeViewStateForCurrentDetail(); return; } await RecordRecentTableVisitAsync(); + NormalizeViewStateForCurrentDetail(); } catch (Exception exception) { detailError = exception.Message; + NormalizeViewStateForCurrentDetail(); } finally { @@ -621,4 +639,75 @@ new( TableSlug: selectedTableSlug, Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference); + + private Task UpdateReferenceModeAsync(string mode) + { + referenceMode = NormalizeMode(mode); + return Task.CompletedTask; + } + + private Task UpdateSelectedGroupAsync(string groupKey) + { + selectedGroupKey = NormalizeOptionalFilter(groupKey); + return Task.CompletedTask; + } + + private Task UpdateSelectedColumnAsync(string columnKey) + { + selectedColumnKey = NormalizeOptionalFilter(columnKey); + return Task.CompletedTask; + } + + private Task UpdateRollJumpAsync(string rollValue) + { + rollJumpValue = NormalizeRollInput(rollValue); + return Task.CompletedTask; + } + + private void NormalizeViewStateForCurrentDetail() + { + referenceMode = NormalizeMode(referenceMode); + + if (tableDetail is null) + { + selectedGroupKey = string.Empty; + selectedColumnKey = string.Empty; + rollJumpValue = string.Empty; + return; + } + + if (tableDetail.Groups.All(group => !string.Equals(group.Key, selectedGroupKey, StringComparison.OrdinalIgnoreCase))) + { + selectedGroupKey = string.Empty; + } + + if (tableDetail.Columns.All(column => !string.Equals(column.Key, selectedColumnKey, StringComparison.OrdinalIgnoreCase))) + { + selectedColumnKey = string.Empty; + } + + rollJumpValue = NormalizeRollInput(rollJumpValue); + } + + private static string NormalizeMode(string? mode) => + mode switch + { + TablesReferenceMode.NeedsCuration => TablesReferenceMode.NeedsCuration, + TablesReferenceMode.Curated => TablesReferenceMode.Curated, + _ => TablesReferenceMode.Reference + }; + + private static string NormalizeOptionalFilter(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + + private static string NormalizeRollInput(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var digitsOnly = new string(value.Where(char.IsDigit).ToArray()); + return digitsOnly.Length == 0 ? string.Empty : digitsOnly; + } } diff --git a/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor b/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor index 0587521..4dc0240 100644 --- a/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor +++ b/src/RolemasterDb.App/Components/Tables/TablesCanvas.razor @@ -3,11 +3,11 @@ @if (Detail.Groups.Count > 0) { - @foreach (var group in Detail.Groups) + @foreach (var group in visibleGroups) {
+ style="@BuildColumnSpanStyle(visibleColumns.Count)"> @group.Label
} @@ -30,39 +30,48 @@ { var cell = resolvedCell; -
-
-
- @if (cell.IsCurated) - { - Curated - } - else - { + @if (MatchesModeFilter(cell)) + { +
+
+
+ @if (cell.IsCurated) + { + Curated + } + else + { + + } + - } +
- +
- -
-
+ } + else + { +
+ Filtered +
+ } } else { @@ -80,11 +89,22 @@ @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 readonly List visibleGroups = new(); + private readonly List visibleColumns = new(); private string gridTemplateStyle = string.Empty; [Parameter, EditorRequired] public CriticalTableDetail Detail { get; set; } = default!; + [Parameter] + public string CurrentMode { get; set; } = TablesReferenceMode.Reference; + + [Parameter] + public string SelectedGroupKey { get; set; } = string.Empty; + + [Parameter] + public string SelectedColumnKey { get; set; } = string.Empty; + [Parameter] public EventCallback OnOpenCuration { get; set; } @@ -95,6 +115,8 @@ { cellIndex.Clear(); displayColumns.Clear(); + visibleGroups.Clear(); + visibleColumns.Clear(); foreach (var cell in Detail.Cells) { @@ -103,29 +125,56 @@ if (Detail.Groups.Count == 0) { - foreach (var column in Detail.Columns) + foreach (var column in Detail.Columns.Where(MatchesColumnFilter)) { + visibleColumns.Add(column); displayColumns.Add((null, column.Key, column.Label)); } } else { - foreach (var group in Detail.Groups) + foreach (var group in Detail.Groups.Where(MatchesGroupFilter)) { - foreach (var column in Detail.Columns) + visibleGroups.Add(group); + } + + foreach (var column in Detail.Columns.Where(MatchesColumnFilter)) + { + visibleColumns.Add(column); + } + + foreach (var group in visibleGroups) + { + foreach (var column in visibleColumns) { displayColumns.Add((group.Key, column.Key, column.Label)); } } } - var dataColumnCount = Detail.Columns.Count * Math.Max(Detail.Groups.Count, 1); + var dataColumnCount = displayColumns.Count; 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 bool MatchesGroupFilter(CriticalGroupReference group) => + string.IsNullOrWhiteSpace(SelectedGroupKey) + || string.Equals(group.Key, SelectedGroupKey, StringComparison.OrdinalIgnoreCase); + + private bool MatchesColumnFilter(CriticalColumnReference column) => + string.IsNullOrWhiteSpace(SelectedColumnKey) + || string.Equals(column.Key, SelectedColumnKey, StringComparison.OrdinalIgnoreCase); + + private bool MatchesModeFilter(CriticalTableCellDetail cell) => + CurrentMode switch + { + TablesReferenceMode.NeedsCuration => !cell.IsCurated, + TablesReferenceMode.Curated => cell.IsCurated, + _ => true + }; + private static string BuildColumnSpanStyle(int span) => $"grid-column: span {span};"; private static string GetCellCssClass(CriticalTableCellDetail cell) => diff --git a/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor b/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor index e24d6e9..17ef104 100644 --- a/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor +++ b/src/RolemasterDb.App/Components/Tables/TablesContextBar.razor @@ -1,28 +1,173 @@ -
-
-

@Detail.DisplayName

-

@GetReadingHint()

+
+
+
+

@Detail.DisplayName

+

@GetReadingHint()

+
+
+ +

Use the inspector to inspect, curate, or edit the selected result.

+
-
- -

Use the curation action or edit action on any filled result.

+ +
+ + +
+ @if (Detail.Groups.Count > 1) + { + + } + + @if (Detail.Columns.Count > 1) + { + + } + + +
+ + @if (HasActiveFilters()) + { +
+ @if (!string.IsNullOrWhiteSpace(SelectedGroupLabel)) + { + + } + + @if (!string.IsNullOrWhiteSpace(SelectedColumnLabel)) + { + + } + + @if (!string.IsNullOrWhiteSpace(RollJumpValue)) + { + + } + + @if (!string.Equals(CurrentMode, TablesReferenceMode.Reference, StringComparison.Ordinal)) + { + + } +
+ }
@code { + private readonly IReadOnlyList modeTabs = + [ + new(TablesReferenceMode.Reference, "Reference"), + new(TablesReferenceMode.NeedsCuration, "Needs Curation"), + new(TablesReferenceMode.Curated, "Curated") + ]; + [Parameter, EditorRequired] public CriticalTableDetail Detail { get; set; } = default!; [Parameter] public bool IsPinned { get; set; } + [Parameter] + public string CurrentMode { get; set; } = TablesReferenceMode.Reference; + + [Parameter] + public string SelectedGroupKey { get; set; } = string.Empty; + + [Parameter] + public string SelectedColumnKey { get; set; } = string.Empty; + + [Parameter] + public string RollJumpValue { get; set; } = string.Empty; + [Parameter] public EventCallback OnTogglePin { get; set; } + [Parameter] + public EventCallback OnModeChanged { get; set; } + + [Parameter] + public EventCallback OnGroupChanged { get; set; } + + [Parameter] + public EventCallback OnColumnChanged { get; set; } + + [Parameter] + public EventCallback OnRollJumpChanged { 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."; + + private string? SelectedGroupLabel => + Detail.Groups.FirstOrDefault(group => string.Equals(group.Key, SelectedGroupKey, StringComparison.OrdinalIgnoreCase))?.Label; + + private string? SelectedColumnLabel => + Detail.Columns.FirstOrDefault(column => string.Equals(column.Key, SelectedColumnKey, StringComparison.OrdinalIgnoreCase))?.Label; + + private bool HasActiveFilters() => + !string.IsNullOrWhiteSpace(SelectedGroupKey) + || !string.IsNullOrWhiteSpace(SelectedColumnKey) + || !string.IsNullOrWhiteSpace(RollJumpValue) + || !string.Equals(CurrentMode, TablesReferenceMode.Reference, StringComparison.Ordinal); + + private Task HandleGroupChanged(ChangeEventArgs args) => + OnGroupChanged.InvokeAsync(args.Value?.ToString() ?? string.Empty); + + private Task HandleColumnChanged(ChangeEventArgs args) => + OnColumnChanged.InvokeAsync(args.Value?.ToString() ?? string.Empty); + + private Task HandleRollJumpChanged(ChangeEventArgs args) => + OnRollJumpChanged.InvokeAsync(args.Value?.ToString() ?? string.Empty); + + private static string GetModeLabel(string mode) => + mode switch + { + TablesReferenceMode.NeedsCuration => "Needs Curation", + TablesReferenceMode.Curated => "Curated", + _ => "Reference" + }; } diff --git a/src/RolemasterDb.App/Components/Tables/TablesReferenceMode.cs b/src/RolemasterDb.App/Components/Tables/TablesReferenceMode.cs new file mode 100644 index 0000000..842077c --- /dev/null +++ b/src/RolemasterDb.App/Components/Tables/TablesReferenceMode.cs @@ -0,0 +1,8 @@ +namespace RolemasterDb.App.Components.Tables; + +public static class TablesReferenceMode +{ + public const string Reference = "reference"; + public const string NeedsCuration = "needs-curation"; + public const string Curated = "curated"; +} diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index 69c66b4..bde5a03 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -1357,6 +1357,72 @@ pre, margin-bottom: 1rem; } +.tables-context-bar { + position: sticky; + top: calc(var(--shell-header-height) + 1rem); + z-index: 4; + padding-bottom: 1rem; + margin-bottom: 1rem; + background: linear-gradient(180deg, rgba(255, 251, 245, 0.96), rgba(255, 251, 245, 0.92)); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(127, 96, 55, 0.14); +} + +.theme-dark .tables-context-bar { + background: linear-gradient(180deg, rgba(28, 31, 33, 0.96), rgba(28, 31, 33, 0.92)); +} + +.tables-context-primary, +.tables-context-controls { + display: grid; + gap: 0.85rem; +} + +.tables-context-controls { + margin-top: 0.85rem; +} + +.tables-context-mode-tabs { + width: fit-content; +} + +.tables-context-fields { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.tables-context-field { + display: grid; + gap: 0.35rem; + min-width: min(100%, 11rem); + color: var(--ink-soft); + font-size: 0.82rem; +} + +.tables-context-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tables-context-filter-chip { + border: 1px solid rgba(127, 96, 55, 0.2); + border-radius: 999px; + background: rgba(255, 246, 233, 0.88); + color: var(--ink); + padding: 0.35rem 0.75rem; + font-size: 0.82rem; + transition: border-color 0.16s ease, background-color 0.16s ease; +} + +.tables-context-filter-chip:hover, +.tables-context-filter-chip:focus-visible { + border-color: rgba(184, 121, 59, 0.34); + background: rgba(255, 239, 214, 0.96); + outline: none; +} + .table-browser-reading-hint { margin-top: 0.2rem; } @@ -1497,6 +1563,13 @@ pre, justify-content: center; } +.tables-filtered-cell { + color: var(--ink-faint); + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .empty-cell { color: #a08464; font-style: italic; @@ -2175,6 +2248,14 @@ pre, white-space: normal; } + .tables-context-fields { + flex-direction: column; + } + + .tables-context-field { + min-width: 0; + } + .critical-editor-header, .critical-editor-footer { flex-direction: column;