Harden shell and tables interactions

This commit is contained in:
2026-04-12 22:38:18 +02:00
parent 222abc155b
commit 0dd1f42fac
18 changed files with 299 additions and 664 deletions

View File

@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
- Branch: `frontend/tables-overhaul` - Branch: `frontend/tables-overhaul`
- Last updated: `2026-04-12` - Last updated: `2026-04-12`
- Current focus: `Phase 7` - Current focus: `Post-Phase 7 review`
- Document mode: living plan and progress log - Document mode: living plan and progress log
### 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 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 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 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 ### 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 | | 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 | | 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 | | 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` | | 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 | | 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 | | 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 | | 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 | | 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 ### 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 | | 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 | | 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 | | 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 | | 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 | | 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 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 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 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. - 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 ### 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`. - 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. - `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. - 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 ## 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 ### Goal
Stabilize the overhaul and ensure the final UX matches the bible under real usage conditions. Stabilize the overhaul and ensure the final UX matches the bible under real usage conditions.

View File

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

View File

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

View File

@@ -2,14 +2,17 @@
@foreach (var item in Items) @foreach (var item in Items)
{ {
var isSelected = string.Equals(item.Value, SelectedValue, StringComparison.Ordinal); var isSelected = string.Equals(item.Value, SelectedValue, StringComparison.Ordinal);
var tabIndex = isSelected || (!HasSelectedValue && string.Equals(item.Value, FirstEnabledValue, StringComparison.Ordinal)) ? 0 : -1;
<button <button
type="button" type="button"
class="segmented-tabs-button @(isSelected ? "is-selected" : null)" class="segmented-tabs-button @(isSelected ? "is-selected" : null)"
role="tab" role="tab"
aria-selected="@isSelected" aria-selected="@isSelected"
tabindex="@tabIndex"
disabled="@item.IsDisabled" disabled="@item.IsDisabled"
@onclick="() => SelectAsync(item.Value)"> @onclick="() => SelectAsync(item.Value)"
@onkeydown="args => HandleKeyDownAsync(args, item.Value)">
<span>@item.Label</span> <span>@item.Label</span>
@if (!string.IsNullOrWhiteSpace(item.Badge)) @if (!string.IsNullOrWhiteSpace(item.Badge))
{ {
@@ -20,6 +23,7 @@
</div> </div>
@code { @code {
[Parameter] [Parameter]
public IReadOnlyList<SegmentedTabItem> Items { get; set; } = []; public IReadOnlyList<SegmentedTabItem> Items { get; set; } = [];
@@ -35,11 +39,47 @@
[Parameter] [Parameter]
public string? CssClass { get; set; } 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() => private string BuildCssClass() =>
string.IsNullOrWhiteSpace(CssClass) string.IsNullOrWhiteSpace(CssClass) ? "segmented-tabs" : $"segmented-tabs {CssClass}";
? "segmented-tabs"
: $"segmented-tabs {CssClass}";
private Task SelectAsync(string value) => private Task SelectAsync(string value) =>
SelectedValueChanged.InvokeAsync(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);
}
}

View File

@@ -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();
}

View File

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

View File

@@ -26,7 +26,7 @@
<nav class="app-shell-header-nav" aria-label="Primary"> <nav class="app-shell-header-nav" aria-label="Primary">
@if (PrimaryNavContent is null) @if (PrimaryNavContent is null)
{ {
<ShellPrimaryNav /> <ShellPrimaryNav/>
} }
else else
{ {
@@ -45,6 +45,7 @@
<button <button
type="button" type="button"
class="app-shell-menu-toggle" class="app-shell-menu-toggle"
aria-haspopup="dialog"
aria-expanded="@isNavMenuOpen" aria-expanded="@isNavMenuOpen"
aria-controls="app-shell-drawer" aria-controls="app-shell-drawer"
@onclick="ToggleNavMenu"> @onclick="ToggleNavMenu">
@@ -65,7 +66,7 @@
} }
</header> </header>
<ShellOmniboxPalette /> <ShellOmniboxPalette/>
<main id="app-main" class="app-shell-main"> <main id="app-main" class="app-shell-main">
<div class="content-shell"> <div class="content-shell">
@@ -79,26 +80,28 @@
type="button" type="button"
class="app-shell-drawer-backdrop" class="app-shell-drawer-backdrop"
aria-label="Close navigation" 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"> <div class="app-shell-drawer-header">
<strong>Navigate</strong> <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 Close
</button> </button>
</div> </div>
<ShellPrimaryNav /> <ShellPrimaryNav/>
</aside> </aside>
} }
<nav class="app-shell-mobile-nav" aria-label="Primary"> <nav class="app-shell-mobile-nav" aria-label="Primary">
<ShellPrimaryNav IsBottomNav="true" /> <ShellPrimaryNav IsBottomNav="true"/>
</nav> </nav>
</div> </div>
@code { @code {
[Parameter] [Parameter]
public RenderFragment? ChildContent { get; set; } public RenderFragment? ChildContent { get; set; }
@@ -118,20 +121,35 @@
public RenderFragment? UtilityContent { get; set; } public RenderFragment? UtilityContent { get; set; }
private bool isNavMenuOpen; private bool isNavMenuOpen;
private ElementReference navMenuCloseButton;
private bool shouldFocusNavMenuCloseButton;
protected override void OnInitialized() protected override void OnInitialized()
{ {
NavigationManager.LocationChanged += HandleLocationChanged; NavigationManager.LocationChanged += HandleLocationChanged;
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!shouldFocusNavMenuCloseButton || !isNavMenuOpen)
{
return;
}
shouldFocusNavMenuCloseButton = false;
await navMenuCloseButton.FocusAsync();
}
private void ToggleNavMenu() private void ToggleNavMenu()
{ {
isNavMenuOpen = !isNavMenuOpen; isNavMenuOpen = !isNavMenuOpen;
shouldFocusNavMenuCloseButton = isNavMenuOpen;
} }
private void CloseNavMenu() private void CloseNavMenu()
{ {
isNavMenuOpen = false; isNavMenuOpen = false;
shouldFocusNavMenuCloseButton = false;
} }
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args) private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
@@ -144,4 +162,15 @@
{ {
NavigationManager.LocationChanged -= HandleLocationChanged; NavigationManager.LocationChanged -= HandleLocationChanged;
} }
}
private Task HandleNavMenuKeyDownAsync(KeyboardEventArgs args)
{
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
{
CloseNavMenu();
}
return Task.CompletedTask;
}
}

