Harden shell and tables interactions
This commit is contained in:
@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
|
||||
|
||||
- Branch: `frontend/tables-overhaul`
|
||||
- Last updated: `2026-04-12`
|
||||
- Current focus: `Phase 7`
|
||||
- Current focus: `Post-Phase 7 review`
|
||||
- Document mode: living plan and progress log
|
||||
|
||||
### Progress Log
|
||||
@@ -79,6 +79,7 @@ It is intentionally implementation-focused:
|
||||
| 2026-04-12 | Phase 4 | Completed | Replaced the placeholder `/curation` route with a real queue-first workspace, added queue scope and context persistence, moved browse-to-curation handoff out of `Tables`, and preserved diagnostics and full-editor escape hatches without keeping queue work on the reference page. |
|
||||
| 2026-04-12 | Phase 5 | Completed | Consolidated the existing tooling routes into a coherent `Tools` workspace with a real hub, shared tooling page frame, preserved-context exits from diagnostics back into `Tables` and `Curation`, and a grouped API reference surface. |
|
||||
| 2026-04-12 | Phase 6 | Completed | Reframed `/` as `Play`, replaced the old symmetric dashboard treatment with a resolver-first layout, aligned the page to the shared shell and token system, and added result-to-`Tables` deep links from both direct and attack-driven critical outcomes. |
|
||||
| 2026-04-12 | Phase 7 | Completed | Hardened keyboard/focus behavior across shell and table workflows, raised compact controls to the 44px accessibility target, tightened sticky-shell scroll offsets, added explicit deep-link serializer coverage, removed obsolete old-shell and old-`/tables` component paths, and cleaned up the final route/shell documentation. |
|
||||
|
||||
### Lessons Learned
|
||||
|
||||
@@ -212,12 +213,12 @@ Create the implementation foundation so the visual overhaul does not start with
|
||||
| App host | `src/RolemasterDb.App/Components/App.razor` | document shell, fonts, global CSS/script includes | keep as the document root; only update fonts and global assets during Phase 1 |
|
||||
| Router | `src/RolemasterDb.App/Components/Routes.razor` | route resolution and layout selection | keep single router; add compatibility route components instead of special middleware redirects |
|
||||
| App shell | `src/RolemasterDb.App/Components/Layout/MainLayout.razor` | current sidebar layout and page body host | replace with the new top app bar shell and mobile bottom nav |
|
||||
| Primary nav | `src/RolemasterDb.App/Components/Layout/NavMenu.razor` | implementation-bucket navigation | retire after the new shell lands; replace with destination navigation primitives |
|
||||
| Primary nav | `src/RolemasterDb.App/Components/Shell/ShellPrimaryNav.razor` | destination-based shell navigation | keep as the canonical top and mobile nav surface |
|
||||
| Home page | `src/RolemasterDb.App/Components/Pages/Home.razor` | live lookup flow | keep behavior, later restyle and reframe as `Play` |
|
||||
| Tables page | `src/RolemasterDb.App/Components/Pages/Tables.razor` | table selection, table rendering, persisted selection, editor launch, curation queue work | split into page shell, selection state, index rail, context bar, table canvas, inspector, and action services |
|
||||
| Diagnostics page | `src/RolemasterDb.App/Components/Pages/Diagnostics.razor` | engineering inspection of a selected cell | move under `Tools` and reuse shared table-selection state |
|
||||
| API page | `src/RolemasterDb.App/Components/Pages/Api.razor` | static API docs page | move under `Tools` with updated framing |
|
||||
| Shared editor and curation UI | `src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor`, `src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor` | full editing and quick curation interactions | preserve behavior; change launch points and page ownership |
|
||||
| Shared editor and curation UI | `src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor`, `src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor` | full editing and queue-first quick curation interactions | preserve behavior while keeping repeated curation work on the dedicated `/curation` surface |
|
||||
| Lookup and table contracts | `src/RolemasterDb.App/Features/LookupContracts.cs` | frontend-facing DTOs for tables, lookups, editing, and diagnostics | preserve contracts; build frontend state and deep links around them |
|
||||
|
||||
### Behaviors To Preserve
|
||||
@@ -226,7 +227,7 @@ Create the implementation foundation so the visual overhaul does not start with
|
||||
| --- | --- | --- | --- |
|
||||
| Selected table persistence | `Tables.razor` local storage key `rolemaster.tables.selectedTable` | shared per-destination context persistence service | move out of page code during Phase 2 |
|
||||
| Full cell editor flow | `Tables.razor` + `CriticalCellEditorDialog.razor` | stable editor entry from inspector, curation, and tools | editor remains modal for now |
|
||||
| Quick curation flow and save-next behavior | `Tables.razor` + `CriticalCellCurationDialog.razor` | dedicated `Curation` workflow with reused quick-parse components | queue logic moves off the reference page |
|
||||
| Quick curation flow and save-next behavior | `Curation.razor` + `CurationWorkspace.razor` | dedicated `Curation` workflow with reused quick-parse components | queue logic is no longer carried by the reference page |
|
||||
| Diagnostics selection model | `Diagnostics.razor` | shared table-position selector model used by `Tools` | existing selection order is a good starting point |
|
||||
| Attack and direct critical lookup flows | `Home.razor` | `Play` page behaviors | preserve contracts and calculation behavior |
|
||||
| Result preview components | `CriticalLookupResultCard.razor`, `CompactCriticalCell.razor`, related shared components | reusable reference and inspector content | keep and adapt instead of rewriting presentation logic from scratch |
|
||||
@@ -589,7 +590,7 @@ Turn `/tables` into the canonical reference surface for reading and inspecting c
|
||||
|
||||
- The route already exists at `Components/Pages/Curation.razor`, but it is still a placeholder panel with no queue, no selection state, and no working-lane layout.
|
||||
- The active curation implementation currently lives inside `Components/Pages/Tables.razor` through page-local state such as `OpenCellCurationAsync`, `MarkCellCuratedAsync`, `LoadCurationCellAsync`, `ReparseCurationCellAsync`, and `FindNextUncuratedResultId`.
|
||||
- The current curation UI is rendered by `Components/Shared/CriticalCellCurationDialog.razor`, which already contains the two most valuable pieces of the eventual Phase 4 surface: side-by-side parsed preview and source image, plus an inline quick-parse mode.
|
||||
- The current queue-first curation UI now lives in `Components/Curation/CurationWorkspace.razor`, which keeps the two most valuable pieces of the old dialog flow: side-by-side parsed preview and source image, plus an inline quick-parse mode.
|
||||
- The current `next uncurated` behavior only walks the already loaded cells of the selected table. It does not yet operate across queue scopes such as all tables or the pinned set.
|
||||
- The full editor path already exists and should remain available, but the present implementation still depends on opening curation from the `Tables` browsing flow rather than from a dedicated repeated-action workflow.
|
||||
|
||||
@@ -677,7 +678,7 @@ Create a dedicated queue-first curation workflow so repair work is fast and does
|
||||
|
||||
### Phase 4 Implementation Notes
|
||||
|
||||
- The lowest-risk migration path is to keep `CriticalCellCurationDialog` as the behavioral baseline, extract any queue and save helpers that are currently trapped in `Tables.razor`, and then rehost that workflow inside new `Components/Curation` surfaces.
|
||||
- The lowest-risk migration path was to keep the old dialog behavior as the baseline, extract any queue and save helpers that were trapped in `Tables.razor`, and then rehost that workflow inside `Components/Curation` surfaces.
|
||||
- The current page-local `FindNextUncuratedResultId` logic is the clearest seam for early extraction. Once that becomes a shared queue helper, the same save-and-advance contract can back both the dedicated `Curation` page and any temporary compatibility entry points from `Tables`.
|
||||
- `Curation` should reuse the existing deep-link grammar rather than inventing a second identifier model. The distinction should be workflow mode and queue scope, not a parallel object-addressing scheme.
|
||||
- The implementation should keep normal curation modal-free on desktop and mobile. Full edit can still use a focused drawer or sheet, but the queue lane itself should be a stable page surface.
|
||||
@@ -770,6 +771,26 @@ Make the default landing experience feel like part of the same product and conne
|
||||
|
||||
## Phase 7: Hardening, Accessibility, Performance, And Rollout Cleanup
|
||||
|
||||
### Status
|
||||
|
||||
`Completed`
|
||||
|
||||
### Task Progress
|
||||
|
||||
| Task | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| `P7.1` | Completed | Shared tabs, shell overlays, and the table index rail now expose stronger keyboard reachability and visible active state. |
|
||||
| `P7.2` | Completed | Focus treatment was normalized across shell navigation, compact controls, table index actions, and interactive result surfaces. |
|
||||
| `P7.3` | Completed | Accent, warning, success, and tooling emphasis continue to use distinct semantic color ramps in both themes after the final focus and hit-target pass. |
|
||||
| `P7.4` | Completed | Sticky shell/context-bar and contained-scroll behavior were verified and tightened for the final desktop/mobile layout. |
|
||||
| `P7.5` | Completed | Final shell scroll offsets now reserve space for sticky shell chrome so content is not obscured during navigation or restored context jumps. |
|
||||
| `P7.6` | Completed | Shared table-context URL round-trip coverage now explicitly exercises the full serializer field set, including queue scope. |
|
||||
| `P7.7` | Completed | The per-destination restore model established earlier remained the final persisted-context path and was reverified during the hardening pass. |
|
||||
| `P7.8` | Completed | `TablesCanvas` now caches active row/column/group state and roll-jump context instead of recomputing those hot-path values throughout the render loop. |
|
||||
| `P7.9` | Completed | Virtualization was not introduced because profiling did not justify the extra sticky-grid complexity. |
|
||||
| `P7.10` | Completed | Obsolete old-shell and old-`/tables` components were removed from the active codebase, including the retired inspector and pre-queue curation dialog path. |
|
||||
| `P7.11` | Completed | The implementation plan now reflects the final route map, shell model, and surviving component boundaries. |
|
||||
|
||||
### Goal
|
||||
|
||||
Stabilize the overhaul and ensure the final UX matches the bible under real usage conditions.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<div class="nav-menu-shell">
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler')?.click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Lookup Desk
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="api">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> API Surface
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="tables">
|
||||
<span class="bi bi-table" aria-hidden="true"></span> Critical Tables
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="diagnostics">
|
||||
<span class="bi bi-tools" aria-hidden="true"></span> Diagnostics
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,98 +0,0 @@
|
||||
.nav-menu-shell {
|
||||
position: relative;
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(226, 195, 128, 0.22);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 244, 218, 0.82%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(226, 195, 128, 0.3);
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23fce7bc' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23fce7bc' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.98rem;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
font-family: "Source Sans 3", "Segoe UI", sans-serif;
|
||||
color: #f3ddbc;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background: linear-gradient(135deg, rgba(230, 195, 126, 0.22), rgba(255, 245, 225, 0.1));
|
||||
color: #fff7e2;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255, 248, 234, 0.08);
|
||||
color: #fff7e2;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
padding: 3.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-menu-shell {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: block;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,17 @@
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
var isSelected = string.Equals(item.Value, SelectedValue, StringComparison.Ordinal);
|
||||
var tabIndex = isSelected || (!HasSelectedValue && string.Equals(item.Value, FirstEnabledValue, StringComparison.Ordinal)) ? 0 : -1;
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="segmented-tabs-button @(isSelected ? "is-selected" : null)"
|
||||
role="tab"
|
||||
aria-selected="@isSelected"
|
||||
tabindex="@tabIndex"
|
||||
disabled="@item.IsDisabled"
|
||||
@onclick="() => SelectAsync(item.Value)">
|
||||
@onclick="() => SelectAsync(item.Value)"
|
||||
@onkeydown="args => HandleKeyDownAsync(args, item.Value)">
|
||||
<span>@item.Label</span>
|
||||
@if (!string.IsNullOrWhiteSpace(item.Badge))
|
||||
{
|
||||
@@ -20,6 +23,7 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SegmentedTabItem> Items { get; set; } = [];
|
||||
|
||||
@@ -35,11 +39,47 @@
|
||||
[Parameter]
|
||||
public string? CssClass { get; set; }
|
||||
|
||||
private bool HasSelectedValue =>
|
||||
!string.IsNullOrWhiteSpace(SelectedValue) && Items.Any(item => !item.IsDisabled && string.Equals(item.Value, SelectedValue, StringComparison.Ordinal));
|
||||
|
||||
private string? FirstEnabledValue =>
|
||||
Items.FirstOrDefault(item => !item.IsDisabled)?.Value;
|
||||
|
||||
private string BuildCssClass() =>
|
||||
string.IsNullOrWhiteSpace(CssClass)
|
||||
? "segmented-tabs"
|
||||
: $"segmented-tabs {CssClass}";
|
||||
string.IsNullOrWhiteSpace(CssClass) ? "segmented-tabs" : $"segmented-tabs {CssClass}";
|
||||
|
||||
private Task SelectAsync(string value) =>
|
||||
SelectedValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task HandleKeyDownAsync(KeyboardEventArgs args, string currentValue)
|
||||
{
|
||||
var enabledItems = Items.Where(item => !item.IsDisabled).ToList();
|
||||
if (enabledItems.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentIndex = enabledItems.FindIndex(item => string.Equals(item.Value, currentValue, StringComparison.Ordinal));
|
||||
if (currentIndex < 0)
|
||||
{
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
var nextValue = args.Key switch
|
||||
{
|
||||
"ArrowRight" or "ArrowDown" => enabledItems[Math.Min(currentIndex + 1, enabledItems.Count - 1)].Value,
|
||||
"ArrowLeft" or "ArrowUp" => enabledItems[Math.Max(currentIndex - 1, 0)].Value,
|
||||
"Home" => enabledItems[0].Value,
|
||||
"End" => enabledItems[^1].Value,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(nextValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SelectAsync(nextValue);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
@if (IsOpen)
|
||||
{
|
||||
<button
|
||||
type="button"
|
||||
class="surface-drawer-backdrop"
|
||||
aria-label="@CloseLabel"
|
||||
@onclick="HandleCloseAsync"></button>
|
||||
|
||||
<aside class="@BuildCssClass()" aria-label="@AriaLabel">
|
||||
@if (!string.IsNullOrWhiteSpace(Title) || HeaderContent is not null || OnClose.HasDelegate)
|
||||
{
|
||||
<header class="surface-drawer-header">
|
||||
<div>
|
||||
@if (!string.IsNullOrWhiteSpace(Title))
|
||||
{
|
||||
<strong class="surface-drawer-title">@Title</strong>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="surface-drawer-header-actions">
|
||||
@HeaderContent
|
||||
@if (OnClose.HasDelegate)
|
||||
{
|
||||
<button type="button" class="btn btn-link" @onclick="HandleCloseAsync">Close</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
<div class="surface-drawer-body">
|
||||
@ChildContent
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool IsOpen { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Placement { get; set; } = "end";
|
||||
|
||||
[Parameter]
|
||||
public string AriaLabel { get; set; } = "Drawer";
|
||||
|
||||
[Parameter]
|
||||
public string CloseLabel { get; set; } = "Close panel";
|
||||
|
||||
[Parameter]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? HeaderContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnClose { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? CssClass { get; set; }
|
||||
|
||||
private string BuildCssClass()
|
||||
{
|
||||
var classes = new List<string> { "surface-drawer", $"is-{Placement.Trim().ToLowerInvariant()}" };
|
||||
if (!string.IsNullOrWhiteSpace(CssClass))
|
||||
{
|
||||
classes.Add(CssClass);
|
||||
}
|
||||
|
||||
return string.Join(' ', classes);
|
||||
}
|
||||
|
||||
private Task HandleCloseAsync() =>
|
||||
OnClose.InvokeAsync();
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using RolemasterDb.App.Components.Curation
|
||||
@using RolemasterDb.App.Features
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="critical-editor-backdrop"
|
||||
@onpointerdown="HandleBackdropPointerDown"
|
||||
@onpointerup="HandleBackdropPointerUp"
|
||||
@onpointercancel="HandleBackdropPointerCancel">
|
||||
<div class="critical-editor-dialog critical-curation-dialog"
|
||||
@onpointerdown="HandleDialogPointerDown"
|
||||
@onpointerup="HandleDialogPointerUp"
|
||||
@onpointercancel="HandleDialogPointerCancel"
|
||||
@onpointerdown:stopPropagation="true"
|
||||
@onpointerup:stopPropagation="true"
|
||||
@onpointercancel:stopPropagation="true">
|
||||
<header class="critical-editor-header">
|
||||
<div>
|
||||
<h3 class="panel-title">Curate Result Card</h3>
|
||||
@if (Model is not null)
|
||||
{
|
||||
<p class="muted critical-editor-meta">
|
||||
<strong>@Model.TableName</strong>
|
||||
<span> · Column <strong>@BuildColumnDisplayText(Model)</strong></span>
|
||||
<span> · Roll band <strong>@Model.RollBand</strong></span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button>
|
||||
</header>
|
||||
|
||||
<div class="critical-editor-body critical-curation-body @(IsQuickParseMode ? "is-quick-parse" : null)">
|
||||
<CurationWorkspace
|
||||
Model="Model"
|
||||
IsLoading="IsLoading"
|
||||
IsSaving="IsSaving"
|
||||
IsReparsing="IsReparsing"
|
||||
IsQuickParseMode="IsQuickParseMode"
|
||||
ErrorMessage="@ErrorMessage"
|
||||
QuickParseErrorMessage="@QuickParseErrorMessage"
|
||||
LegendEntries="LegendEntries"
|
||||
PrimaryActionLabel="Mark as Curated"
|
||||
SecondaryActionLabel="Edit"
|
||||
SecondaryActionCssClass="btn btn-link"
|
||||
OnPrimaryAction="OnMarkCurated"
|
||||
OnSecondaryAction="OnEdit"
|
||||
OnEnterQuickParse="OnEnterQuickParse"
|
||||
OnCancelQuickParse="OnCancelQuickParse"
|
||||
OnReparse="OnReparse"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public CriticalCellEditorModel? Model { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsLoading { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsSaving { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsReparsing { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsQuickParseMode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? QuickParseErrorMessage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CriticalTableLegendEntry>? LegendEntries { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback OnClose { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback OnMarkCurated { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback OnEnterQuickParse { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback OnCancelQuickParse { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public EventCallback OnReparse { get; set; }
|
||||
|
||||
private IJSObjectReference? jsModule;
|
||||
private bool isBackdropPointerDown;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./components/shared/critical-cell-editor-dialog.js");
|
||||
|
||||
await jsModule.InvokeVoidAsync("lockBackgroundScroll");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (jsModule is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await jsModule.InvokeVoidAsync("unlockBackgroundScroll");
|
||||
await jsModule.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleBackdropPointerDown()
|
||||
{
|
||||
isBackdropPointerDown = true;
|
||||
}
|
||||
|
||||
private async Task HandleBackdropPointerUp()
|
||||
{
|
||||
if (!isBackdropPointerDown)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isBackdropPointerDown = false;
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private void HandleBackdropPointerCancel()
|
||||
{
|
||||
isBackdropPointerDown = false;
|
||||
}
|
||||
|
||||
private void HandleDialogPointerDown()
|
||||
{
|
||||
isBackdropPointerDown = false;
|
||||
}
|
||||
|
||||
private void HandleDialogPointerUp()
|
||||
{
|
||||
isBackdropPointerDown = false;
|
||||
}
|
||||
|
||||
private void HandleDialogPointerCancel()
|
||||
{
|
||||
isBackdropPointerDown = false;
|
||||
}
|
||||
|
||||
private static string BuildColumnDisplayText(CriticalCellEditorModel model) =>
|
||||
string.IsNullOrWhiteSpace(model.GroupLabel) ? model.ColumnLabel : $"{model.GroupLabel} / {model.ColumnLabel}";
|
||||
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
<nav class="app-shell-header-nav" aria-label="Primary">
|
||||
@if (PrimaryNavContent is null)
|
||||
{
|
||||
<ShellPrimaryNav />
|
||||
<ShellPrimaryNav/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -45,6 +45,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="app-shell-menu-toggle"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="@isNavMenuOpen"
|
||||
aria-controls="app-shell-drawer"
|
||||
@onclick="ToggleNavMenu">
|
||||
@@ -65,7 +66,7 @@
|
||||
}
|
||||
</header>
|
||||
|
||||
<ShellOmniboxPalette />
|
||||
<ShellOmniboxPalette/>
|
||||
|
||||
<main id="app-main" class="app-shell-main">
|
||||
<div class="content-shell">
|
||||
@@ -79,26 +80,28 @@
|
||||
type="button"
|
||||
class="app-shell-drawer-backdrop"
|
||||
aria-label="Close navigation"
|
||||
@onclick="CloseNavMenu"></button>
|
||||
@onclick="CloseNavMenu">
|
||||
</button>
|
||||
|
||||
<aside id="app-shell-drawer" class="app-shell-drawer" aria-label="Primary navigation">
|
||||
<aside id="app-shell-drawer" class="app-shell-drawer" role="dialog" aria-modal="true" aria-label="Primary navigation" @onkeydown="HandleNavMenuKeyDownAsync">
|
||||
<div class="app-shell-drawer-header">
|
||||
<strong>Navigate</strong>
|
||||
<button type="button" class="app-shell-drawer-close" @onclick="CloseNavMenu">
|
||||
<button type="button" class="app-shell-drawer-close" @ref="navMenuCloseButton" @onclick="CloseNavMenu">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ShellPrimaryNav />
|
||||
<ShellPrimaryNav/>
|
||||
</aside>
|
||||
}
|
||||
|
||||
<nav class="app-shell-mobile-nav" aria-label="Primary">
|
||||
<ShellPrimaryNav IsBottomNav="true" />
|
||||
<ShellPrimaryNav IsBottomNav="true"/>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
@@ -118,20 +121,35 @@
|
||||
public RenderFragment? UtilityContent { get; set; }
|
||||
|
||||
private bool isNavMenuOpen;
|
||||
private ElementReference navMenuCloseButton;
|
||||
private bool shouldFocusNavMenuCloseButton;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.LocationChanged += HandleLocationChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!shouldFocusNavMenuCloseButton || !isNavMenuOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
shouldFocusNavMenuCloseButton = false;
|
||||
await navMenuCloseButton.FocusAsync();
|
||||
}
|
||||
|
||||
private void ToggleNavMenu()
|
||||
{
|
||||
isNavMenuOpen = !isNavMenuOpen;
|
||||
shouldFocusNavMenuCloseButton = isNavMenuOpen;
|
||||
}
|
||||
|
||||
private void CloseNavMenu()
|
||||
{
|
||||
isNavMenuOpen = false;
|
||||
shouldFocusNavMenuCloseButton = false;
|
||||
}
|
||||
|
||||
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||
@@ -144,4 +162,15 @@
|
||||
{
|
||||
NavigationManager.LocationChanged -= HandleLocationChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleNavMenuKeyDownAsync(KeyboardEventArgs args)
|
||||
{
|
||||
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
|
||||
{
|
||||
CloseNavMenu();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,6 +24,11 @@
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.skip-link:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-shell-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -95,6 +100,7 @@
|
||||
|
||||
.app-shell-header-omnibox {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell-header-actions {
|
||||
@@ -127,6 +133,12 @@
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.app-shell-menu-toggle:focus-visible,
|
||||
.app-shell-drawer-close:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-shell-shortcuts {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
@@ -135,6 +147,7 @@
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 1rem 0 5.75rem;
|
||||
scroll-margin-top: calc(var(--shell-header-height, 5.75rem) + 1rem);
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
@@ -197,7 +210,7 @@
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, transparent);
|
||||
color: var(--text-primary);
|
||||
min-height: 2.5rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
}
|
||||
|
||||
@@ -212,7 +225,7 @@
|
||||
}
|
||||
|
||||
.app-shell-bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
type="button"
|
||||
class="shell-omnibox-backdrop"
|
||||
aria-label="Close search panel"
|
||||
@onclick="CloseAsync"></button>
|
||||
@onclick="CloseAsync">
|
||||
</button>
|
||||
|
||||
<section class="shell-omnibox-palette" role="dialog" aria-modal="true" aria-label="Search tables and commands">
|
||||
<section class="shell-omnibox-palette" role="dialog" aria-modal="true" aria-labelledby="shell-omnibox-title" @onkeydown="HandlePaletteKeyDownAsync">
|
||||
<header class="shell-omnibox-header">
|
||||
<h2 id="shell-omnibox-title" class="visually-hidden">Search tables and commands</h2>
|
||||
<label class="shell-omnibox-search">
|
||||
<span class="visually-hidden">Search tables or commands</span>
|
||||
<input
|
||||
@@ -26,7 +28,7 @@
|
||||
placeholder="Search tables or type /"
|
||||
@bind="query"
|
||||
@bind:event="oninput"
|
||||
@onkeydown="HandleInputKeyDownAsync" />
|
||||
@onkeydown="HandleInputKeyDownAsync"/>
|
||||
</label>
|
||||
|
||||
<button type="button" class="shell-omnibox-close" @onclick="CloseAsync">
|
||||
@@ -139,6 +141,7 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
private static readonly IReadOnlyList<ShellOmniboxCommand> Commands =
|
||||
[
|
||||
new("/tables", "Reference", "Open the reference tables surface.", "/tables"),
|
||||
@@ -155,37 +158,19 @@
|
||||
private string query = string.Empty;
|
||||
|
||||
private bool HasResults =>
|
||||
MatchingCommands.Count > 0 ||
|
||||
MatchingPinned.Count > 0 ||
|
||||
MatchingRecent.Count > 0 ||
|
||||
MatchingTables.Count > 0;
|
||||
MatchingCommands.Count > 0 || MatchingPinned.Count > 0 || MatchingRecent.Count > 0 || MatchingTables.Count > 0;
|
||||
|
||||
private IReadOnlyList<CriticalTableReference> MatchingTables =>
|
||||
referenceData?.CriticalTables
|
||||
.Where(MatchesTableQuery)
|
||||
.Take(8)
|
||||
.ToList()
|
||||
?? [];
|
||||
referenceData?.CriticalTables.Where(MatchesTableQuery).Take(8).ToList() ?? [];
|
||||
|
||||
private IReadOnlyList<PinnedTableEntry> MatchingPinned =>
|
||||
PinnedTablesState.Items
|
||||
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
|
||||
.Take(6)
|
||||
.ToList();
|
||||
PinnedTablesState.Items.Where(item => MatchesText(item.Label, item.Family, item.Slug)).Take(6).ToList();
|
||||
|
||||
private IReadOnlyList<RecentTableEntry> MatchingRecent =>
|
||||
RecentTablesState.Items
|
||||
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
|
||||
.Take(6)
|
||||
.ToList();
|
||||
RecentTablesState.Items.Where(item => MatchesText(item.Label, item.Family, item.Slug)).Take(6).ToList();
|
||||
|
||||
private IReadOnlyList<ShellOmniboxCommand> MatchingCommands =>
|
||||
Commands
|
||||
.Where(command => QueryStartsCommandMode()
|
||||
? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
: MatchesText(command.Shortcut, command.Label, command.Description))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
Commands.Where(command => QueryStartsCommandMode() ? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase) : MatchesText(command.Shortcut, command.Label, command.Description)).Take(5).ToList();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -274,11 +259,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandlePaletteKeyDownAsync(KeyboardEventArgs args) =>
|
||||
string.Equals(args.Key, "Escape", StringComparison.Ordinal) ? CloseAsync() : Task.CompletedTask;
|
||||
|
||||
private async Task OpenTableAsync(string tableSlug)
|
||||
{
|
||||
var snapshot = new TableContextSnapshot(
|
||||
TableSlug: tableSlug,
|
||||
Mode: TableContextMode.Reference);
|
||||
var snapshot = new TableContextSnapshot(TableSlug: tableSlug, Mode: TableContextMode.Reference);
|
||||
|
||||
await TableContextState.PersistAsync("tables", snapshot);
|
||||
ShellOmniboxState.Close();
|
||||
@@ -303,11 +289,10 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
return values.Any(value =>
|
||||
!string.IsNullOrWhiteSpace(value) &&
|
||||
value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase));
|
||||
return values.Any(value => !string.IsNullOrWhiteSpace(value) && value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool QueryStartsCommandMode() =>
|
||||
query.TrimStart().StartsWith("/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border: 1px solid var(--border-default);
|
||||
@@ -13,6 +15,9 @@
|
||||
}
|
||||
|
||||
.shell-omnibox-trigger-label {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.shell-primary-nav-link:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.shell-primary-nav-link.active {
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--accent-1) 84%, var(--surface-2));
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
@onkeydown="args => HandleCellKeyDown(args, cell)">
|
||||
<div class="critical-table-cell-shell">
|
||||
<div class="critical-table-cell-actions">
|
||||
@if (string.Equals(CurrentMode, TablesReferenceMode.Reference, StringComparison.Ordinal))
|
||||
@if (isReferenceMode)
|
||||
{
|
||||
<StatusIndicator Tone="@(cell.IsCurated ? "success" : "warning")" CssClass="tables-cell-status-indicator"/>
|
||||
}
|
||||
@@ -92,6 +92,11 @@
|
||||
private readonly List<CriticalGroupReference> visibleGroups = new();
|
||||
private readonly List<CriticalColumnReference> visibleColumns = new();
|
||||
private string gridTemplateStyle = string.Empty;
|
||||
private bool isReferenceMode;
|
||||
private string? activeRollBand;
|
||||
private string? activeColumnKey;
|
||||
private string? activeGroupKey;
|
||||
private string? rollJumpBandLabel;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public CriticalTableDetail Detail { get; set; } = default!;
|
||||
@@ -123,6 +128,11 @@
|
||||
displayColumns.Clear();
|
||||
visibleGroups.Clear();
|
||||
visibleColumns.Clear();
|
||||
isReferenceMode = string.Equals(CurrentMode, TablesReferenceMode.Reference, StringComparison.Ordinal);
|
||||
rollJumpBandLabel = ResolveRollJumpBandLabel();
|
||||
activeRollBand = !string.IsNullOrWhiteSpace(SelectedCell?.RollBand) ? SelectedCell.RollBand : rollJumpBandLabel;
|
||||
activeColumnKey = !string.IsNullOrWhiteSpace(SelectedCell?.ColumnKey) ? SelectedCell.ColumnKey : (!string.IsNullOrWhiteSpace(SelectedColumnKey) ? SelectedColumnKey : null);
|
||||
activeGroupKey = !string.IsNullOrWhiteSpace(SelectedCell?.GroupKey) ? SelectedCell.GroupKey : (!string.IsNullOrWhiteSpace(SelectedGroupKey) ? SelectedGroupKey : null);
|
||||
|
||||
foreach (var cell in Detail.Cells)
|
||||
{
|
||||
@@ -197,15 +207,6 @@
|
||||
_ => true
|
||||
};
|
||||
|
||||
private string? ActiveRollBand =>
|
||||
!string.IsNullOrWhiteSpace(SelectedCell?.RollBand) ? SelectedCell.RollBand : ResolveRollJumpBandLabel();
|
||||
|
||||
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()
|
||||
{
|
||||
var classes = new List<string>
|
||||
@@ -224,7 +225,7 @@
|
||||
"critical-table-grid-header-cell",
|
||||
"critical-table-grid-group-header"
|
||||
};
|
||||
if (string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(groupKey, activeGroupKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-active-group");
|
||||
}
|
||||
@@ -240,12 +241,12 @@
|
||||
"critical-table-grid-column-header"
|
||||
};
|
||||
|
||||
if (string.Equals(columnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(columnKey, activeColumnKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-active-column");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, activeGroupKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-active-group");
|
||||
}
|
||||
@@ -260,12 +261,12 @@
|
||||
"critical-table-grid-header-cell",
|
||||
"critical-table-grid-roll-band"
|
||||
};
|
||||
if (string.Equals(rollBandLabel, ActiveRollBand, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(rollBandLabel, activeRollBand, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-active-row");
|
||||
}
|
||||
|
||||
if (string.Equals(rollBandLabel, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(rollBandLabel, rollJumpBandLabel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-roll-target");
|
||||
}
|
||||
@@ -281,17 +282,17 @@
|
||||
cell.IsCurated ? "is-curated" : "needs-curation"
|
||||
};
|
||||
|
||||
if (string.Equals(cell.RollBand, ActiveRollBand, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(cell.RollBand, activeRollBand, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-active-row");
|
||||
}
|
||||
|
||||
if (string.Equals(cell.ColumnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(cell.ColumnKey, activeColumnKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-active-column");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, activeGroupKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-active-group");
|
||||
}
|
||||
@@ -301,7 +302,7 @@
|
||||
classes.Add("is-selected-cell");
|
||||
}
|
||||
|
||||
if (string.Equals(cell.RollBand, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(cell.RollBand, rollJumpBandLabel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
classes.Add("is-roll-target");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<section class="tables-index-rail" aria-labelledby="tables-index-heading">
|
||||
<section class="tables-index-rail" aria-labelledby="tables-index-heading" tabindex="0" @onkeydown="HandleRailKeyDown">
|
||||
<div class="tables-index-rail-header">
|
||||
<h2 id="tables-index-heading" class="tables-index-title">Table Index</h2>
|
||||
</div>
|
||||
|
||||
<div class="tables-index-controls" @onkeydown="HandleRailKeyDown">
|
||||
<div class="tables-index-controls">
|
||||
<label class="tables-index-search-label" for="tables-index-search">Search tables</label>
|
||||
<input
|
||||
id="tables-index-search"
|
||||
@@ -11,7 +11,8 @@
|
||||
type="search"
|
||||
placeholder="Search tables"
|
||||
value="@searchText"
|
||||
@oninput="HandleSearchInput"/>
|
||||
@oninput="HandleSearchInput"
|
||||
@onkeydown:stopPropagation="true"/>
|
||||
|
||||
@if (familyFilters.Count > 1)
|
||||
{
|
||||
@@ -40,7 +41,7 @@
|
||||
<h3>Pinned</h3>
|
||||
<span class="tables-index-section-count">@pinnedTables.Count</span>
|
||||
</div>
|
||||
<div class="tables-index-list" role="listbox" aria-label="Pinned critical tables">
|
||||
<div class="tables-index-list" aria-label="Pinned critical tables">
|
||||
@foreach (var table in pinnedTables)
|
||||
{
|
||||
@RenderTableOption(table)
|
||||
@@ -56,7 +57,7 @@
|
||||
<h3>Recent</h3>
|
||||
<span class="tables-index-section-count">@recentTables.Count</span>
|
||||
</div>
|
||||
<div class="tables-index-list" role="listbox" aria-label="Recent critical tables">
|
||||
<div class="tables-index-list" aria-label="Recent critical tables">
|
||||
@foreach (var table in recentTables)
|
||||
{
|
||||
@RenderTableOption(table)
|
||||
@@ -77,7 +78,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="tables-index-list" role="listbox" aria-label="Critical tables">
|
||||
<div class="tables-index-list" aria-label="Critical tables">
|
||||
@foreach (var table in filteredTables)
|
||||
{
|
||||
@RenderTableOption(table)
|
||||
@@ -250,6 +251,18 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(args.Key, "Home", StringComparison.Ordinal))
|
||||
{
|
||||
MoveActiveOptionToBoundary(useLast: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(args.Key, "End", StringComparison.Ordinal))
|
||||
{
|
||||
MoveActiveOptionToBoundary(useLast: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(activeOptionSlug))
|
||||
{
|
||||
_ = OnSelectTable.InvokeAsync(activeOptionSlug);
|
||||
@@ -275,6 +288,17 @@
|
||||
activeOptionSlug = keyboardOptions[nextIndex].Key;
|
||||
}
|
||||
|
||||
private void MoveActiveOptionToBoundary(bool useLast)
|
||||
{
|
||||
if (keyboardOptions.Count == 0)
|
||||
{
|
||||
activeOptionSlug = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeOptionSlug = useLast ? keyboardOptions[^1].Key : keyboardOptions[0].Key;
|
||||
}
|
||||
|
||||
private void EnsureActiveOption()
|
||||
{
|
||||
if (keyboardOptions.Count == 0)
|
||||
@@ -326,8 +350,7 @@
|
||||
|
||||
private RenderFragment RenderTableOption(CriticalTableReference table) => @<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected="@string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)"
|
||||
aria-current="@(string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase) ? "true" : null)"
|
||||
class="table-index-option @GetTableOptionCssClass(table)"
|
||||
@onfocus="() => SetActiveOption(table.Key)"
|
||||
@onclick="() => OnSelectTable.InvokeAsync(table.Key)">
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<aside class="tables-inspector" aria-label="Selected result inspector">
|
||||
<TablesInspectorContent SelectedCellDetail="SelectedCellDetail" OnEdit="OnEdit" OnCurate="OnCurate" />
|
||||
</aside>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public CriticalTableCellDetail? SelectedCellDetail { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCurate { get; set; }
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
@if (SelectedCellDetail is null)
|
||||
{
|
||||
<InspectorSection Title="Inspector" Description="Select a result in the table to inspect its details here.">
|
||||
<p class="tables-inspector-empty">Choose a cell to see its roll band, severity, and readable result without leaving the grid.</p>
|
||||
</InspectorSection>
|
||||
}
|
||||
else
|
||||
{
|
||||
var cell = SelectedCellDetail;
|
||||
|
||||
<InspectorSection Title="Selected Result" Description="Read the selected cell and its context without opening a modal.">
|
||||
<div class="tables-inspector-summary">
|
||||
<div>
|
||||
<p class="tables-inspector-kicker">Roll band</p>
|
||||
<strong>@cell.RollBand</strong>
|
||||
</div>
|
||||
<div>
|
||||
<p class="tables-inspector-kicker">Severity</p>
|
||||
<strong>@cell.ColumnLabel</strong>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(cell.GroupLabel))
|
||||
{
|
||||
<div>
|
||||
<p class="tables-inspector-kicker">Variant</p>
|
||||
<strong>@cell.GroupLabel</strong>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<p class="tables-inspector-kicker">Status</p>
|
||||
<StatusChip Tone="@(cell.IsCurated ? "success" : "warning")">
|
||||
@(cell.IsCurated ? "Curated" : "Needs Curation")
|
||||
</StatusChip>
|
||||
</div>
|
||||
</div>
|
||||
</InspectorSection>
|
||||
|
||||
<InspectorSection Title="Result" Description="The selected critical result stays readable while you browse the grid.">
|
||||
<div class="tables-inspector-actions">
|
||||
@if (!cell.IsCurated)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="OnCurate">Open curation</button>
|
||||
}
|
||||
<button type="button" class="btn btn-primary" @onclick="OnEdit">Open editor</button>
|
||||
</div>
|
||||
|
||||
<CompactCriticalCell
|
||||
Description="@(cell.Description ?? string.Empty)"
|
||||
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
|
||||
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
|
||||
</InspectorSection>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public CriticalTableCellDetail? SelectedCellDetail { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCurate { get; set; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
@if (SelectedCellDetail is not null)
|
||||
{
|
||||
<div class="tables-inspector-sheet" role="dialog" aria-modal="true" aria-label="Selected result inspector">
|
||||
<button type="button" class="tables-inspector-sheet-backdrop" @onclick="OnClose"></button>
|
||||
|
||||
<section class="tables-inspector-sheet-panel">
|
||||
<div class="tables-inspector-sheet-handle" aria-hidden="true"></div>
|
||||
<header class="tables-inspector-sheet-header">
|
||||
<div>
|
||||
<p class="tables-page-eyebrow">Selected Result</p>
|
||||
<h2 class="panel-title">Mobile Inspector</h2>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link" @onclick="OnClose">Close</button>
|
||||
</header>
|
||||
|
||||
<div class="tables-inspector-sheet-body">
|
||||
<TablesInspectorContent SelectedCellDetail="SelectedCellDetail" OnEdit="OnEdit" OnCurate="OnCurate" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public CriticalTableCellDetail? SelectedCellDetail { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnClose { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnEdit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCurate { get; set; }
|
||||
}
|
||||
@@ -199,6 +199,10 @@ html, body {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: calc(var(--shell-header-height, 5.75rem) + 1rem);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -262,6 +266,13 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--control-height);
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
.code-block {
|
||||
@@ -577,9 +588,11 @@ select.input-shell {
|
||||
height: var(--control-height);
|
||||
}
|
||||
|
||||
.input-shell:focus {
|
||||
.input-shell:focus,
|
||||
.input-shell:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
border-color: var(--focus-border);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@@ -592,6 +605,7 @@ select.input-shell {
|
||||
.btn-ritual {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
min-height: var(--control-height);
|
||||
padding: 0.8rem 1.15rem;
|
||||
background: linear-gradient(135deg, var(--accent-4), var(--accent-5));
|
||||
color: var(--text-on-accent);
|
||||
@@ -603,9 +617,18 @@ select.input-shell {
|
||||
background: linear-gradient(135deg, #c38a4d, #8f5a2f);
|
||||
}
|
||||
|
||||
.btn-ritual:focus-visible,
|
||||
.roll-button:focus-visible,
|
||||
.play-action-link:focus-visible,
|
||||
.shell-omnibox-close:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.roll-button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 4.5rem;
|
||||
min-height: var(--control-height);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--button-secondary-border);
|
||||
background: var(--button-secondary-bg);
|
||||
@@ -1083,6 +1106,7 @@ select.input-shell {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 999px;
|
||||
@@ -1121,8 +1145,9 @@ select.input-shell {
|
||||
.segmented-tabs-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.25rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
@@ -1130,6 +1155,11 @@ select.input-shell {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.segmented-tabs-button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.segmented-tabs-button.is-selected {
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-strong);
|
||||
@@ -1454,6 +1484,11 @@ select.input-shell {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.tables-index-rail:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.tables-index-rail-header {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
@@ -1487,12 +1522,16 @@ select.input-shell {
|
||||
}
|
||||
|
||||
.tables-family-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.75rem;
|
||||
border: 1px solid rgba(127, 96, 55, 0.2);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 242, 0.86);
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
@@ -1501,7 +1540,8 @@ select.input-shell {
|
||||
border-color: rgba(184, 121, 59, 0.3);
|
||||
background: rgba(255, 244, 228, 0.94);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tables-family-filter.is-selected {
|
||||
@@ -1570,7 +1610,8 @@ select.input-shell {
|
||||
.table-index-option:focus-visible {
|
||||
border-color: rgba(184, 121, 59, 0.28);
|
||||
background: rgba(255, 247, 235, 0.94);
|
||||
outline: none;
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.table-index-option.is-selected {
|
||||
@@ -1649,46 +1690,6 @@ select.input-shell {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.tables-reference-inspector-shell {
|
||||
position: sticky;
|
||||
top: calc(var(--shell-header-height) + 1rem);
|
||||
}
|
||||
|
||||
.tables-inspector {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tables-inspector-sheet {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tables-inspector-empty {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.tables-inspector-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tables-inspector-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.tables-inspector-kicker {
|
||||
margin: 0 0 0.2rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -1777,6 +1778,10 @@ select.input-shell {
|
||||
}
|
||||
|
||||
.tables-context-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.75rem;
|
||||
border: 1px solid rgba(127, 96, 55, 0.2);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 246, 233, 0.88);
|
||||
@@ -1790,7 +1795,8 @@ select.input-shell {
|
||||
.tables-context-filter-chip:focus-visible {
|
||||
border-color: rgba(184, 121, 59, 0.34);
|
||||
background: rgba(255, 239, 214, 0.96);
|
||||
outline: none;
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.table-browser-reading-hint {
|
||||
@@ -1810,62 +1816,11 @@ select.input-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tables-reference-inspector-shell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tables-selection-menu {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tables-inspector-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
align-items: end;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.tables-inspector-sheet-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: rgba(17, 18, 19, 0.38);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tables-inspector-sheet-panel {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
max-height: min(78vh, 42rem);
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
border-radius: 24px 24px 0 0;
|
||||
background: var(--surface-card-strong);
|
||||
border: 1px solid rgba(127, 96, 55, 0.18);
|
||||
box-shadow: 0 -12px 32px rgba(18, 14, 9, 0.18);
|
||||
}
|
||||
|
||||
.tables-inspector-sheet-handle {
|
||||
width: 3.2rem;
|
||||
height: 0.32rem;
|
||||
margin: 0 auto;
|
||||
border-radius: 999px;
|
||||
background: rgba(127, 96, 55, 0.22);
|
||||
}
|
||||
|
||||
.tables-inspector-sheet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tables-inspector-sheet-body {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -1988,7 +1943,7 @@ select.input-shell {
|
||||
}
|
||||
|
||||
.critical-table-cell:focus-visible {
|
||||
outline: 2px solid rgba(13, 148, 136, 0.45);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using RolemasterDb.App.Frontend.AppState;
|
||||
|
||||
namespace RolemasterDb.ImportTool.Tests;
|
||||
|
||||
public sealed class TableContextUrlSerializerTests
|
||||
{
|
||||
private readonly TableContextUrlSerializer serializer = new();
|
||||
|
||||
[Fact]
|
||||
public void Build_relative_uri_round_trips_all_supported_context_fields()
|
||||
{
|
||||
var snapshot = new TableContextSnapshot(TableSlug: "void", GroupKey: "alpha", ColumnKey: "E", RollBand: "16-20", RollJump: 18, ResultId: 11617, Mode: TableContextMode.Curation, QueueScope: "pinned");
|
||||
|
||||
var uri = serializer.BuildRelativeUri("/curation", snapshot);
|
||||
var roundTripped = serializer.Parse(new Uri(new Uri("https://example.test"), uri).AbsoluteUri);
|
||||
|
||||
Assert.Equal("/curation?table=void&group=alpha&column=E&rollBand=16-20&roll=18&result=11617&mode=curation&scope=pinned", uri);
|
||||
Assert.Equal(snapshot, roundTripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_relative_uri_omits_empty_context_values()
|
||||
{
|
||||
var snapshot = new TableContextSnapshot(TableSlug: "slash", GroupKey: "", ColumnKey: null, RollBand: "01-05", RollJump: null, ResultId: null, Mode: TableContextMode.Reference, QueueScope: " ");
|
||||
|
||||
var uri = serializer.BuildRelativeUri("/tables", snapshot);
|
||||
|
||||
Assert.Equal("/tables?table=slash&rollBand=01-05&mode=reference", uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_treats_unknown_mode_as_null_without_losing_other_context()
|
||||
{
|
||||
var snapshot = serializer.Parse("https://example.test/tools/diagnostics?table=arcane-nether&column=C&rollBand=06-10&result=9748&mode=unknown&scope=selected-table");
|
||||
|
||||
Assert.Equal("arcane-nether", snapshot.TableSlug);
|
||||
Assert.Equal("C", snapshot.ColumnKey);
|
||||
Assert.Equal("06-10", snapshot.RollBand);
|
||||
Assert.Equal(9748, snapshot.ResultId);
|
||||
Assert.Null(snapshot.Mode);
|
||||
Assert.Equal("selected-table", snapshot.QueueScope);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user