Add sticky tables context controls

This commit is contained in:
2026-03-21 15:04:56 +01:00
parent 88018e047e
commit 7a5568f77c
6 changed files with 418 additions and 44 deletions

View File

@@ -57,10 +57,21 @@
<TablesContextBar
Detail="detail"
IsPinned="PinnedTablesState.IsPinned(detail.Slug)"
OnTogglePin="TogglePinnedTableAsync" />
CurrentMode="referenceMode"
SelectedGroupKey="selectedGroupKey"
SelectedColumnKey="selectedColumnKey"
RollJumpValue="rollJumpValue"
OnTogglePin="TogglePinnedTableAsync"
OnModeChanged="UpdateReferenceModeAsync"
OnGroupChanged="UpdateSelectedGroupAsync"
OnColumnChanged="UpdateSelectedColumnAsync"
OnRollJumpChanged="UpdateRollJumpAsync" />
<TablesCanvas
Detail="detail"
CurrentMode="referenceMode"
SelectedGroupKey="selectedGroupKey"
SelectedColumnKey="selectedColumnKey"
OnOpenCuration="OpenCellCurationAsync"
OnOpenEditor="OpenCellEditorAsync" />
</div>
@@ -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;
}
}

View File

@@ -3,11 +3,11 @@
@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)
@foreach (var group in visibleGroups)
{
<div
class="critical-table-grid-header-cell critical-table-grid-group-header"
style="@BuildColumnSpanStyle(Detail.Columns.Count)">
style="@BuildColumnSpanStyle(visibleColumns.Count)">
<span>@group.Label</span>
</div>
}
@@ -30,39 +30,48 @@
{
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
{
@if (MatchesModeFilter(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="() => OnOpenCuration.InvokeAsync(cell.ResultId)">
Needs Curation
</button>
}
<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
class="critical-cell-action-button is-edit"
title="Open the full editor for this cell."
@onclick="() => OnOpenEditor.InvokeAsync(cell.ResultId)">
Edit
</button>
}
</div>
<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>
<CompactCriticalCell
Description="@(cell.Description ?? string.Empty)"
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
</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 tables-filtered-cell">
<span class="empty-cell">Filtered</span>
</div>
}
}
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<CriticalGroupReference> visibleGroups = new();
private readonly List<CriticalColumnReference> 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<int> 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) =>

View File

@@ -1,28 +1,173 @@
<header class="table-browser-header">
<div>
<h2 class="panel-title">@Detail.DisplayName</h2>
<p class="table-browser-reading-hint">@GetReadingHint()</p>
<header class="table-browser-header tables-context-bar">
<div class="tables-context-primary">
<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 inspector to inspect, curate, or edit the selected result.</p>
</div>
</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 class="tables-context-controls">
<SegmentedTabs
Items="modeTabs"
SelectedValue="CurrentMode"
SelectedValueChanged="OnModeChanged"
AriaLabel="Reference mode"
CssClass="tables-context-mode-tabs" />
<div class="tables-context-fields">
@if (Detail.Groups.Count > 1)
{
<label class="tables-context-field">
<span>Variant</span>
<select class="input-shell" value="@SelectedGroupKey" @onchange="HandleGroupChanged">
<option value="">All variants</option>
@foreach (var group in Detail.Groups)
{
<option value="@group.Key">@group.Label</option>
}
</select>
</label>
}
@if (Detail.Columns.Count > 1)
{
<label class="tables-context-field">
<span>Severity focus</span>
<select class="input-shell" value="@SelectedColumnKey" @onchange="HandleColumnChanged">
<option value="">All severities</option>
@foreach (var column in Detail.Columns)
{
<option value="@column.Key">@column.Label</option>
}
</select>
</label>
}
<label class="tables-context-field">
<span>Roll jump</span>
<input
class="input-shell"
inputmode="numeric"
pattern="[0-9]*"
placeholder="e.g. 66"
value="@RollJumpValue"
@oninput="HandleRollJumpChanged" />
</label>
</div>
@if (HasActiveFilters())
{
<div class="tables-context-filter-chips" aria-label="Active table filters">
@if (!string.IsNullOrWhiteSpace(SelectedGroupLabel))
{
<button type="button" class="tables-context-filter-chip" @onclick="() => OnGroupChanged.InvokeAsync(string.Empty)">
Variant: @SelectedGroupLabel
</button>
}
@if (!string.IsNullOrWhiteSpace(SelectedColumnLabel))
{
<button type="button" class="tables-context-filter-chip" @onclick="() => OnColumnChanged.InvokeAsync(string.Empty)">
Severity: @SelectedColumnLabel
</button>
}
@if (!string.IsNullOrWhiteSpace(RollJumpValue))
{
<button type="button" class="tables-context-filter-chip" @onclick="() => OnRollJumpChanged.InvokeAsync(string.Empty)">
Roll: @RollJumpValue
</button>
}
@if (!string.Equals(CurrentMode, TablesReferenceMode.Reference, StringComparison.Ordinal))
{
<button type="button" class="tables-context-filter-chip" @onclick="() => OnModeChanged.InvokeAsync(TablesReferenceMode.Reference)">
Mode: @GetModeLabel(CurrentMode)
</button>
}
</div>
}
</div>
</header>
@code {
private readonly IReadOnlyList<SegmentedTabItem> 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<string> OnModeChanged { get; set; }
[Parameter]
public EventCallback<string> OnGroupChanged { get; set; }
[Parameter]
public EventCallback<string> OnColumnChanged { get; set; }
[Parameter]
public EventCallback<string> 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"
};
}

View File

@@ -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";
}