View File

@@ -24,6 +24,11 @@
top: 1rem; top: 1rem;
} }
.skip-link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.app-shell-header { .app-shell-header {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -95,6 +100,7 @@
.app-shell-header-omnibox { .app-shell-header-omnibox {
min-width: 0; min-width: 0;
overflow: hidden;
} }
.app-shell-header-actions { .app-shell-header-actions {
@@ -127,6 +133,12 @@
background: currentColor; 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 { .app-shell-shortcuts {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
@@ -135,6 +147,7 @@
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
padding: 1rem 0 5.75rem; padding: 1rem 0 5.75rem;
scroll-margin-top: calc(var(--shell-header-height, 5.75rem) + 1rem);
} }
.content-shell { .content-shell {
@@ -197,7 +210,7 @@
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, var(--surface-2) 84%, transparent); background: color-mix(in srgb, var(--surface-2) 84%, transparent);
color: var(--text-primary); color: var(--text-primary);
min-height: 2.5rem; min-height: 2.75rem;
padding: 0.45rem 0.85rem; padding: 0.45rem 0.85rem;
} }
@@ -212,7 +225,7 @@
} }
.app-shell-bar { .app-shell-bar {
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 0.75rem; gap: 0.75rem;
padding: 0.7rem 0.85rem; padding: 0.7rem 0.85rem;
} }

View File

@@ -14,10 +14,12 @@
type="button" type="button"
class="shell-omnibox-backdrop" class="shell-omnibox-backdrop"
aria-label="Close search panel" 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"> <header class="shell-omnibox-header">
<h2 id="shell-omnibox-title" class="visually-hidden">Search tables and commands</h2>
<label class="shell-omnibox-search"> <label class="shell-omnibox-search">
<span class="visually-hidden">Search tables or commands</span> <span class="visually-hidden">Search tables or commands</span>
<input <input
@@ -26,7 +28,7 @@
placeholder="Search tables or type /" placeholder="Search tables or type /"
@bind="query" @bind="query"
@bind:event="oninput" @bind:event="oninput"
@onkeydown="HandleInputKeyDownAsync" /> @onkeydown="HandleInputKeyDownAsync"/>
</label> </label>
<button type="button" class="shell-omnibox-close" @onclick="CloseAsync"> <button type="button" class="shell-omnibox-close" @onclick="CloseAsync">
@@ -139,6 +141,7 @@
} }
@code { @code {
private static readonly IReadOnlyList<ShellOmniboxCommand> Commands = private static readonly IReadOnlyList<ShellOmniboxCommand> Commands =
[ [
new("/tables", "Reference", "Open the reference tables surface.", "/tables"), new("/tables", "Reference", "Open the reference tables surface.", "/tables"),
@@ -155,37 +158,19 @@
private string query = string.Empty; private string query = string.Empty;
private bool HasResults => private bool HasResults =>
MatchingCommands.Count > 0 || MatchingCommands.Count > 0 || MatchingPinned.Count > 0 || MatchingRecent.Count > 0 || MatchingTables.Count > 0;
MatchingPinned.Count > 0 ||
MatchingRecent.Count > 0 ||
MatchingTables.Count > 0;
private IReadOnlyList<CriticalTableReference> MatchingTables => private IReadOnlyList<CriticalTableReference> MatchingTables =>
referenceData?.CriticalTables referenceData?.CriticalTables.Where(MatchesTableQuery).Take(8).ToList() ?? [];
.Where(MatchesTableQuery)
.Take(8)
.ToList()
?? [];
private IReadOnlyList<PinnedTableEntry> MatchingPinned => private IReadOnlyList<PinnedTableEntry> MatchingPinned =>
PinnedTablesState.Items PinnedTablesState.Items.Where(item => MatchesText(item.Label, item.Family, item.Slug)).Take(6).ToList();
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
.Take(6)
.ToList();
private IReadOnlyList<RecentTableEntry> MatchingRecent => private IReadOnlyList<RecentTableEntry> MatchingRecent =>
RecentTablesState.Items RecentTablesState.Items.Where(item => MatchesText(item.Label, item.Family, item.Slug)).Take(6).ToList();
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
.Take(6)
.ToList();
private IReadOnlyList<ShellOmniboxCommand> MatchingCommands => private IReadOnlyList<ShellOmniboxCommand> MatchingCommands =>
Commands Commands.Where(command => QueryStartsCommandMode() ? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase) : MatchesText(command.Shortcut, command.Label, command.Description)).Take(5).ToList();
.Where(command => QueryStartsCommandMode()
? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase)
: MatchesText(command.Shortcut, command.Label, command.Description))
.Take(5)
.ToList();
protected override void OnInitialized() 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) private async Task OpenTableAsync(string tableSlug)
{ {
var snapshot = new TableContextSnapshot( var snapshot = new TableContextSnapshot(TableSlug: tableSlug, Mode: TableContextMode.Reference);
TableSlug: tableSlug,
Mode: TableContextMode.Reference);
await TableContextState.PersistAsync("tables", snapshot); await TableContextState.PersistAsync("tables", snapshot);
ShellOmniboxState.Close(); ShellOmniboxState.Close();
@@ -303,11 +289,10 @@
return true; return true;
} }
return values.Any(value => return values.Any(value => !string.IsNullOrWhiteSpace(value) && value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase));
!string.IsNullOrWhiteSpace(value) &&
value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase));
} }
private bool QueryStartsCommandMode() => private bool QueryStartsCommandMode() =>
query.TrimStart().StartsWith("/", StringComparison.Ordinal); query.TrimStart().StartsWith("/", StringComparison.Ordinal);
}
}

View File

@@ -3,6 +3,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
min-width: 0;
max-width: 100%;
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.65rem 0.9rem; padding: 0.65rem 0.9rem;
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
@@ -13,6 +15,9 @@
} }
.shell-omnibox-trigger-label { .shell-omnibox-trigger-label {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

View File

@@ -24,6 +24,11 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.shell-primary-nav-link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.shell-primary-nav-link.active { .shell-primary-nav-link.active {
color: var(--text-primary); color: var(--text-primary);
background: color-mix(in srgb, var(--accent-1) 84%, var(--surface-2)); background: color-mix(in srgb, var(--accent-1) 84%, var(--surface-2));

View File

@@ -42,7 +42,7 @@
@onkeydown="args => HandleCellKeyDown(args, cell)"> @onkeydown="args => HandleCellKeyDown(args, cell)">
<div class="critical-table-cell-shell"> <div class="critical-table-cell-shell">
<div class="critical-table-cell-actions"> <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"/> <StatusIndicator Tone="@(cell.IsCurated ? "success" : "warning")" CssClass="tables-cell-status-indicator"/>
} }
@@ -92,6 +92,11 @@
private readonly List<CriticalGroupReference> visibleGroups = new(); private readonly List<CriticalGroupReference> visibleGroups = new();
private readonly List<CriticalColumnReference> visibleColumns = new(); private readonly List<CriticalColumnReference> visibleColumns = new();
private string gridTemplateStyle = string.Empty; private string gridTemplateStyle = string.Empty;
private bool isReferenceMode;
private string? activeRollBand;
private string? activeColumnKey;
private string? activeGroupKey;
private string? rollJumpBandLabel;
[Parameter, EditorRequired] [Parameter, EditorRequired]
public CriticalTableDetail Detail { get; set; } = default!; public CriticalTableDetail Detail { get; set; } = default!;
@@ -123,6 +128,11 @@
displayColumns.Clear(); displayColumns.Clear();
visibleGroups.Clear(); visibleGroups.Clear();
visibleColumns.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) foreach (var cell in Detail.Cells)
{ {
@@ -197,15 +207,6 @@
_ => true _ => 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() private string BuildGridCssClass()
{ {
var classes = new List<string> var classes = new List<string>
@@ -224,7 +225,7 @@
"critical-table-grid-header-cell", "critical-table-grid-header-cell",
"critical-table-grid-group-header" "critical-table-grid-group-header"
}; };
if (string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase)) if (string.Equals(groupKey, activeGroupKey, StringComparison.OrdinalIgnoreCase))
{ {
classes.Add("is-active-group"); classes.Add("is-active-group");
} }
@@ -240,12 +241,12 @@
"critical-table-grid-column-header" "critical-table-grid-column-header"
}; };
if (string.Equals(columnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase)) if (string.Equals(columnKey, activeColumnKey, StringComparison.OrdinalIgnoreCase))
{ {
classes.Add("is-active-column"); 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"); classes.Add("is-active-group");
} }
@@ -260,12 +261,12 @@
"critical-table-grid-header-cell", "critical-table-grid-header-cell",
"critical-table-grid-roll-band" "critical-table-grid-roll-band"
}; };
if (string.Equals(rollBandLabel, ActiveRollBand, StringComparison.OrdinalIgnoreCase)) if (string.Equals(rollBandLabel, activeRollBand, StringComparison.OrdinalIgnoreCase))
{ {
classes.Add("is-active-row"); classes.Add("is-active-row");
} }
if (string.Equals(rollBandLabel, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase)) if (string.Equals(rollBandLabel, rollJumpBandLabel, StringComparison.OrdinalIgnoreCase))
{ {
classes.Add("is-roll-target"); classes.Add("is-roll-target");
} }
@@ -281,17 +282,17 @@
cell.IsCurated ? "is-curated" : "needs-curation" 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"); 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"); 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"); classes.Add("is-active-group");
} }
@@ -301,7 +302,7 @@
classes.Add("is-selected-cell"); 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"); classes.Add("is-roll-target");
} }

