diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 3e6ea11..727d86d 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -59,6 +59,7 @@ It is intentionally implementation-focused: | 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. | | 2026-03-21 | Post-P2 fix 1 | Completed | Fixed the shell omnibox drawer regression by adding explicit shell offset variables, constraining drawer/body scrolling, and giving the omnibox its own backdrop geometry so the flyout opens within the visible viewport instead of collapsing into invalid top/bottom positioning. | +| 2026-03-21 | Post-P2 fix 2 | Completed | Rebuilt the shell omnibox as a dedicated command palette instead of a repurposed drawer, with shell-owned overlay markup, explicit viewport-safe geometry, autofocus, Escape and navigation close behavior, and a stable scrollable result body. | ### Lessons Learned @@ -87,6 +88,7 @@ It is intentionally implementation-focused: - 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. - Shared overlay primitives should not depend on undeclared layout variables. If a drawer needs shell offsets, the shell must define them explicitly and overlay-specific backdrops should be adjustable instead of assuming full-screen dimming is always correct. +- A command palette is not just a styled drawer. It needs shell-owned geometry, predictable focus behavior, and a bounded scroll region; treating it as a generic side panel led directly to the layout regressions found in Phase 2. ## Target Outcomes diff --git a/src/RolemasterDb.App/Components/Primitives/SurfaceDrawer.razor b/src/RolemasterDb.App/Components/Primitives/SurfaceDrawer.razor index a303184..b9d7268 100644 --- a/src/RolemasterDb.App/Components/Primitives/SurfaceDrawer.razor +++ b/src/RolemasterDb.App/Components/Primitives/SurfaceDrawer.razor @@ -2,7 +2,7 @@ { @@ -61,9 +61,6 @@ [Parameter] public string? CssClass { get; set; } - [Parameter] - public string? BackdropCssClass { get; set; } - private string BuildCssClass() { var classes = new List { "surface-drawer", $"is-{Placement.Trim().ToLowerInvariant()}" }; @@ -75,17 +72,6 @@ return string.Join(' ', classes); } - private string BuildBackdropCssClass() - { - var classes = new List { "surface-drawer-backdrop" }; - if (!string.IsNullOrWhiteSpace(BackdropCssClass)) - { - classes.Add(BackdropCssClass); - } - - return string.Join(' ', classes); - } - private Task HandleCloseAsync() => OnClose.InvokeAsync(); } diff --git a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor index cd03b91..ee1106a 100644 --- a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor +++ b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor @@ -1,3 +1,4 @@ +@implements IDisposable @using System.Linq @using RolemasterDb.App.Frontend.AppState @inject NavigationManager NavigationManager @@ -6,127 +7,145 @@ @inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState @inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState - - Search tables or commands - +
+ + Search tables or commands + - -
- + @if (isOpen) + { + - @if (isLoading) - { -

Loading searchable tables…

- } - else - { - @if (MatchingTables.Count > 0) - { - -
- @foreach (var table in MatchingTables) - { - - } -
-
- } +
+ + } +
@code { private static readonly IReadOnlyList Commands = @@ -138,11 +157,19 @@ new("/api", "API", "Open API surface documentation.", "/tools/api") ]; + private ElementReference queryInput; private LookupReferenceData? referenceData; private bool isOpen; private bool isLoading; + private bool shouldFocusInput; private string query = string.Empty; + private bool HasResults => + MatchingCommands.Count > 0 || + MatchingPinned.Count > 0 || + MatchingRecent.Count > 0 || + MatchingTables.Count > 0; + private IReadOnlyList MatchingTables => referenceData?.CriticalTables .Where(MatchesTableQuery) @@ -164,10 +191,26 @@ private IReadOnlyList MatchingCommands => Commands - .Where(command => MatchesText(command.Shortcut, command.Label, command.Description)) + .Where(command => QueryStartsCommandMode() + ? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase) + : MatchesText(command.Shortcut, command.Label, command.Description)) .Take(5) .ToList(); + protected override void OnInitialized() + { + NavigationManager.LocationChanged += HandleLocationChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (shouldFocusInput && isOpen) + { + shouldFocusInput = false; + await queryInput.FocusAsync(); + } + } + private async Task ToggleOpenAsync(MouseEventArgs _) { if (isOpen) @@ -177,6 +220,7 @@ } isOpen = true; + shouldFocusInput = true; await EnsureLoadedAsync(); } @@ -184,6 +228,7 @@ { isOpen = false; query = string.Empty; + shouldFocusInput = false; return Task.CompletedTask; } @@ -208,6 +253,14 @@ } } + private async Task HandleInputKeyDownAsync(KeyboardEventArgs args) + { + if (string.Equals(args.Key, "Escape", StringComparison.Ordinal)) + { + await CloseAsync(); + } + } + private async Task OpenTableAsync(string tableSlug) { var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot( @@ -240,4 +293,25 @@ !string.IsNullOrWhiteSpace(value) && value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)); } + + private bool QueryStartsCommandMode() => + query.TrimStart().StartsWith("/", StringComparison.Ordinal); + + private void HandleLocationChanged(object? sender, LocationChangedEventArgs args) + { + if (!isOpen) + { + return; + } + + isOpen = false; + query = string.Empty; + shouldFocusInput = false; + _ = InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + NavigationManager.LocationChanged -= HandleLocationChanged; + } } diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index c967aa0..c734b8c 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -958,21 +958,52 @@ pre, overflow: auto; } -.shell-omnibox-backdrop.surface-drawer-backdrop { - top: calc(var(--shell-header-height, 5.75rem) + 0.5rem); - bottom: calc(var(--shell-mobile-nav-height, 0rem) + 0rem); +.shell-omnibox { + position: relative; } -.shell-omnibox-drawer.surface-drawer.is-end { +.shell-omnibox-trigger.is-open { + border-color: var(--border-strong); + background: var(--surface-elevated); +} + +.shell-omnibox-backdrop { + position: fixed; + top: calc(var(--shell-header-height, 5.75rem) + 0.35rem); + right: 0; + bottom: calc(var(--shell-mobile-nav-height, 0rem)); + left: 0; + z-index: 90; + border: none; + background: color-mix(in srgb, var(--bg-overlay) 85%, transparent); +} + +.shell-omnibox-palette { + position: fixed; top: calc(var(--shell-header-height, 5.75rem) + 0.75rem); - bottom: 1rem; - width: min(34rem, calc(100vw - 2rem)); + left: 50%; + z-index: 95; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(46rem, calc(100vw - 2rem)); + max-height: calc(100dvh - var(--shell-header-height, 5.75rem) - var(--shell-mobile-nav-height, 0rem) - 1.75rem); + border: 1px solid var(--border-default); + border-radius: 1.5rem; + background: color-mix(in srgb, var(--bg-elevated) 96%, transparent); + box-shadow: var(--shadow-2); + backdrop-filter: blur(22px); + transform: translateX(-50%); + overflow: hidden; } -.shell-omnibox-panel { +.shell-omnibox-header { display: grid; - gap: 1rem; - min-height: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.85rem; + padding: 1rem; + border-bottom: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--surface-2) 92%, transparent); } .shell-omnibox-search { @@ -984,6 +1015,24 @@ pre, min-height: 3rem; } +.shell-omnibox-close { + min-height: 2.75rem; + padding: 0.65rem 0.95rem; + border: 1px solid var(--border-default); + border-radius: 999px; + background: color-mix(in srgb, var(--surface-2) 84%, transparent); + color: var(--text-primary); +} + +.shell-omnibox-body { + display: grid; + gap: 0.9rem; + min-height: 0; + padding: 1rem; + overflow: auto; + overscroll-behavior: contain; +} + .shell-omnibox-results { display: grid; gap: 0.6rem; @@ -2067,11 +2116,21 @@ pre, align-items: stretch; } - .shell-omnibox-drawer.surface-drawer.is-end { + .shell-omnibox-backdrop { + top: calc(var(--shell-header-height, 5.1rem) + 0.25rem); + bottom: calc(var(--shell-mobile-nav-height, 5.5rem)); + } + + .shell-omnibox-palette { + top: calc(var(--shell-header-height, 5.1rem) + 0.45rem); right: 0.75rem; left: 0.75rem; width: auto; - top: calc(var(--shell-header-height, 5.1rem) + 0.5rem); - bottom: calc(var(--shell-mobile-nav-height, 5.5rem) + 0.5rem); + max-height: calc(100dvh - var(--shell-header-height, 5.1rem) - var(--shell-mobile-nav-height, 5.5rem) - 1rem); + transform: none; + } + + .shell-omnibox-header { + grid-template-columns: 1fr; } }