From 4134d84b9dbda443f9ae2f2cd93c0c18911d1d09 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 21 Mar 2026 14:12:43 +0100 Subject: [PATCH] Add shell omnibox foundation --- ...s_frontend_overhaul_implementation_plan.md | 4 +- .../Components/Shell/ShellOmniboxCommand.cs | 7 + .../Shell/ShellOmniboxTrigger.razor | 241 +++++++++++++++++- src/RolemasterDb.App/wwwroot/app.css | 72 ++++++ 4 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 src/RolemasterDb.App/Components/Shell/ShellOmniboxCommand.cs diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 79fdbb5..d5275ca 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -55,6 +55,7 @@ It is intentionally implementation-focused: | 2026-03-21 | P2.3 | Completed | Added a shared `RecentTablesState` service backed by browser storage, centralized the recents storage key, and started recording successful table visits from the `Tables` page so later omnibox and rail work has real shared data. | | 2026-03-21 | P2.4 | Completed | Added a shared `PinnedTablesState` service with browser persistence, centralized the pin storage key, initialized pin state in the `Tables` page, and added a first live pin/unpin action plus pinned status chips so later omnibox and navigation work have real saved pins to consume. | | 2026-03-21 | P2.5 | Completed | Added shared table-context URL types and a serializer, registered the serializer centrally, and started round-tripping table context through `/tables` and `/tools/diagnostics` so direct links restore the selected table and diagnostic cell context instead of relying only on local storage defaults. | +| 2026-03-21 | P2.6 | Completed | Replaced the dead shell omnibox trigger with a live drawer-backed omnibox foundation that loads critical tables on demand, filters table search results, surfaces pinned and recent table sections from shared state, and exposes slash-command navigation for the core destinations and tooling routes. | | 2026-03-21 | P2.7 | Completed | Added shared frontend primitives for app-bar actions, chips, segmented tabs, drawers, inspector sections, and status indicators, wired the shell omnibox trigger onto the new app-bar action primitive, and switched the new pinned-table labels in `Tables` to the shared status-chip primitive so later page work can build on reusable building blocks instead of fresh ad hoc markup. | | 2026-03-21 | P2.8 | Completed | Added a shared `TableContextState` service on top of browser storage and the URL serializer, moved the `Tables` page off page-local table selection persistence, and switched diagnostics to the same restore/persist/build-URI flow so table context logic now lives in shared frontend state instead of being reinvented per page. | @@ -83,6 +84,7 @@ It is intentionally implementation-focused: - URL serializers only pay off when wired into real pages. Using the shared serializer in both `Tables` and diagnostics now gives the project one verified round-trip path before the larger table-context service lands. - The serializer alone is not the reusable boundary. The maintainable seam is a shared context-state helper that owns restore, persist, normalization, and URI-building conventions while pages keep only workflow-specific selection logic. - Primitive extraction lands best when one or two live consumers adopt the new components immediately. That keeps the foundation honest without forcing a broad page rewrite just to validate the abstraction. +- The omnibox foundation does not need the full final interaction model to be useful. A drawer with real table search, real pinned/recent data, and a small slash-command set is enough to validate the shell surface before Phase 3 builds deeper index and inspector flows on top of it. ## Target Outcomes @@ -375,7 +377,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest | `P2.3` | Completed | Recent critical-table visits now persist through a shared app-state service and are recorded from the `Tables` page. | | `P2.4` | Completed | Pinned tables now persist through a shared app-state service and can already be toggled from the `Tables` page. | | `P2.5` | Completed | Shared table-context URLs now parse and serialize through one helper and are consumed by `/tables` and `/tools/diagnostics`. | -| `P2.6` | Pending | The shell omnibox is still a placeholder trigger. | +| `P2.6` | Completed | The shell omnibox now opens a live drawer with table search, pinned tables, recent tables, and slash-command navigation. | | `P2.7` | Completed | Shared primitives for chips, tabs, drawers, inspector sections, app-bar actions, and status indicators now exist in reusable components. | | `P2.8` | Completed | Table-context restore, persistence, normalization, and URI building now flow through one shared state service used by `Tables` and diagnostics. | diff --git a/src/RolemasterDb.App/Components/Shell/ShellOmniboxCommand.cs b/src/RolemasterDb.App/Components/Shell/ShellOmniboxCommand.cs new file mode 100644 index 0000000..c937f68 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shell/ShellOmniboxCommand.cs @@ -0,0 +1,7 @@ +namespace RolemasterDb.App.Components.Shell; + +public sealed record ShellOmniboxCommand( + string Shortcut, + string Label, + string Description, + string Href); diff --git a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor index 918c808..886820a 100644 --- a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor +++ b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor @@ -1,3 +1,242 @@ - +@using System.Linq +@using RolemasterDb.App.Frontend.AppState +@inject NavigationManager NavigationManager +@inject LookupService LookupService +@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState +@inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState +@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState + + Search tables or commands + + +
+ + + @if (isLoading) + { +

Loading searchable tables…

+ } + else + { + @if (MatchingTables.Count > 0) + { + +
+ @foreach (var table in MatchingTables) + { + + } +
+
+ } + + @if (MatchingPinned.Count > 0) + { + +
+ @foreach (var table in MatchingPinned) + { + + } +
+
+ } + + @if (MatchingRecent.Count > 0) + { + +
+ @foreach (var table in MatchingRecent) + { + + } +
+
+ } + + @if (MatchingCommands.Count > 0) + { + +
+ @foreach (var command in MatchingCommands) + { + + } +
+
+ } + + @if (MatchingTables.Count == 0 && MatchingPinned.Count == 0 && MatchingRecent.Count == 0 && MatchingCommands.Count == 0) + { + + Nothing matches “@query”. + + } + } +
+
+ +@code { + private static readonly IReadOnlyList Commands = + [ + new("/tables", "Reference", "Open the reference tables surface.", "/tables"), + new("/curation", "Curation", "Open the queue-first curation workflow.", "/curation"), + new("/tools", "Tools", "Open the developer tools hub.", "/tools"), + new("/diag", "Diagnostics", "Open tooling diagnostics.", "/tools/diagnostics"), + new("/api", "API", "Open API surface documentation.", "/tools/api") + ]; + + private LookupReferenceData? referenceData; + private bool isOpen; + private bool isLoading; + private string query = string.Empty; + + private IReadOnlyList MatchingTables => + referenceData?.CriticalTables + .Where(MatchesTableQuery) + .Take(8) + .ToList() + ?? []; + + private IReadOnlyList MatchingPinned => + PinnedTablesState.Items + .Where(item => MatchesText(item.Label, item.Family, item.Slug)) + .Take(6) + .ToList(); + + private IReadOnlyList MatchingRecent => + RecentTablesState.Items + .Where(item => MatchesText(item.Label, item.Family, item.Slug)) + .Take(6) + .ToList(); + + private IReadOnlyList MatchingCommands => + Commands + .Where(command => MatchesText(command.Shortcut, command.Label, command.Description)) + .Take(5) + .ToList(); + + private async Task ToggleOpenAsync(MouseEventArgs _) + { + if (isOpen) + { + await CloseAsync(); + return; + } + + isOpen = true; + await EnsureLoadedAsync(); + } + + private Task CloseAsync() + { + isOpen = false; + query = string.Empty; + return Task.CompletedTask; + } + + private async Task EnsureLoadedAsync() + { + if (referenceData is not null) + { + return; + } + + isLoading = true; + + try + { + await RecentTablesState.InitializeAsync(); + await PinnedTablesState.InitializeAsync(); + referenceData = await LookupService.GetReferenceDataAsync(); + } + finally + { + isLoading = false; + } + } + + private async Task OpenTableAsync(string tableSlug) + { + var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot( + TableSlug: tableSlug, + Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference); + + await TableContextState.PersistAsync("tables", snapshot); + await CloseAsync(); + NavigationManager.NavigateTo(TableContextState.BuildUri("/tables", snapshot)); + } + + private async Task OpenCommandAsync(string href) + { + await CloseAsync(); + NavigationManager.NavigateTo(href); + } + + private bool MatchesTableQuery(CriticalTableReference table) => + MatchesText(table.Label, table.Key, table.Family, table.SourceDocument); + + private bool MatchesText(params string?[] values) + { + var normalizedQuery = query.Trim(); + if (string.IsNullOrWhiteSpace(normalizedQuery)) + { + return true; + } + + return values.Any(value => + !string.IsNullOrWhiteSpace(value) && + value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index 2aa0794..7dfe33f 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -820,6 +820,12 @@ pre, background: var(--surface-elevated); } +.app-bar-action-button:focus-visible, +.shell-omnibox-result:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + .app-bar-action-button:disabled { opacity: 0.78; cursor: not-allowed; @@ -949,6 +955,64 @@ pre, overflow: auto; } +.shell-omnibox-drawer.surface-drawer.is-end { + width: min(34rem, calc(100vw - 2rem)); +} + +.shell-omnibox-panel { + display: grid; + gap: 1rem; +} + +.shell-omnibox-search { + display: block; +} + +.shell-omnibox-input { + width: 100%; + min-height: 3rem; +} + +.shell-omnibox-results { + display: grid; + gap: 0.6rem; +} + +.shell-omnibox-result { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.85rem 0.95rem; + border: 1px solid var(--border-subtle); + border-radius: 1rem; + background: color-mix(in srgb, var(--surface-elevated) 86%, transparent); + text-align: left; +} + +.shell-omnibox-result:hover { + border-color: var(--border-strong); + background: var(--surface-elevated); +} + +.shell-omnibox-result-main { + display: grid; + gap: 0.2rem; +} + +.shell-omnibox-result-main span { + color: var(--text-muted); +} + +.shell-omnibox-result-meta { + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + justify-content: end; +} + .details-block { margin-top: 0.85rem; } @@ -1991,4 +2055,12 @@ pre, flex-direction: column; align-items: stretch; } + + .shell-omnibox-drawer.surface-drawer.is-end { + right: 0.75rem; + left: 0.75rem; + width: auto; + top: calc(var(--shell-header-height) + 0.75rem); + bottom: calc(var(--shell-mobile-nav-height) + 0.75rem); + } }