Fix omnibox overlay hosting
This commit is contained in:
@@ -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 | 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 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 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
|
### 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.
|
- 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.
|
- 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.
|
- 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
|
## Target Outcomes
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@
|
|||||||
}
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<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">
|
||||||
@ChildContent
|
@ChildContent
|
||||||
|
|||||||
313
src/RolemasterDb.App/Components/Shell/ShellOmniboxPalette.razor
Normal file
313
src/RolemasterDb.App/Components/Shell/ShellOmniboxPalette.razor
Normal file
@@ -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)
|
||||||
|
{
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shell-omnibox-backdrop"
|
||||||
|
aria-label="Close search panel"
|
||||||
|
@onclick="CloseAsync"></button>
|
||||||
|
|
||||||
|
<section class="shell-omnibox-palette" role="dialog" aria-modal="true" aria-label="Search tables and commands">
|
||||||
|
<header class="shell-omnibox-header">
|
||||||
|
<label class="shell-omnibox-search">
|
||||||
|
<span class="visually-hidden">Search tables or commands</span>
|
||||||
|
<input
|
||||||
|
@ref="queryInput"
|
||||||
|
class="input-shell shell-omnibox-input"
|
||||||
|
placeholder="Search tables or type /"
|
||||||
|
@bind="query"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@onkeydown="HandleInputKeyDownAsync" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="button" class="shell-omnibox-close" @onclick="CloseAsync">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="shell-omnibox-body">
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<InspectorSection Title="Searching" Description="Loading the critical table index and shortcuts.">
|
||||||
|
<StatusIndicator Tone="info">Loading searchable tables…</StatusIndicator>
|
||||||
|
</InspectorSection>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (MatchingCommands.Count > 0)
|
||||||
|
{
|
||||||
|
<InspectorSection Title="Commands" Description="Navigate directly to key workflows.">
|
||||||
|
<div class="shell-omnibox-results">
|
||||||
|
@foreach (var command in MatchingCommands)
|
||||||
|
{
|
||||||
|
<button type="button" class="shell-omnibox-result" @onclick="() => OpenCommandAsync(command.Href)">
|
||||||
|
<span class="shell-omnibox-result-main">
|
||||||
|
<strong>@command.Shortcut</strong>
|
||||||
|
<span>@command.Description</span>
|
||||||
|
</span>
|
||||||
|
<span class="shell-omnibox-result-meta">
|
||||||
|
<StatusChip Tone="neutral">@command.Label</StatusChip>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</InspectorSection>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (MatchingPinned.Count > 0)
|
||||||
|
{
|
||||||
|
<InspectorSection Title="Pinned tables" Description="Saved shortcuts available across destinations.">
|
||||||
|
<div class="shell-omnibox-results">
|
||||||
|
@foreach (var table in MatchingPinned)
|
||||||
|
{
|
||||||
|
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Slug)">
|
||||||
|
<span class="shell-omnibox-result-main">
|
||||||
|
<strong>@table.Label</strong>
|
||||||
|
<span>@table.Family</span>
|
||||||
|
</span>
|
||||||
|
<span class="shell-omnibox-result-meta">
|
||||||
|
<StatusChip Tone="accent">Pinned</StatusChip>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</InspectorSection>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (MatchingRecent.Count > 0)
|
||||||
|
{
|
||||||
|
<InspectorSection Title="Recent tables" Description="Resume recently opened critical tables.">
|
||||||
|
<div class="shell-omnibox-results">
|
||||||
|
@foreach (var table in MatchingRecent)
|
||||||
|
{
|
||||||
|
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Slug)">
|
||||||
|
<span class="shell-omnibox-result-main">
|
||||||
|
<strong>@table.Label</strong>
|
||||||
|
<span>@table.Family</span>
|
||||||
|
</span>
|
||||||
|
<span class="shell-omnibox-result-meta">
|
||||||
|
<StatusChip Tone="info">Recent</StatusChip>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</InspectorSection>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (MatchingTables.Count > 0)
|
||||||
|
{
|
||||||
|
<InspectorSection Title="Tables" Description="Open a critical table in reference mode.">
|
||||||
|
<div class="shell-omnibox-results">
|
||||||
|
@foreach (var table in MatchingTables)
|
||||||
|
{
|
||||||
|
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Key)">
|
||||||
|
<span class="shell-omnibox-result-main">
|
||||||
|
<strong>@table.Label</strong>
|
||||||
|
<span>@table.Family</span>
|
||||||
|
</span>
|
||||||
|
<span class="shell-omnibox-result-meta">
|
||||||
|
@if (PinnedTablesState.IsPinned(table.Key))
|
||||||
|
{
|
||||||
|
<StatusChip Tone="accent">Pinned</StatusChip>
|
||||||
|
}
|
||||||
|
<span class="chip">@($"{table.CurationPercentage}%")</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</InspectorSection>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!HasResults)
|
||||||
|
{
|
||||||
|
<InspectorSection Title="No matches" Description="Try a different table name, family, or slash command.">
|
||||||
|
<StatusIndicator Tone="warning">Nothing matches “@query”.</StatusIndicator>
|
||||||
|
</InspectorSection>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private static readonly IReadOnlyList<ShellOmniboxCommand> 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<CriticalTableReference> MatchingTables =>
|
||||||
|
referenceData?.CriticalTables
|
||||||
|
.Where(MatchesTableQuery)
|
||||||
|
.Take(8)
|
||||||
|
.ToList()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
private IReadOnlyList<PinnedTableEntry> MatchingPinned =>
|
||||||
|
PinnedTablesState.Items
|
||||||
|
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
|
||||||
|
.Take(6)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private IReadOnlyList<RecentTableEntry> MatchingRecent =>
|
||||||
|
RecentTablesState.Items
|
||||||
|
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
|
||||||
|
.Take(6)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private IReadOnlyList<ShellOmniboxCommand> MatchingCommands =>
|
||||||
|
Commands
|
||||||
|
.Where(command => QueryStartsCommandMode()
|
||||||
|
? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||||
|
: MatchesText(command.Shortcut, command.Label, command.Description))
|
||||||
|
.Take(5)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,317 +1,19 @@
|
|||||||
@implements IDisposable
|
@inject RolemasterDb.App.Frontend.AppState.ShellOmniboxState ShellOmniboxState
|
||||||
@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
|
|
||||||
|
|
||||||
<div class="shell-omnibox">
|
<div class="shell-omnibox">
|
||||||
<AppBarActionButton
|
<AppBarActionButton
|
||||||
CssClass="@(isOpen ? "shell-omnibox-trigger is-open" : "shell-omnibox-trigger")"
|
CssClass="@(ShellOmniboxState.IsOpen ? "shell-omnibox-trigger is-open" : "shell-omnibox-trigger")"
|
||||||
Title="Search tables or commands"
|
Title="Search tables or commands"
|
||||||
AriaLabel="Search tables or commands"
|
AriaLabel="Search tables or commands"
|
||||||
OnClick="ToggleOpenAsync">
|
OnClick="ToggleOpenAsync">
|
||||||
<span class="shell-omnibox-trigger-label">Search tables or commands</span>
|
<span class="shell-omnibox-trigger-label">Search tables or commands</span>
|
||||||
</AppBarActionButton>
|
</AppBarActionButton>
|
||||||
|
|
||||||
@if (isOpen)
|
|
||||||
{
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="shell-omnibox-backdrop"
|
|
||||||
aria-label="Close search panel"
|
|
||||||
@onclick="CloseAsync"></button>
|
|
||||||
|
|
||||||
<section class="shell-omnibox-palette" role="dialog" aria-modal="true" aria-label="Search tables and commands">
|
|
||||||
<header class="shell-omnibox-header">
|
|
||||||
<label class="shell-omnibox-search">
|
|
||||||
<span class="visually-hidden">Search tables or commands</span>
|
|
||||||
<input
|
|
||||||
@ref="queryInput"
|
|
||||||
class="input-shell shell-omnibox-input"
|
|
||||||
placeholder="Search tables or type /"
|
|
||||||
@bind="query"
|
|
||||||
@bind:event="oninput"
|
|
||||||
@onkeydown="HandleInputKeyDownAsync" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="button" class="shell-omnibox-close" @onclick="CloseAsync">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="shell-omnibox-body">
|
|
||||||
@if (isLoading)
|
|
||||||
{
|
|
||||||
<InspectorSection Title="Searching" Description="Loading the critical table index and shortcuts.">
|
|
||||||
<StatusIndicator Tone="info">Loading searchable tables…</StatusIndicator>
|
|
||||||
</InspectorSection>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@if (MatchingCommands.Count > 0)
|
|
||||||
{
|
|
||||||
<InspectorSection Title="Commands" Description="Navigate directly to key workflows.">
|
|
||||||
<div class="shell-omnibox-results">
|
|
||||||
@foreach (var command in MatchingCommands)
|
|
||||||
{
|
|
||||||
<button type="button" class="shell-omnibox-result" @onclick="() => OpenCommandAsync(command.Href)">
|
|
||||||
<span class="shell-omnibox-result-main">
|
|
||||||
<strong>@command.Shortcut</strong>
|
|
||||||
<span>@command.Description</span>
|
|
||||||
</span>
|
|
||||||
<span class="shell-omnibox-result-meta">
|
|
||||||
<StatusChip Tone="neutral">@command.Label</StatusChip>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</InspectorSection>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (MatchingPinned.Count > 0)
|
|
||||||
{
|
|
||||||
<InspectorSection Title="Pinned tables" Description="Saved shortcuts available across destinations.">
|
|
||||||
<div class="shell-omnibox-results">
|
|
||||||
@foreach (var table in MatchingPinned)
|
|
||||||
{
|
|
||||||
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Slug)">
|
|
||||||
<span class="shell-omnibox-result-main">
|
|
||||||
<strong>@table.Label</strong>
|
|
||||||
<span>@table.Family</span>
|
|
||||||
</span>
|
|
||||||
<span class="shell-omnibox-result-meta">
|
|
||||||
<StatusChip Tone="accent">Pinned</StatusChip>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</InspectorSection>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (MatchingRecent.Count > 0)
|
|
||||||
{
|
|
||||||
<InspectorSection Title="Recent tables" Description="Resume recently opened critical tables.">
|
|
||||||
<div class="shell-omnibox-results">
|
|
||||||
@foreach (var table in MatchingRecent)
|
|
||||||
{
|
|
||||||
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Slug)">
|
|
||||||
<span class="shell-omnibox-result-main">
|
|
||||||
<strong>@table.Label</strong>
|
|
||||||
<span>@table.Family</span>
|
|
||||||
</span>
|
|
||||||
<span class="shell-omnibox-result-meta">
|
|
||||||
<StatusChip Tone="info">Recent</StatusChip>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</InspectorSection>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (MatchingTables.Count > 0)
|
|
||||||
{
|
|
||||||
<InspectorSection Title="Tables" Description="Open a critical table in reference mode.">
|
|
||||||
<div class="shell-omnibox-results">
|
|
||||||
@foreach (var table in MatchingTables)
|
|
||||||
{
|
|
||||||
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Key)">
|
|
||||||
<span class="shell-omnibox-result-main">
|
|
||||||
<strong>@table.Label</strong>
|
|
||||||
<span>@table.Family</span>
|
|
||||||
</span>
|
|
||||||
<span class="shell-omnibox-result-meta">
|
|
||||||
@if (PinnedTablesState.IsPinned(table.Key))
|
|
||||||
{
|
|
||||||
<StatusChip Tone="accent">Pinned</StatusChip>
|
|
||||||
}
|
|
||||||
<span class="chip">@($"{table.CurationPercentage}%")</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</InspectorSection>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!HasResults)
|
|
||||||
{
|
|
||||||
<InspectorSection Title="No matches" Description="Try a different table name, family, or slash command.">
|
|
||||||
<StatusIndicator Tone="warning">Nothing matches “@query”.</StatusIndicator>
|
|
||||||
</InspectorSection>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private static readonly IReadOnlyList<ShellOmniboxCommand> Commands =
|
private Task ToggleOpenAsync(MouseEventArgs _)
|
||||||
[
|
|
||||||
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<CriticalTableReference> MatchingTables =>
|
|
||||||
referenceData?.CriticalTables
|
|
||||||
.Where(MatchesTableQuery)
|
|
||||||
.Take(8)
|
|
||||||
.ToList()
|
|
||||||
?? [];
|
|
||||||
|
|
||||||
private IReadOnlyList<PinnedTableEntry> MatchingPinned =>
|
|
||||||
PinnedTablesState.Items
|
|
||||||
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
|
|
||||||
.Take(6)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
private IReadOnlyList<RecentTableEntry> MatchingRecent =>
|
|
||||||
RecentTablesState.Items
|
|
||||||
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
|
|
||||||
.Take(6)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
private IReadOnlyList<ShellOmniboxCommand> MatchingCommands =>
|
|
||||||
Commands
|
|
||||||
.Where(command => QueryStartsCommandMode()
|
|
||||||
? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase)
|
|
||||||
: MatchesText(command.Shortcut, command.Label, command.Description))
|
|
||||||
.Take(5)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
{
|
||||||
NavigationManager.LocationChanged += HandleLocationChanged;
|
ShellOmniboxState.Toggle();
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
return Task.CompletedTask;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/RolemasterDb.App/Frontend/AppState/ShellOmniboxState.cs
Normal file
41
src/RolemasterDb.App/Frontend/AppState/ShellOmniboxState.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ builder.Services.AddScoped<LookupService>();
|
|||||||
builder.Services.AddScoped<BrowserStorageService>();
|
builder.Services.AddScoped<BrowserStorageService>();
|
||||||
builder.Services.AddScoped<PinnedTablesState>();
|
builder.Services.AddScoped<PinnedTablesState>();
|
||||||
builder.Services.AddScoped<RecentTablesState>();
|
builder.Services.AddScoped<RecentTablesState>();
|
||||||
|
builder.Services.AddScoped<ShellOmniboxState>();
|
||||||
builder.Services.AddScoped<TableContextState>();
|
builder.Services.AddScoped<TableContextState>();
|
||||||
builder.Services.AddSingleton<TableContextUrlSerializer>();
|
builder.Services.AddSingleton<TableContextUrlSerializer>();
|
||||||
builder.Services.AddScoped<ThemeState>();
|
builder.Services.AddScoped<ThemeState>();
|
||||||
|
|||||||
Reference in New Issue
Block a user