View File

@@ -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"> <div class="tables-index-rail-header">
<h2 id="tables-index-heading" class="tables-index-title">Table Index</h2> <h2 id="tables-index-heading" class="tables-index-title">Table Index</h2>
</div> </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> <label class="tables-index-search-label" for="tables-index-search">Search tables</label>
<input <input
id="tables-index-search" id="tables-index-search"
@@ -11,7 +11,8 @@
type="search" type="search"
placeholder="Search tables" placeholder="Search tables"
value="@searchText" value="@searchText"
@oninput="HandleSearchInput"/> @oninput="HandleSearchInput"
@onkeydown:stopPropagation="true"/>
@if (familyFilters.Count > 1) @if (familyFilters.Count > 1)
{ {
@@ -40,7 +41,7 @@
<h3>Pinned</h3> <h3>Pinned</h3>
<span class="tables-index-section-count">@pinnedTables.Count</span> <span class="tables-index-section-count">@pinnedTables.Count</span>
</div> </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) @foreach (var table in pinnedTables)
{ {
@RenderTableOption(table) @RenderTableOption(table)
@@ -56,7 +57,7 @@
<h3>Recent</h3> <h3>Recent</h3>
<span class="tables-index-section-count">@recentTables.Count</span> <span class="tables-index-section-count">@recentTables.Count</span>
</div> </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) @foreach (var table in recentTables)
{ {
@RenderTableOption(table) @RenderTableOption(table)
@@ -77,7 +78,7 @@
} }
else 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) @foreach (var table in filteredTables)
{ {
@RenderTableOption(table) @RenderTableOption(table)
@@ -250,6 +251,18 @@
return; 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)) if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(activeOptionSlug))
{ {
_ = OnSelectTable.InvokeAsync(activeOptionSlug); _ = OnSelectTable.InvokeAsync(activeOptionSlug);
@@ -275,6 +288,17 @@
activeOptionSlug = keyboardOptions[nextIndex].Key; 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() private void EnsureActiveOption()
{ {
if (keyboardOptions.Count == 0) if (keyboardOptions.Count == 0)
@@ -326,8 +350,7 @@
private RenderFragment RenderTableOption(CriticalTableReference table) => @<button private RenderFragment RenderTableOption(CriticalTableReference table) => @<button
type="button" type="button"
role="option" aria-current="@(string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase) ? "true" : null)"
aria-selected="@string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)"
class="table-index-option @GetTableOptionCssClass(table)" class="table-index-option @GetTableOptionCssClass(table)"
@onfocus="() => SetActiveOption(table.Key)" @onfocus="() => SetActiveOption(table.Key)"
@onclick="() => OnSelectTable.InvokeAsync(table.Key)"> @onclick="() => OnSelectTable.InvokeAsync(table.Key)">

View File

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

View File

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

View File

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

View File

@@ -199,6 +199,10 @@ html, body {
font-weight: 400; font-weight: 400;
} }
html {
scroll-padding-top: calc(var(--shell-header-height, 5.75rem) + 1rem);
}
body { body {
margin: 0; margin: 0;
} }
@@ -262,6 +266,13 @@ textarea {
font: inherit; font: inherit;
} }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--control-height);
}
code, code,
pre, pre,
.code-block { .code-block {
@@ -577,9 +588,11 @@ select.input-shell {
height: var(--control-height); height: var(--control-height);
} }
.input-shell:focus { .input-shell:focus,
.input-shell:focus-visible {
outline: 2px solid var(--focus-ring); outline: 2px solid var(--focus-ring);
border-color: var(--focus-border); border-color: var(--focus-border);
outline-offset: 1px;
} }
.action-row { .action-row {
@@ -592,6 +605,7 @@ select.input-shell {
.btn-ritual { .btn-ritual {
border: none; border: none;
border-radius: 999px; border-radius: 999px;
min-height: var(--control-height);
padding: 0.8rem 1.15rem; padding: 0.8rem 1.15rem;
background: linear-gradient(135deg, var(--accent-4), var(--accent-5)); background: linear-gradient(135deg, var(--accent-4), var(--accent-5));
color: var(--text-on-accent); color: var(--text-on-accent);
@@ -603,9 +617,18 @@ select.input-shell {
background: linear-gradient(135deg, #c38a4d, #8f5a2f); 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 { .roll-button {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 4.5rem; min-width: 4.5rem;
min-height: var(--control-height);
border-radius: 14px; border-radius: 14px;
border: 1px solid var(--button-secondary-border); border: 1px solid var(--button-secondary-border);
background: var(--button-secondary-bg); background: var(--button-secondary-bg);
@@ -1083,6 +1106,7 @@ select.input-shell {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.55rem; gap: 0.55rem;
min-width: 0;
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.7rem 1rem; padding: 0.7rem 1rem;
border-radius: 999px; border-radius: 999px;
@@ -1121,8 +1145,9 @@ select.input-shell {
.segmented-tabs-button { .segmented-tabs-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.45rem; gap: 0.45rem;
min-height: 2.25rem; min-height: 2.75rem;
padding: 0.4rem 0.85rem; padding: 0.4rem 0.85rem;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
@@ -1130,6 +1155,11 @@ select.input-shell {
color: var(--text-muted); color: var(--text-muted);
} }
.segmented-tabs-button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.segmented-tabs-button.is-selected { .segmented-tabs-button.is-selected {
background: var(--surface-elevated); background: var(--surface-elevated);
color: var(--text-strong); color: var(--text-strong);
@@ -1454,6 +1484,11 @@ select.input-shell {
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.tables-index-rail:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 3px;
}
.tables-index-rail-header { .tables-index-rail-header {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
@@ -1487,12 +1522,16 @@ select.input-shell {
} }
.tables-family-filter { .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: 1px solid rgba(127, 96, 55, 0.2);
border-radius: 999px; border-radius: 999px;
background: rgba(255, 250, 242, 0.86); background: rgba(255, 250, 242, 0.86);
color: var(--ink-soft); color: var(--ink-soft);
font-size: 0.8rem; 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; 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); border-color: rgba(184, 121, 59, 0.3);
background: rgba(255, 244, 228, 0.94); background: rgba(255, 244, 228, 0.94);
color: var(--ink); color: var(--ink);
outline: none; outline: 2px solid var(--focus-ring);
outline-offset: 2px;
} }
.tables-family-filter.is-selected { .tables-family-filter.is-selected {
@@ -1570,7 +1610,8 @@ select.input-shell {
.table-index-option:focus-visible { .table-index-option:focus-visible {
border-color: rgba(184, 121, 59, 0.28); border-color: rgba(184, 121, 59, 0.28);
background: rgba(255, 247, 235, 0.94); background: rgba(255, 247, 235, 0.94);
outline: none; outline: 2px solid var(--focus-ring);
outline-offset: 2px;
} }
.table-index-option.is-selected { .table-index-option.is-selected {
@@ -1649,46 +1690,6 @@ select.input-shell {
backdrop-filter: blur(12px); 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 { .table-shell {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
@@ -1777,6 +1778,10 @@ select.input-shell {
} }
.tables-context-filter-chip { .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: 1px solid rgba(127, 96, 55, 0.2);
border-radius: 999px; border-radius: 999px;
background: rgba(255, 246, 233, 0.88); background: rgba(255, 246, 233, 0.88);
@@ -1790,7 +1795,8 @@ select.input-shell {
.tables-context-filter-chip:focus-visible { .tables-context-filter-chip:focus-visible {
border-color: rgba(184, 121, 59, 0.34); border-color: rgba(184, 121, 59, 0.34);
background: rgba(255, 239, 214, 0.96); background: rgba(255, 239, 214, 0.96);
outline: none; outline: 2px solid var(--focus-ring);
outline-offset: 2px;
} }
.table-browser-reading-hint { .table-browser-reading-hint {
@@ -1810,62 +1816,11 @@ select.input-shell {
display: block; display: block;
} }
.tables-reference-inspector-shell {
display: none;
}
.tables-selection-menu { .tables-selection-menu {
right: 0.75rem; right: 0.75rem;
left: 0.75rem; left: 0.75rem;
justify-content: flex-end; 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) { @media (min-width: 1024px) {
@@ -1988,7 +1943,7 @@ select.input-shell {
} }
.critical-table-cell:focus-visible { .critical-table-cell:focus-visible {
outline: 2px solid rgba(13, 148, 136, 0.45); outline: 2px solid var(--focus-ring);
outline-offset: -2px; outline-offset: -2px;
} }

View File

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