diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 727d86d..a8a94be 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -60,6 +60,7 @@ It is intentionally implementation-focused: | 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. | +| 2026-03-21 | Post-P2 fix 3 | Completed | Moved omnibox overlay ownership from the header subtree into `AppShell` itself via a shared omnibox state service and a top-level palette host, which restored full-screen backdrop coverage and reliable outside-click close behavior. | ### Lessons Learned @@ -89,6 +90,7 @@ It is intentionally implementation-focused: - 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. +- Backdrop and outside-click behavior depend on overlay ownership as much as CSS. If the trigger owns the overlay inside a sticky header subtree, fixed-position assumptions can break; shell-level overlays should be rendered by the shell, not by individual header controls. ## Target Outcomes diff --git a/src/RolemasterDb.App/Components/Shell/AppShell.razor b/src/RolemasterDb.App/Components/Shell/AppShell.razor index cc01ecc..b1869f6 100644 --- a/src/RolemasterDb.App/Components/Shell/AppShell.razor +++ b/src/RolemasterDb.App/Components/Shell/AppShell.razor @@ -65,6 +65,8 @@ } + +
@ChildContent diff --git a/src/RolemasterDb.App/Components/Shell/ShellOmniboxPalette.razor b/src/RolemasterDb.App/Components/Shell/ShellOmniboxPalette.razor new file mode 100644 index 0000000..c47ed98 --- /dev/null +++ b/src/RolemasterDb.App/Components/Shell/ShellOmniboxPalette.razor @@ -0,0 +1,313 @@ +@implements IDisposable +@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 +@inject RolemasterDb.App.Frontend.AppState.ShellOmniboxState ShellOmniboxState + +@if (ShellOmniboxState.IsOpen) +{ + + + +} + +@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 ElementReference queryInput; + private LookupReferenceData? referenceData; + 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) + .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 => QueryStartsCommandMode() + ? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase) + : MatchesText(command.Shortcut, command.Label, command.Description)) + .Take(5) + .ToList(); + + protected override void OnInitialized() + { + ShellOmniboxState.Changed += HandleStateChanged; + NavigationManager.LocationChanged += HandleLocationChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (shouldFocusInput && ShellOmniboxState.IsOpen) + { + shouldFocusInput = false; + await queryInput.FocusAsync(); + } + } + + public void Dispose() + { + ShellOmniboxState.Changed -= HandleStateChanged; + NavigationManager.LocationChanged -= HandleLocationChanged; + } + + private void HandleStateChanged() + { + _ = InvokeAsync(HandleStateChangedAsync); + } + + private async Task HandleStateChangedAsync() + { + if (ShellOmniboxState.IsOpen) + { + shouldFocusInput = true; + await EnsureLoadedAsync(); + } + else + { + query = string.Empty; + shouldFocusInput = false; + } + + await InvokeAsync(StateHasChanged); + } + + private void HandleLocationChanged(object? sender, LocationChangedEventArgs args) + { + if (!ShellOmniboxState.IsOpen) + { + return; + } + + ShellOmniboxState.Close(); + } + + 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 Task CloseAsync() + { + ShellOmniboxState.Close(); + return Task.CompletedTask; + } + + 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 TableContextSnapshot( + TableSlug: tableSlug, + Mode: TableContextMode.Reference); + + await TableContextState.PersistAsync("tables", snapshot); + ShellOmniboxState.Close(); + NavigationManager.NavigateTo(TableContextState.BuildUri("/tables", snapshot)); + } + + private Task OpenCommandAsync(string href) + { + ShellOmniboxState.Close(); + NavigationManager.NavigateTo(href); + return Task.CompletedTask; + } + + 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)); + } + + private bool QueryStartsCommandMode() => + query.TrimStart().StartsWith("/", StringComparison.Ordinal); +} diff --git a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor index ee1106a..4d64998 100644 --- a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor +++ b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor @@ -1,317 +1,19 @@ -@implements IDisposable -@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 +@inject RolemasterDb.App.Frontend.AppState.ShellOmniboxState ShellOmniboxState
Search tables or commands - - @if (isOpen) - { - - - - }
@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 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) - .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 => QueryStartsCommandMode() - ? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase) - : MatchesText(command.Shortcut, command.Label, command.Description)) - .Take(5) - .ToList(); - - protected override void OnInitialized() + private Task ToggleOpenAsync(MouseEventArgs _) { - 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) - { - await CloseAsync(); - return; - } - - isOpen = true; - shouldFocusInput = true; - await EnsureLoadedAsync(); - } - - private Task CloseAsync() - { - isOpen = false; - query = string.Empty; - shouldFocusInput = false; + ShellOmniboxState.Toggle(); 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 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( - 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)); - } - - 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/Frontend/AppState/ShellOmniboxState.cs b/src/RolemasterDb.App/Frontend/AppState/ShellOmniboxState.cs new file mode 100644 index 0000000..ff0900f --- /dev/null +++ b/src/RolemasterDb.App/Frontend/AppState/ShellOmniboxState.cs @@ -0,0 +1,41 @@ +namespace RolemasterDb.App.Frontend.AppState; + +public sealed class ShellOmniboxState +{ + public bool IsOpen { get; private set; } + + public event Action? Changed; + + public void Open() + { + if (IsOpen) + { + return; + } + + IsOpen = true; + Changed?.Invoke(); + } + + public void Close() + { + if (!IsOpen) + { + return; + } + + IsOpen = false; + Changed?.Invoke(); + } + + public void Toggle() + { + if (IsOpen) + { + Close(); + return; + } + + Open(); + } +} diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index 5580603..b1f4296 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -15,6 +15,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped();